Compare commits

..
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison ac4eafc3a0 fix: detect single-backslash, home-relative and bare relative Windows paths
Addresses Gemini and Greptile review feedback: is_path_like now recognizes
\path, ~\path and relative paths containing backslashes (e.g. knowledge\docs)
so equivalent Windows configurations resolve as knowledge sources.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-27 02:07:57 +00:00
825 changed files with 46593 additions and 85185 deletions

No files matched your search

@@ -1,66 +0,0 @@
name: Install PraisonAI monorepo packages
description: Install praisonai-agents, praisonai-code, and praisonai wrapper from local paths (mirrors PyPI three-package install order).
inputs:
repo-root:
description: Path prefix to monorepo root (e.g. PraisonAI/ for cross-repo checkout)
required: false
default: ""
use-uv:
description: Use uv pip instead of pip
required: false
default: "false"
agents-extras:
description: Optional extras for praisonaiagents (e.g. .[all] or [all])
required: false
default: ""
wrapper-extras:
description: Optional extras for praisonai wrapper (e.g. .[chat,code] or [chat,code])
required: false
default: ""
runs:
using: composite
steps:
- name: Install monorepo packages
shell: bash
run: |
set -euo pipefail
WORKSPACE="${GITHUB_WORKSPACE:-$(pwd)}"
ROOT="${{ inputs.repo-root }}"
if [ -n "$ROOT" ]; then
BASE="${WORKSPACE%/}/${ROOT%/}/"
else
BASE="${WORKSPACE%/}/"
fi
AGENTS_PKG="${BASE}src/praisonai-agents"
CODE_PKG="${BASE}src/praisonai-code"
WRAPPER_PKG="${BASE}src/praisonai"
normalise_extras() {
local value="${1#.}"
if [ -n "$value" ]; then
printf '.%s' "$value"
else
printf '.'
fi
}
AGENTS_SUFFIX="$(normalise_extras "${{ inputs.agents-extras }}")"
WRAPPER_SUFFIX="$(normalise_extras "${{ inputs.wrapper-extras }}")"
if [ "${{ inputs.use-uv }}" = "true" ]; then
cd "$AGENTS_PKG"
uv pip install --system -e "${AGENTS_SUFFIX}"
cd "$CODE_PKG"
uv pip install --system -e .
cd "$WRAPPER_PKG"
uv pip install --system "${WRAPPER_SUFFIX}"
else
cd "$AGENTS_PKG"
pip install -e "${AGENTS_SUFFIX}"
cd "$CODE_PKG"
pip install -e .
cd "$WRAPPER_PKG"
pip install -e "${WRAPPER_SUFFIX}"
fi
-59
View File
@@ -27,40 +27,6 @@ const withRecovery = [
];
assert('not stale when @claude after head', !mg.isStaleFinalAfterPush(withRecovery, '2026-06-12T09:00:00Z'));
// Stale-FINAL recovery guards (PR #2560 push loop)
const nowMs = Date.now();
const iso = (ms) => new Date(ms).toISOString();
const firstFinal = {
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: iso(nowMs - 20 * 60 * 1000),
};
const pushSoonAfter = mg.shouldSkipStaleFinalRecovery(
[firstFinal],
iso(nowMs - 16 * 60 * 1000)
);
assert('debounce push soon after FINAL', pushSoonAfter.skip && pushSoonAfter.reason.includes('soon after'));
const secondFinal = {
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: iso(nowMs - 20 * 60 * 1000),
};
const capped = mg.shouldSkipStaleFinalRecovery(
[firstFinal, secondFinal],
iso(nowMs - 2 * 60 * 1000)
);
assert('hourly cap blocks third FINAL in window', capped.skip && capped.reason.includes('capped'));
const botPush = mg.shouldSkipStaleFinalRecovery(
[firstFinal],
iso(nowMs - 16 * 60 * 1000),
'praisonai-triage-agent[bot]'
);
assert('skip when automation pushed head', botPush.skip && botPush.reason.includes('automation'));
assert('claude automation login', mg.isClaudeAutomationLogin('praisonai-triage-agent[bot]'));
// Bot CHANGES_REQUESTED then APPROVE
const reviews = [
{ user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T08:00:00Z' },
@@ -85,31 +51,6 @@ assert('verdict after head accepted', mg.findMergeGateVerdict(
const noise = [{ user: { login: 'MervinPraison' }, body: '**Merge gate scan** — wait for `@claude`', created_at: new Date().toISOString() }];
assert('diagnostic comment not a trigger', !mg.hasRecentClaudeTrigger(noise, 35));
// Cooldown skip requires an actual verdict on HEAD, not just a FINAL trigger comment
const recentFinalOnHead = [
{
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: '2026-06-27T10:00:00Z',
},
];
assert(
'final trigger alone does not count as verdict on head',
mg.findMergeGateVerdict(recentFinalOnHead, null, '2026-06-27T09:55:00Z') === null
);
const recentVerdictOnHead = [
...recentFinalOnHead,
{
user: { login: 'github-actions[bot]' },
body: 'MERGE_GATE_VERDICT: APPROVE',
created_at: '2026-06-27T10:05:00Z',
},
];
assert(
'verdict on head skips cooldown gate',
mg.findMergeGateVerdict(recentVerdictOnHead, null, '2026-06-27T09:55:00Z') === 'APPROVE'
);
// Sensitive + secrets
assert('workflow path sensitive', mg.sensitivePathReasons([{ filename: '.github/workflows/foo.yml' }]).length === 1);
assert('ci-only label exempts workflows', mg.sensitivePathReasons(
+7 -62
View File
@@ -7,9 +7,7 @@ const CLAUDE_TRIGGER_LOGINS = ['MervinPraison', 'github-actions[bot]'];
const AUTO_ACTORS = CLAUDE_TRIGGER_LOGINS;
const CONFLICT_COOLDOWN_MS = 12 * 60 * 60 * 1000;
const CLAUDE_ACTIVE_MS = 35 * 60 * 1000;
const STALE_FINAL_RECOVERY_WINDOW_MS = 60 * 60 * 1000;
const STALE_FINAL_MAX_PER_WINDOW = 2;
const PUSH_AFTER_FINAL_DEBOUNCE_MS = 15 * 60 * 1000;
const POST_PUSH_BUFFER_MS = 5 * 60 * 1000;
const ALLOWED_MERGE_STATES = new Set(['CLEAN', 'UNSTABLE']);
const AGENT_PY_MAX_AUTO_LINES = 100;
const PR_MAX_AUTO_ADDITIONS = 800;
@@ -197,55 +195,6 @@ function shouldSkipFinalRecovery(comments, headPushedAt) {
return hasRecentClaudeTrigger(comments, 35);
}
function countFinalTriggersSince(comments, sinceMs) {
return comments.filter(
(c) => isFinalClaudeTriggerComment(c) && new Date(c.created_at).getTime() > sinceMs
).length;
}
function isClaudeAutomationLogin(login) {
const lower = (login || '').toLowerCase();
return lower.includes('praisonai-triage') || lower === 'github-actions[bot]';
}
function isPushSoonAfterLatestFinal(comments, headPushedAt) {
if (!headPushedAt) return false;
const finals = comments.filter(isFinalClaudeTriggerComment);
if (finals.length === 0) return false;
const latestFinal = finals.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const finalTime = new Date(latestFinal.created_at).getTime();
const headTime = new Date(headPushedAt).getTime();
if (headTime <= finalTime) return false;
return headTime - finalTime < PUSH_AFTER_FINAL_DEBOUNCE_MS;
}
/** Returns { skip: true, reason } when stale-FINAL recovery should not post. */
function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin = null) {
if (!isStaleFinalAfterPush(comments, headPushedAt)) {
return { skip: true, reason: 'not stale' };
}
if (headPusherLogin && isClaudeAutomationLogin(headPusherLogin)) {
return { skip: true, reason: 'head pushed by Claude automation' };
}
if (isPushSoonAfterLatestFinal(comments, headPushedAt)) {
return {
skip: true,
reason: 'head pushed soon after FINAL (wait for CI / batched fixes)',
};
}
const windowStart = Date.now() - STALE_FINAL_RECOVERY_WINDOW_MS;
const finalsInWindow = countFinalTriggersSince(comments, windowStart);
if (finalsInWindow >= STALE_FINAL_MAX_PER_WINDOW) {
return {
skip: true,
reason: `stale-FINAL capped (${finalsInWindow} FINAL triggers in last hour)`,
};
}
return { skip: false, reason: '' };
}
function finalClaudeCompletedOnSha(comments, headPushedAt) {
if (!hasFinalClaudeReviewTrigger(comments)) return false;
if (isStaleFinalAfterPush(comments, headPushedAt)) return false;
@@ -654,16 +603,15 @@ async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, op
reasons.push('recent merge-conflict @claude');
}
if (!skipRecentClaudeCooldown && hasRecentClaudeTrigger(ctx.comments, 35)) {
const verdictOnHead = findMergeGateVerdict(ctx.comments, null, ctx.headPushedAt) !== null;
if (!verdictOnHead) reasons.push('recent @claude within 35min');
reasons.push('recent @claude within 35min');
}
if (!skipGlobalClaudeRunCheck && (await hasInProgressClaudeAssistant(github, owner, repo, prNumber))) {
reasons.push('claude.yml in progress');
}
const checksOk = await allChecksGreenOnSha(github, owner, repo, ctx.headSha, core);
if (!checksOk) reasons.push('CI not green on HEAD');
const headTime = new Date(ctx.headPushedAt).getTime();
if (Date.now() - headTime < POST_PUSH_BUFFER_MS) reasons.push('post-push buffer (<5min)');
if (!finalClaudeCompletedOnSha(ctx.comments, ctx.headPushedAt)) {
if (!hasFinalClaudeReviewTrigger(ctx.comments)) {
@@ -698,6 +646,9 @@ async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, op
const sdkCheckReason = await sdkTestChecksReason(github, owner, repo, ctx.headSha, pullFiles, core);
if (sdkCheckReason) reasons.push(sdkCheckReason);
const checksOk = await allChecksGreenOnSha(github, owner, repo, ctx.headSha, core);
if (!checksOk) reasons.push('CI not green on HEAD');
if (ctx.pr.mergeable === false) reasons.push('not mergeable');
const ready = reasons.length === 0;
@@ -788,12 +739,6 @@ module.exports = {
isStaleFinalAfterPush,
needsStaleFinalRecovery,
shouldSkipFinalRecovery,
shouldSkipStaleFinalRecovery,
isClaudeAutomationLogin,
isPushSoonAfterLatestFinal,
countFinalTriggersSince,
STALE_FINAL_RECOVERY_WINDOW_MS,
STALE_FINAL_MAX_PER_WINDOW,
FINAL_CLAUDE_REVIEW_BODY,
loadPrContext,
evaluatePipelineQuiescent,
+4 -47
View File
@@ -155,39 +155,11 @@ async function syncPipelineLabels(github, owner, repo, prNumber, core) {
}
core?.info?.(`PR #${prNumber}: stage=${stage} blockers=[${blockers.join(', ')}]`);
return {
synced: true,
stage,
blockers,
ready: evalResult.ready,
reasons: evalResult.reasons,
labels: ctx.labels,
createdAt: new Date(ctx.pr.created_at).getTime(),
};
}
async function dispatchMergeGateForOldestReady(github, owner, repo, readyCandidates, core) {
if (!readyCandidates.length) return 0;
readyCandidates.sort((a, b) => a.createdAt - b.createdAt);
for (const cand of readyCandidates) {
if ((cand.labels || []).includes('claude-merge-gate-active')) {
core?.info?.(`Skip dispatch PR #${cand.prNumber}: merge gate already active`);
continue;
}
await github.rest.repos.createDispatchEvent({
owner,
repo,
event_type: 'claude-merge-gate',
client_payload: { pr_number: cand.prNumber },
});
core?.info?.(`Dispatched merge gate for ready PR #${cand.prNumber}`);
return cand.prNumber;
}
return 0;
return { synced: true, stage, blockers, ready: evalResult.ready, reasons: evalResult.reasons };
}
async function syncOpenPullRequests(github, owner, repo, options, core) {
const { maxPrs = 20, dispatchMergeGate = true } = options || {};
const { maxPrs = 20 } = options || {};
await ensurePipelineLabels(github, owner, repo, core);
let prs;
if (maxPrs <= 100) {
@@ -206,28 +178,14 @@ async function syncOpenPullRequests(github, owner, repo, options, core) {
});
}
let synced = 0;
const readyCandidates = [];
for (const pr of prs) {
if (synced >= maxPrs) break;
if (pr.draft) continue;
if (pr.head?.repo?.full_name && pr.head.repo.full_name !== `${owner}/${repo}`) continue;
const result = await syncPipelineLabels(github, owner, repo, pr.number, core);
if (result.ready) {
readyCandidates.push({
prNumber: pr.number,
createdAt: result.createdAt || new Date(pr.created_at).getTime(),
labels: result.labels || [],
});
}
await syncPipelineLabels(github, owner, repo, pr.number, core);
synced += 1;
}
let dispatched = 0;
if (dispatchMergeGate && readyCandidates.length) {
dispatched = await dispatchMergeGateForOldestReady(
github, owner, repo, readyCandidates, core
);
}
core?.info?.(`Pipeline label sync complete (${synced} PR(s), dispatched=${dispatched || 'none'})`);
core?.info?.(`Pipeline label sync complete (${synced} PR(s))`);
return synced;
}
@@ -241,5 +199,4 @@ module.exports = {
ensurePipelineLabels,
syncPipelineLabels,
syncOpenPullRequests,
dispatchMergeGateForOldestReady,
};
-20
View File
@@ -1,20 +0,0 @@
const assert = require('assert');
const rg = require('./release-gate.js');
assert.strictEqual(rg.bumpPatch('1.6.80'), '1.6.81');
assert.strictEqual(rg.bumpPatch('4.6.80'), '4.6.81');
assert.strictEqual(rg.bumpPatch('0.0.2'), '0.0.3');
const versions = rg.readVersionsFromTree();
assert.ok(versions.currentAgents);
assert.ok(versions.currentCode);
assert.ok(versions.currentWrapper);
assert.ok(versions.targetCode);
assert.ok(rg.PACKAGE_PATHS.includes('src/praisonai-code'));
(async () => {
assert.strictEqual(await rg.pypiVersionExists('praisonaiagents', '0.0.0'), false);
console.log('ok: bumpPatch');
console.log('ok: readVersionsFromTree');
console.log('ok: pypiVersionExists missing version');
})();
-203
View File
@@ -1,203 +0,0 @@
/**
* Release gate preflight — path changes, CI SHA, dedupe, PyPI version checks.
*/
const https = require('https');
const PACKAGE_PATHS = ['src/praisonai', 'src/praisonai-agents', 'src/praisonai-code'];
const ACTIVE_RELEASE_STATUSES = new Set([
'queued', 'in_progress', 'waiting', 'pending', 'requested',
]);
function bumpPatch(version) {
const parts = version.split('.');
if (parts.length !== 3) throw new Error(`Invalid version: ${version}`);
return `${parts[0]}.${parts[1]}.${Number(parts[2]) + 1}`;
}
function readVersionsFromTree(root = '.') {
const fs = require('fs');
const path = require('path');
const agentsToml = fs.readFileSync(
path.join(root, 'src/praisonai-agents/pyproject.toml'), 'utf8'
);
const agentsMatch = agentsToml.match(/^version\s*=\s*"([^"]+)"/m);
if (!agentsMatch) throw new Error('Could not read agents version');
const codeToml = fs.readFileSync(
path.join(root, 'src/praisonai-code/pyproject.toml'), 'utf8'
);
const codeMatch = codeToml.match(/^version\s*=\s*"([^"]+)"/m);
if (!codeMatch) throw new Error('Could not read praisonai-code version');
const wrapperPy = fs.readFileSync(
path.join(root, 'src/praisonai/praisonai/version.py'), 'utf8'
);
const wrapperMatch = wrapperPy.match(/__version__ = "([^"]+)"/);
if (!wrapperMatch) throw new Error('Could not read wrapper version');
return {
currentAgents: agentsMatch[1],
currentCode: codeMatch[1],
currentWrapper: wrapperMatch[1],
targetAgents: bumpPatch(agentsMatch[1]),
targetCode: bumpPatch(codeMatch[1]),
targetWrapper: bumpPatch(wrapperMatch[1]),
};
}
function pypiVersionExists(packageName, version) {
return new Promise((resolve) => {
const req = https.get(
`https://pypi.org/pypi/${packageName}/${version}/json`,
{ timeout: 15000 },
(res) => {
res.resume();
resolve(res.statusCode === 200);
}
);
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
});
}
async function hasActiveReleaseRun(github, owner, repo) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'pypi-release.yml',
per_page: 20,
});
return runs.data.workflow_runs.some(
(r) => ACTIVE_RELEASE_STATUSES.has(r.status) && !r.conclusion
);
}
async function lastGreenCoreTestsSha(github, owner, repo) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'test-core.yml',
branch: 'main',
status: 'completed',
per_page: 30,
});
const hit = runs.data.workflow_runs.find((r) => r.conclusion === 'success');
return hit ? hit.head_sha : '';
}
async function evaluateReleasePreflight(github, owner, repo, options, core) {
const {
headSha,
isCiTrigger = false,
bump = 'patch',
} = options;
const reasons = [];
const out = {
ready: false,
reasons,
headSha: headSha || '',
lastTag: '',
targetAgents: '',
targetCode: '',
targetWrapper: '',
};
if (bump !== 'patch') {
reasons.push('only patch auto-release supported');
return out;
}
if (await hasActiveReleaseRun(github, owner, repo)) {
reasons.push('PyPI Release already in progress or awaiting approval');
return out;
}
let versions;
try {
versions = readVersionsFromTree();
} catch (err) {
reasons.push(err.message);
return out;
}
out.targetAgents = versions.targetAgents;
out.targetCode = versions.targetCode;
out.targetWrapper = versions.targetWrapper;
const agentsOnPypi = await pypiVersionExists('praisonaiagents', versions.targetAgents);
const codeOnPypi = await pypiVersionExists('praisonai-code', versions.targetCode);
const wrapperOnPypi = await pypiVersionExists('praisonai', versions.targetWrapper);
if (agentsOnPypi && codeOnPypi && wrapperOnPypi) {
reasons.push(
`already published: praisonaiagents==${versions.targetAgents}, `
+ `praisonai-code==${versions.targetCode}, praisonai==${versions.targetWrapper}`
);
return out;
}
const { execSync } = require('child_process');
let lastTag = '';
try {
lastTag = execSync('git describe --tags --match "v*" --abbrev=0', { encoding: 'utf8' }).trim();
} catch {
reasons.push('no v* tag found');
return out;
}
out.lastTag = lastTag;
let changed = '';
try {
changed = execSync(
`git diff --name-only ${lastTag} HEAD -- ${PACKAGE_PATHS.join(' ')}`,
{ encoding: 'utf8' }
).trim();
} catch {
reasons.push('git diff failed');
return out;
}
if (!changed) {
reasons.push(`no changes in ${PACKAGE_PATHS.join(' or ')} since ${lastTag}`);
return out;
}
if (isCiTrigger) {
let mainSha = '';
try {
mainSha = execSync('git rev-parse origin/main', { encoding: 'utf8' }).trim();
} catch {
reasons.push('could not resolve origin/main');
return out;
}
if (headSha !== mainSha) {
reasons.push(`superseded: green SHA ${headSha.slice(0, 7)} != main ${mainSha.slice(0, 7)}`);
return out;
}
out.headSha = headSha;
} else {
const evalSha = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
out.headSha = evalSha;
const greenSha = await lastGreenCoreTestsSha(github, owner, repo);
if (greenSha !== evalSha) {
reasons.push(`CI not green on HEAD (last green: ${greenSha ? greenSha.slice(0, 7) : 'none'})`);
return out;
}
}
out.ready = true;
out.reasons = ['ready'];
if (core) core.info(`Release preflight passed for ${out.headSha || headSha}`);
return out;
}
module.exports = {
bumpPatch,
readVersionsFromTree,
pypiVersionExists,
evaluateReleasePreflight,
ACTIVE_RELEASE_STATUSES,
PACKAGE_PATHS,
};
-20
View File
@@ -550,7 +550,6 @@ jobs:
const comments = await mergeGate.listAllComments(github, owner, repo, pr);
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr))
|| context.payload.pull_request.updated_at;
const headPusher = context.payload.sender?.login || null;
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
@@ -560,19 +559,9 @@ jobs:
return;
}
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr)) {
core.info('Claude workflow in progress — skip recovery');
return;
}
const finalBody = mergeGate.FINAL_CLAUDE_REVIEW_BODY;
if (hasFinal && isStale) {
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusher);
if (gate.skip) {
core.info(`Skip stale-FINAL recovery: ${gate.reason}`);
return;
}
await github.rest.issues.createComment({ issue_number: pr, owner, repo, body: finalBody });
core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`);
return;
@@ -640,15 +629,6 @@ jobs:
core.info(`PR #${pr.number}: stale FINAL but @claude within 10min — skip`);
continue;
}
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr.number)) {
core.info(`PR #${pr.number}: Claude workflow in progress — skip`);
continue;
}
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt);
if (gate.skip) {
core.info(`PR #${pr.number}: skip stale-FINAL — ${gate.reason}`);
continue;
}
await github.rest.issues.createComment({
issue_number: pr.number, owner, repo,
body: mergeGate.FINAL_CLAUDE_REVIEW_BODY,
+6 -36
View File
@@ -5,8 +5,7 @@ name: Claude PR Merge Gate
on:
schedule:
# Scan for merge-ready PRs every 10 minutes (assess runs only when candidates exist).
- cron: '*/10 * * * *'
- cron: '0 2,8,14,20 * * *'
workflow_dispatch:
inputs:
pr_number:
@@ -34,11 +33,11 @@ on:
concurrency:
group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || github.event.workflow_run.id || 'scan' }}
cancel-in-progress: false
cancel-in-progress: true
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
MAX_CANDIDATES: 1
MAX_CANDIDATES: 5
DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
permissions:
@@ -451,38 +450,9 @@ jobs:
}
const method = process.env.MERGE_METHOD || 'merge';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let merged = false;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await github.rest.pulls.merge({
owner, repo, pull_number: prNumber, merge_method: method,
});
merged = true;
break;
} catch (e) {
const msg = (e.message || '').toLowerCase();
if (attempt < 3 && msg.includes('base branch was modified')) {
core.info(`Merge attempt ${attempt} failed (base moved), retrying in 8s...`);
await sleep(8000);
const recheck = await mergeGate.evaluatePipelineQuiescent(
github, owner, repo, prNumber, core, { forMergeStep: true }
);
if (!recheck.ready) {
core.info(`Skip merge retry: ${recheck.reasons.join(', ')}`);
await removeLabels();
return;
}
continue;
}
throw e;
}
}
if (!merged) {
core.info('Skip merge: exhausted retries');
await removeLabels();
return;
}
await github.rest.pulls.merge({
owner, repo, pull_number: prNumber, merge_method: method,
});
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: ['auto-merged-by-gate'],
-3
View File
@@ -133,9 +133,6 @@ jobs:
github.actor != 'dependabot[bot]' &&
github.actor != 'cursor[bot]' &&
github.actor != 'renovate[bot]'
concurrency:
group: claude-response-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}
cancel-in-progress: false
runs-on: ubuntu-latest
permissions:
contents: write
+14 -21
View File
@@ -7,18 +7,12 @@ on:
- 'src/praisonai/praisonai/integration/**'
- 'src/praisonai/tests/integration/test_aiui_*'
- 'src/praisonai/pyproject.toml'
- 'src/praisonai-code/**'
- '.github/workflows/integration-cross-repo.yml'
- '.github/actions/install-monorepo-packages/**'
pull_request:
branches: [ main ]
paths:
- 'src/praisonai/praisonai/integration/**'
- 'src/praisonai/tests/integration/test_aiui_*'
- 'src/praisonai/pyproject.toml'
- 'src/praisonai-code/**'
- '.github/workflows/integration-cross-repo.yml'
- '.github/actions/install-monorepo-packages/**'
workflow_dispatch:
jobs:
@@ -46,16 +40,16 @@ jobs:
python-version: '3.11'
- name: Install dependencies
run: python -m pip install --upgrade pip
- uses: PraisonAI/.github/actions/install-monorepo-packages
with:
repo-root: PraisonAI/
wrapper-extras: ".[ui,dev]"
- name: Install PraisonAIUI
run: |
cd PraisonAIUI
python -m pip install --upgrade pip
# Install PraisonAI packages in development mode
cd PraisonAI/src/praisonai-agents
pip install -e .
cd ../praisonai
pip install -e ".[ui,dev]"
# Install PraisonAIUI in development mode
cd ../../../PraisonAIUI
pip install -e .
pip install pytest pytest-cov
- name: Verify versions
run: |
@@ -143,14 +137,13 @@ jobs:
python-version: '3.11'
- name: Install dependencies
run: python -m pip install --upgrade pip
- uses: PraisonAI/.github/actions/install-monorepo-packages
with:
repo-root: PraisonAI/
wrapper-extras: ".[ui,dev]"
- name: Install PraisonAIUI
run: |
cd PraisonAIUI
python -m pip install --upgrade pip
cd PraisonAI/src/praisonai-agents
pip install -e .
cd ../praisonai
pip install -e ".[ui,dev]"
cd ../../../PraisonAIUI
pip install -e .
- name: Run agentic integration test
+72 -84
View File
@@ -1,8 +1,7 @@
name: Nightly Release Gate
# Preflight before PyPI Release (patch bump).
# Primary: Core Tests success on main (workflow_run).
# Backstop: nightly cron at 00:00 UTC.
# Preflight checks before dispatching PyPI Release (patch bump).
# Runs nightly at 00:00 UTC (midnight).
on:
schedule:
@@ -14,13 +13,6 @@ on:
required: false
default: false
type: boolean
workflow_run:
workflows:
- Core Tests
types:
- completed
branches:
- main
permissions:
contents: read
@@ -32,85 +24,83 @@ concurrency:
jobs:
preflight:
if: |
github.event_name == 'workflow_dispatch' ||
github.event_name == 'schedule' ||
(
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.event == 'push'
)
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
steps:
- name: Resolve trigger context
id: ctx
run: |
if [ "${{ github.event_name }}" = "workflow_run" ]; then
echo "checkout_sha=${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT"
echo "source=ci" >> "$GITHUB_OUTPUT"
echo "is_ci=true" >> "$GITHUB_OUTPUT"
else
echo "checkout_sha=main" >> "$GITHUB_OUTPUT"
echo "source=nightly" >> "$GITHUB_OUTPUT"
echo "is_ci=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout
- name: Checkout main
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ steps.ctx.outputs.checkout_sha }}
ref: main
fetch-depth: 0
- name: Fetch main and tags
run: git fetch --tags origin main
- name: Preflight — path changes, CI SHA, dedupe
- name: Preflight — path changes and CI green
id: preflight
uses: actions/github-script@v7
env:
HEAD_SHA: ${{ steps.ctx.outputs.checkout_sha }}
IS_CI: ${{ steps.ctx.outputs.is_ci }}
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const rg = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/release-gate.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const headSha = process.env.HEAD_SHA;
const isCi = process.env.IS_CI === 'true';
run: |
set -euo pipefail
const result = await rg.evaluateReleasePreflight(
github, owner, repo,
{ headSha, isCiTrigger: isCi, bump: 'patch' },
core
);
git fetch --tags origin
core.setOutput('should_release', result.ready ? 'true' : 'false');
core.setOutput('reason', result.reasons.join('; '));
core.setOutput('head_sha', result.headSha || headSha);
core.setOutput('last_tag', result.lastTag || '');
core.setOutput('target_agents', result.targetAgents || '');
core.setOutput('target_code', result.targetCode || '');
core.setOutput('target_wrapper', result.targetWrapper || '');
LAST_TAG=$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)
if [ -z "$LAST_TAG" ]; then
echo "No v* tag found — skip automated release (first release must be manual)"
echo "should_release=false" >> "$GITHUB_OUTPUT"
echo "reason=no_tag" >> "$GITHUB_OUTPUT"
exit 0
fi
core.summary.addRaw('## Release gate preflight\n');
core.summary.addRaw(`Ready: ${result.ready}\n`);
core.summary.addRaw(`Reason: ${result.reasons.join('; ')}\n`);
if (result.targetAgents) {
core.summary.addRaw(
`Targets: praisonaiagents==${result.targetAgents}, `
+ `praisonai-code==${result.targetCode}, praisonai==${result.targetWrapper}\n`
);
}
await core.summary.write();
CHANGED=$(git diff --name-only "${LAST_TAG}" HEAD -- \
src/praisonai \
src/praisonai-agents || true)
if (!result.ready) {
core.info(`Skip release: ${result.reasons.join('; ')}`);
}
if [ -z "$CHANGED" ]; then
echo "No changes in src/praisonai or src/praisonai-agents since ${LAST_TAG} — skip"
echo "should_release=false" >> "$GITHUB_OUTPUT"
echo "reason=no_path_changes" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Changes since ${LAST_TAG}:"
echo "$CHANGED"
HEAD_SHA=$(git rev-parse HEAD)
echo "main HEAD: ${HEAD_SHA}"
GREEN_SHA=$(gh run list \
--repo "${GITHUB_REPOSITORY}" \
--workflow "Core Tests" \
--branch main \
--status success \
--json headSha \
--jq '.[0].headSha // empty')
if [ "$GREEN_SHA" != "$HEAD_SHA" ]; then
echo "main@${HEAD_SHA} is not covered by a green Core Tests run (last green: ${GREEN_SHA:-none}) — skip"
echo "should_release=false" >> "$GITHUB_OUTPUT"
echo "reason=ci_not_green" >> "$GITHUB_OUTPUT"
exit 0
fi
IN_PROGRESS=$(gh run list \
--repo "${GITHUB_REPOSITORY}" \
--workflow "Core Tests" \
--branch main \
--json headSha,status \
--jq "[.[] | select(.headSha == \"${HEAD_SHA}\" and .status == \"in_progress\")] | length")
if [ "${IN_PROGRESS:-0}" != "0" ]; then
echo "Core Tests still in progress on ${HEAD_SHA} — skip"
echo "should_release=false" >> "$GITHUB_OUTPUT"
echo "reason=ci_in_progress" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Preflight passed — ready to dispatch patch release"
echo "should_release=true" >> "$GITHUB_OUTPUT"
echo "reason=ready" >> "$GITHUB_OUTPUT"
echo "last_tag=${LAST_TAG}" >> "$GITHUB_OUTPUT"
echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
- name: Dry run summary
if: inputs.dry_run == true
@@ -118,6 +108,9 @@ jobs:
echo "Dry run — preflight only, no pypi-release dispatch."
echo "should_release=${{ steps.preflight.outputs.should_release }}"
echo "reason=${{ steps.preflight.outputs.reason }}"
if [ "${{ steps.preflight.outputs.should_release }}" = "true" ]; then
echo "Would dispatch: pypi-release.yml bump=patch source=nightly"
fi
- name: Dispatch PyPI Release (patch)
if: steps.preflight.outputs.should_release == 'true' && inputs.dry_run != true
@@ -127,12 +120,7 @@ jobs:
--repo "${GITHUB_REPOSITORY}" \
--ref main \
-f bump=patch \
-f source=${{ steps.ctx.outputs.source }} \
-f trigger_sha=${{ steps.preflight.outputs.head_sha }} \
-f source=nightly \
-f dry_run=false
echo "Dispatched pypi-release.yml (patch, source=${{ steps.ctx.outputs.source }})"
if [ "${{ steps.ctx.outputs.source }}" = "ci" ]; then
echo "Uses pypi-auto environment (no manual approval)."
else
echo "Uses pypi-auto environment for nightly patch releases."
fi
echo "Dispatched pypi-release.yml (patch, source=nightly)"
echo "Approve the pypi environment when prompted."
+2 -4
View File
@@ -4,13 +4,12 @@ name: Pipeline Status Sync
on:
schedule:
- cron: '*/10 * * * *'
- cron: '*/15 * * * *'
workflow_dispatch:
permissions:
pull-requests: read
issues: write
actions: write
jobs:
sync:
@@ -28,6 +27,5 @@ jobs:
const path = require('path');
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
await ps.syncOpenPullRequests(
github, context.repo.owner, context.repo.repo,
{ maxPrs: 30, dispatchMergeGate: true }, core
github, context.repo.owner, context.repo.repo, { maxPrs: 30 }, core
);
+4 -6
View File
@@ -49,12 +49,10 @@ jobs:
git config --local user.name "PraisonAI Bot"
- name: Install PraisonAI
run: pip install uv
- uses: ./.github/actions/install-monorepo-packages
with:
use-uv: "true"
agents-extras: ".[all]"
wrapper-extras: ".[all]"
run: |
pip install uv
cd src/praisonai-agents && uv pip install --system -e ".[all]"
cd ../praisonai && uv pip install --system -e ".[all]"
- name: Run PraisonAI Issue Triage
env:
+18 -204
View File
@@ -1,14 +1,13 @@
name: PyPI Release
# Environments (Settings → Environments):
# pypi-auto — patch releases from ci/nightly (no reviewers, main branch only)
# pypi — manual workflow_dispatch and major/minor (required reviewers)
# Requires a GitHub Environment named "pypi" (Settings → Environments → pypi)
# with required reviewers before secrets are exposed to this job.
on:
workflow_dispatch:
inputs:
bump:
description: 'Version bump type for all packages (agents, code, wrapper)'
description: 'Version bump type for both packages'
required: true
default: patch
type: choice
@@ -27,12 +26,7 @@ on:
default: false
type: boolean
skip_wrapper:
description: 'Skip wrapper publish (agents and/or code only)'
required: false
default: false
type: boolean
skip_code:
description: 'Skip praisonai-code publish (recovery if already on PyPI)'
description: 'Publish agents only'
required: false
default: false
type: boolean
@@ -46,11 +40,6 @@ on:
required: false
default: ''
type: string
code_version:
description: 'Override praisonai-code version (empty = auto bump from pyproject.toml)'
required: false
default: ''
type: string
source:
description: 'Trigger source (audit trail)'
required: false
@@ -59,12 +48,6 @@ on:
options:
- manual
- nightly
- ci
trigger_sha:
description: 'Main commit SHA that passed release gate (audit/dedupe)'
required: false
default: ''
type: string
permissions:
contents: write
@@ -77,12 +60,7 @@ jobs:
release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: >-
${{
(inputs.source == 'ci' || inputs.source == 'nightly') &&
inputs.bump == 'patch' &&
'pypi-auto' || 'pypi'
}}
environment: pypi
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
@@ -111,10 +89,8 @@ jobs:
- name: Release summary
run: |
echo "Source: ${{ inputs.source }}"
echo "Trigger SHA: ${{ inputs.trigger_sha }}"
echo "Bump: ${{ inputs.bump }}"
echo "Dry run: ${{ inputs.dry_run }}"
echo "Environment: ${{ (inputs.source == 'ci' || inputs.source == 'nightly') && inputs.bump == 'patch' && 'pypi-auto' || 'pypi' }}"
- name: Compute release versions
id: versions
@@ -122,9 +98,7 @@ jobs:
BUMP: ${{ inputs.bump }}
AGENTS_OVERRIDE: ${{ inputs.agents_version }}
WRAPPER_OVERRIDE: ${{ inputs.wrapper_version }}
CODE_OVERRIDE: ${{ inputs.code_version }}
SKIP_AGENTS: ${{ inputs.skip_agents }}
SKIP_CODE: ${{ inputs.skip_code }}
run: |
python <<'PY'
import os
@@ -155,10 +129,8 @@ jobs:
agents_override = os.environ.get("AGENTS_OVERRIDE", "").strip()
wrapper_override = os.environ.get("WRAPPER_OVERRIDE", "").strip()
code_override = os.environ.get("CODE_OVERRIDE", "").strip()
validate_version("agents_version", agents_override)
validate_version("wrapper_version", wrapper_override)
validate_version("code_version", code_override)
agents_pyproject = root / "src/praisonai-agents/pyproject.toml"
agents_content = agents_pyproject.read_text()
@@ -167,14 +139,9 @@ jobs:
print("Could not read agents version from pyproject.toml", file=sys.stderr)
sys.exit(1)
current_agents = agents_match.group(1)
code_pyproject = root / "src/praisonai-code/pyproject.toml"
code_content = code_pyproject.read_text()
code_match = re.search(r'^version\s*=\s*"([^"]+)"', code_content, re.MULTILINE)
if not code_match:
print("Could not read praisonai-code version from pyproject.toml", file=sys.stderr)
if not SEMVER.match(current_agents):
print(f"Invalid current agents version: {current_agents}", file=sys.stderr)
sys.exit(1)
current_code = code_match.group(1)
wrapper_version_py = root / "src/praisonai/praisonai/version.py"
wrapper_content = wrapper_version_py.read_text()
@@ -183,18 +150,11 @@ jobs:
print("Could not read wrapper version from version.py", file=sys.stderr)
sys.exit(1)
current_wrapper = wrapper_match.group(1)
for label, value in (
("agents", current_agents),
("code", current_code),
("wrapper", current_wrapper),
):
if not SEMVER.match(value):
print(f"Invalid current {label} version: {value}", file=sys.stderr)
sys.exit(1)
if not SEMVER.match(current_wrapper):
print(f"Invalid current wrapper version: {current_wrapper}", file=sys.stderr)
sys.exit(1)
skip_agents = os.environ.get("SKIP_AGENTS", "false").lower() == "true"
skip_code = os.environ.get("SKIP_CODE", "false").lower() == "true"
if agents_override:
agents_version = agents_override
@@ -203,13 +163,6 @@ jobs:
else:
agents_version = bump_version(current_agents)
if code_override:
code_version = code_override
elif skip_code:
code_version = current_code
else:
code_version = bump_version(current_code)
if wrapper_override:
wrapper_version = wrapper_override
else:
@@ -218,80 +171,32 @@ jobs:
github_output = os.environ["GITHUB_OUTPUT"]
with open(github_output, "a", encoding="utf-8") as fh:
fh.write(f"agents_version={agents_version}\n")
fh.write(f"code_version={code_version}\n")
fh.write(f"wrapper_version={wrapper_version}\n")
fh.write(f"current_agents={current_agents}\n")
fh.write(f"current_code={current_code}\n")
fh.write(f"current_wrapper={current_wrapper}\n")
print(f"Agents: {current_agents} -> {agents_version}")
print(f"Code: {current_code} -> {code_version}")
print(f"Wrapper: {current_wrapper} -> {wrapper_version}")
PY
- name: Detect existing PyPI versions
id: pypi_exists
if: inputs.dry_run != true
env:
AGENTS_VERSION: ${{ steps.versions.outputs.agents_version }}
CODE_VERSION: ${{ steps.versions.outputs.code_version }}
WRAPPER_VERSION: ${{ steps.versions.outputs.wrapper_version }}
INPUT_SKIP_AGENTS: ${{ inputs.skip_agents }}
INPUT_SKIP_CODE: ${{ inputs.skip_code }}
INPUT_SKIP_WRAPPER: ${{ inputs.skip_wrapper }}
run: |
set -euo pipefail
skip_agents="${INPUT_SKIP_AGENTS}"
skip_code="${INPUT_SKIP_CODE}"
skip_wrapper="${INPUT_SKIP_WRAPPER}"
if [ "$skip_agents" != "true" ]; then
curl -fsSL "https://pypi.org/pypi/praisonaiagents/${AGENTS_VERSION}/json" >/dev/null && skip_agents=true || true
fi
if [ "$skip_code" != "true" ]; then
curl -fsSL "https://pypi.org/pypi/praisonai-code/${CODE_VERSION}/json" >/dev/null && skip_code=true || true
fi
if [ "$skip_wrapper" != "true" ]; then
curl -fsSL "https://pypi.org/pypi/praisonai/${WRAPPER_VERSION}/json" >/dev/null && skip_wrapper=true || true
fi
echo "skip_agents=${skip_agents}" >> "$GITHUB_OUTPUT"
echo "skip_code=${skip_code}" >> "$GITHUB_OUTPUT"
echo "skip_wrapper=${skip_wrapper}" >> "$GITHUB_OUTPUT"
if [ "$skip_agents" = "true" ] && [ "$skip_code" = "true" ] && [ "$skip_wrapper" = "true" ]; then
echo "All target versions already on PyPI — nothing to publish."
fi
- name: Exit if already published
if: >-
inputs.dry_run != true &&
steps.pypi_exists.outputs.skip_agents == 'true' &&
steps.pypi_exists.outputs.skip_code == 'true' &&
steps.pypi_exists.outputs.skip_wrapper == 'true'
run: echo "Idempotent skip — target versions already on PyPI."
- name: Dry run summary
if: inputs.dry_run
run: |
echo "Dry run — no publish, commit, or push."
echo "Agents version: ${{ steps.versions.outputs.agents_version }}"
echo "Code version: ${{ steps.versions.outputs.code_version }}"
echo "Wrapper version: ${{ steps.versions.outputs.wrapper_version }}"
echo ""
if [ "${{ inputs.skip_agents }}" != "true" ]; then
echo "Would publish praisonaiagents ${{ steps.versions.outputs.agents_version }}"
fi
if [ "${{ inputs.skip_code }}" != "true" ]; then
echo "Would publish praisonai-code ${{ steps.versions.outputs.code_version }}"
echo "Would commit src/praisonai-agents/pyproject.toml and uv.lock"
fi
if [ "${{ inputs.skip_wrapper }}" != "true" ]; then
echo "Would run bump_and_release.py ${{ steps.versions.outputs.wrapper_version }} --agents ${{ steps.versions.outputs.agents_version }} --code-pin ${{ steps.versions.outputs.code_version }}"
echo "Would run bump_and_release.py ${{ steps.versions.outputs.wrapper_version }} --agents ${{ steps.versions.outputs.agents_version }}"
echo "Would uv publish praisonai ${{ steps.versions.outputs.wrapper_version }}"
fi
- name: Publish praisonaiagents
if: >-
inputs.dry_run != true &&
inputs.skip_agents != true &&
steps.pypi_exists.outputs.skip_agents != 'true'
if: inputs.dry_run != true && inputs.skip_agents != true
working-directory: src/praisonai-agents
env:
NEW_VERSION: ${{ steps.versions.outputs.agents_version }}
@@ -314,10 +219,7 @@ jobs:
uv publish
- name: Wait for praisonaiagents on PyPI
if: >-
inputs.dry_run != true &&
inputs.skip_agents != true &&
steps.pypi_exists.outputs.skip_agents != 'true'
if: inputs.dry_run != true && inputs.skip_agents != true
run: |
set -euo pipefail
PACKAGE="praisonaiagents"
@@ -341,10 +243,7 @@ jobs:
done
- name: Commit agents version bump
if: >-
inputs.dry_run != true &&
inputs.skip_agents != true &&
steps.pypi_exists.outputs.skip_agents != 'true'
if: inputs.dry_run != true && inputs.skip_agents != true
run: |
set -euo pipefail
git add src/praisonai-agents/pyproject.toml src/praisonai-agents/uv.lock
@@ -353,101 +252,21 @@ jobs:
git pull --rebase origin main
git push origin main
- name: Publish praisonai-code
if: >-
inputs.dry_run != true &&
inputs.skip_code != true &&
steps.pypi_exists.outputs.skip_code != 'true'
working-directory: src/praisonai-code
env:
NEW_VERSION: ${{ steps.versions.outputs.code_version }}
CURRENT: ${{ steps.versions.outputs.current_code }}
run: |
set -euo pipefail
python <<'PY'
import os
import re
from pathlib import Path
new = os.environ["NEW_VERSION"]
current = os.environ["CURRENT"]
pyproject = Path("pyproject.toml")
content = pyproject.read_text()
pyproject.write_text(content.replace(f'version = "{current}"', f'version = "{new}"', 1))
init_py = Path("praisonai_code/__init__.py")
init_content = init_py.read_text()
init_py.write_text(re.sub(r'__version__ = "[^"]+"', f'__version__ = "{new}"', init_content, count=1))
PY
rm -rf dist
uv lock
uv build
uv publish
- name: Wait for praisonai-code on PyPI
if: >-
inputs.dry_run != true &&
inputs.skip_code != true &&
steps.pypi_exists.outputs.skip_code != 'true'
run: |
set -euo pipefail
PACKAGE="praisonai-code"
VERSION="${{ steps.versions.outputs.code_version }}"
MAX_WAIT=600
INTERVAL=30
START=$(date +%s)
while true; do
if curl -fsSL "https://pypi.org/pypi/${PACKAGE}/${VERSION}/json" >/dev/null; then
echo "✅ ${PACKAGE}==${VERSION} is available on PyPI"
exit 0
fi
ELAPSED=$(( $(date +%s) - START ))
if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then
echo "❌ Timeout waiting for ${PACKAGE}==${VERSION} on PyPI"
exit 1
fi
echo "⏳ Waiting for ${PACKAGE}==${VERSION} ($(( MAX_WAIT - ELAPSED ))s remaining...)"
sleep "$INTERVAL"
done
- name: Commit praisonai-code version bump
if: >-
inputs.dry_run != true &&
inputs.skip_code != true &&
steps.pypi_exists.outputs.skip_code != 'true'
run: |
set -euo pipefail
git add src/praisonai-code/pyproject.toml src/praisonai-code/uv.lock src/praisonai-code/praisonai_code/__init__.py
git diff --cached --quiet && { echo "No code files to commit"; exit 0; }
git commit -m "Bump praisonai-code to ${{ steps.versions.outputs.code_version }}"
git pull --rebase origin main
git push origin main
- name: Bump and release wrapper
if: >-
inputs.dry_run != true &&
inputs.skip_wrapper != true &&
steps.pypi_exists.outputs.skip_wrapper != 'true'
if: inputs.dry_run != true && inputs.skip_wrapper != true
run: |
set -euo pipefail
python src/praisonai/scripts/bump_and_release.py "${{ steps.versions.outputs.wrapper_version }}" \
--agents "${{ steps.versions.outputs.agents_version }}" \
--code-pin "${{ steps.versions.outputs.code_version }}" \
--wait --max-wait 600 --no-add-all
- name: Publish praisonai wrapper to PyPI
if: >-
inputs.dry_run != true &&
inputs.skip_wrapper != true &&
steps.pypi_exists.outputs.skip_wrapper != 'true'
if: inputs.dry_run != true && inputs.skip_wrapper != true
working-directory: src/praisonai
run: uv publish
- name: Wait for praisonai on PyPI
if: >-
inputs.dry_run != true &&
inputs.skip_wrapper != true &&
steps.pypi_exists.outputs.skip_wrapper != 'true'
if: inputs.dry_run != true && inputs.skip_wrapper != true
run: |
set -euo pipefail
PACKAGE="praisonai"
@@ -475,17 +294,12 @@ jobs:
run: |
set -euo pipefail
AGENTS="${{ steps.versions.outputs.agents_version }}"
CODE="${{ steps.versions.outputs.code_version }}"
WRAPPER="${{ steps.versions.outputs.wrapper_version }}"
if [ "${{ inputs.skip_agents }}" != "true" ]; then
echo "✅ praisonaiagents==${AGENTS}"
fi
if [ "${{ inputs.skip_code }}" != "true" ]; then
echo "✅ praisonai-code==${CODE}"
fi
if [ "${{ inputs.skip_wrapper }}" != "true" ]; then
echo "✅ praisonai==${WRAPPER}"
fi
+3 -5
View File
@@ -30,12 +30,10 @@ jobs:
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
- name: Install dependencies
run: python -m pip install --upgrade pip
- uses: ./.github/actions/install-monorepo-packages
with:
wrapper-extras: ".[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
- name: Install test tools
run: |
cd src/praisonai
python -m pip install --upgrade pip
python -m pip install ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
python -m pip install duckduckgo_search pytest pytest-asyncio pytest-cov
python -m pip install "praisonaiagents[knowledge]"
+3 -9
View File
@@ -1,13 +1,7 @@
name: Sync Claude Secrets
name: Sync Claude Secrets to PraisonAIUI
on:
workflow_dispatch:
inputs:
target_repo:
description: 'Destination repo (owner/name)'
required: true
default: 'MervinPraison/PraisonAI-Plugins'
type: string
permissions:
contents: read
@@ -16,10 +10,10 @@ jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Sync Claude secrets
- name: Sync secrets to PraisonAIUI
env:
GH_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
TARGET: ${{ inputs.target_repo }}
TARGET: MervinPraison/PraisonAIUI
CLAUDE_APP_ID: ${{ secrets.CLAUDE_APP_ID }}
CLAUDE_APP_PRIVATE_KEY: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+3 -4
View File
@@ -53,13 +53,12 @@ jobs:
- name: Install dependencies
run: |
cd src/praisonai
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
uv pip install --system duckduckgo_search
uv pip install --system pytest pytest-asyncio pytest-cov pytest-benchmark pytest-timeout
# Install knowledge dependencies from praisonai-agents
uv pip install --system "praisonaiagents[knowledge]"
- uses: ./.github/actions/install-monorepo-packages
with:
use-uv: "true"
wrapper-extras: ".[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
- name: Verify environment variables
run: |
+52 -235
View File
@@ -7,251 +7,68 @@ on:
- 'src/**'
- 'pyproject.toml'
- '.github/workflows/test-core.yml'
- '.github/actions/install-monorepo-packages/**'
pull_request:
branches: [ main, develop ]
paths:
- 'src/**'
- 'pyproject.toml'
- '.github/workflows/test-core.yml'
- '.github/actions/install-monorepo-packages/**'
workflow_dispatch:
permissions:
contents: read
env:
PYTHONPATH: ${{ github.workspace }}/src/praisonai-agents
PRAISONAI_ALLOW_NETWORK: '0'
PRAISONAI_LIVE_TESTS: '0'
PRAISONAI_TEST_TIER: main
jobs:
test-core-collect:
name: test-core-collect
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install UV
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Install dependencies
run: |
cd src/praisonai
uv pip install --system duckduckgo_search
uv pip install --system pytest pytest-asyncio pytest-timeout pytest-xdist
uv pip install --system "praisonaiagents[knowledge]"
- uses: ./.github/actions/install-monorepo-packages
with:
use-uv: "true"
wrapper-extras: ".[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
- name: Set environment variables
run: |
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
echo "OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}" >> $GITHUB_ENV
echo "OPENAI_MODEL_NAME=gpt-4o-mini" >> $GITHUB_ENV
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
- name: Verify unit test collection
shell: bash
run: |
set -euo pipefail
cd src/praisonai
python -m pytest tests/unit/ \
-m "not provider_anthropic and not provider_google and not provider_ollama and not provider_grok_xai and not provider_groq and not provider_cohere" \
--ignore=tests/fixtures \
--collect-only -q --disable-warnings
test-core-unit:
name: test-core (${{ matrix.shard }})
needs: test-core-collect
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- shard: cli
paths: >-
tests/unit/cli/
tests/unit/tui/
extra_ignore: --ignore=tests/unit/cli/test_message_queue.py
- shard: bots-gateway
paths: >-
tests/unit/bots/
tests/unit/gateway/
extra_ignore: ""
- shard: subdirs
paths: >-
tests/unit/scheduler/
tests/unit/integrations/
tests/unit/deploy/
tests/unit/mcp_server/
tests/unit/mcp/
tests/unit/knowledge/
tests/unit/train/
tests/unit/llm/
tests/unit/doctor/
tests/unit/code/
tests/unit/acp/
tests/unit/agent/
tests/unit/tools/
tests/unit/persistence/
tests/unit/security/
tests/unit/recipe/
tests/unit/storage/
tests/unit/rag/
tests/unit/replay/
tests/unit/escalation/
tests/unit/orchestration/
tests/unit/dev/
tests/unit/_dev/
tests/unit/daemon/
tests/unit/context/
tests/unit/adapters/
tests/unit/templates/
tests/unit/suite_runner/
tests/unit/standardise/
extra_ignore: ""
- shard: root
paths: ""
extra_ignore: ""
root_glob: "true"
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install UV
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Install dependencies
run: |
cd src/praisonai
uv pip install --system duckduckgo_search
uv pip install --system pytest pytest-asyncio pytest-timeout pytest-xdist
uv pip install --system "praisonaiagents[knowledge]"
- uses: ./.github/actions/install-monorepo-packages
with:
use-uv: "true"
wrapper-extras: ".[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
- name: Set environment variables
run: |
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
echo "OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}" >> $GITHUB_ENV
echo "OPENAI_MODEL_NAME=gpt-4o-mini" >> $GITHUB_ENV
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
- name: Run unit shard
shell: bash
run: |
set -euo pipefail
cd src/praisonai
PYTEST_ARGS=(
-m "not slow and not network and not local_service and not provider_anthropic and not provider_google and not provider_ollama and not provider_grok_xai and not provider_groq and not provider_cohere"
-q --tb=line --disable-warnings
--timeout=60 -n 2 --dist loadfile
--ignore=tests/fixtures
--ignore=tests/e2e
--ignore=tests/live
--ignore=tests/unit/test_langflow_components.py
)
if [ "${{ matrix.root_glob || 'false' }}" = "true" ]; then
shopt -s nullglob
ROOT_TESTS=(tests/unit/test_*.py)
shopt -u nullglob
EXCLUDE=(tests/unit/test_langflow_components.py)
FILTERED=()
for t in "${ROOT_TESTS[@]}"; do
skip=false
for e in "${EXCLUDE[@]}"; do
if [ "$t" = "$e" ]; then skip=true; break; fi
done
if [ "$skip" = false ]; then FILTERED+=("$t"); fi
done
if [ ${#FILTERED[@]} -eq 0 ]; then
echo "No root unit tests matched"
exit 0
fi
python -m pytest "${FILTERED[@]}" "${PYTEST_ARGS[@]}"
else
python -m pytest ${{ matrix.paths }} "${PYTEST_ARGS[@]}" ${{ matrix.extra_ignore }}
fi
test-core-integration:
name: test-core-integration
needs: test-core-unit
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install UV
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Install dependencies
run: |
uv pip install --system duckduckgo_search
uv pip install --system pytest pytest-asyncio pytest-timeout
uv pip install --system "praisonaiagents[knowledge]"
- uses: ./.github/actions/install-monorepo-packages
with:
use-uv: "true"
wrapper-extras: ".[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
- name: Set environment variables
run: |
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
echo "OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}" >> $GITHUB_ENV
echo "OPENAI_MODEL_NAME=gpt-4o-mini" >> $GITHUB_ENV
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
- name: Run integration tests
shell: bash
run: |
set -euo pipefail
cd src/praisonai
python -m pytest tests/integration/ \
-m "not slow and not network and not local_service and not provider_anthropic and not provider_google and not provider_ollama and not provider_grok_xai and not provider_groq and not provider_cohere" \
-q --tb=line --disable-warnings \
--timeout=120 \
--ignore=tests/fixtures \
--ignore=tests/e2e \
--ignore=tests/live \
--ignore=tests/integration/test_tracker_complex_tasks.py \
--ignore=tests/integration/test_serve_integration.py
test-core:
name: test-core
needs: [test-core-collect, test-core-unit, test-core-integration]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 20
steps:
- name: Report Core Tests result
shell: bash
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install UV
run: |
set -euo pipefail
collect='${{ needs.test-core-collect.result }}'
unit='${{ needs.test-core-unit.result }}'
integration='${{ needs.test-core-integration.result }}'
echo "collect=$collect unit=$unit integration=$integration"
if [ "$collect" != "success" ] || [ "$unit" != "success" ] || [ "$integration" != "success" ]; then
echo "Core Tests failed"
exit 1
fi
echo "Core Tests passed"
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Install dependencies
run: |
cd src/praisonai
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
uv pip install --system duckduckgo_search
uv pip install --system pytest pytest-asyncio pytest-cov pytest-timeout
uv pip install --system "praisonaiagents[knowledge]"
- name: Set environment variables
run: |
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
echo "OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}" >> $GITHUB_ENV
echo "OPENAI_MODEL_NAME=${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}" >> $GITHUB_ENV
echo "PYTHONPATH=${{ github.workspace }}/src/praisonai-agents:$PYTHONPATH" >> $GITHUB_ENV
- name: Run Unit Tests
run: |
cd src/praisonai && python -m pytest tests/unit/ -v --tb=short --disable-warnings \
--cov=praisonai --cov-report=xml --cov-branch \
--timeout=60 || echo "Some unit tests failed"
continue-on-error: true
- name: Run Integration Tests
run: |
cd src/praisonai && python -m pytest tests/integration/ -v --tb=short --disable-warnings \
--timeout=60 \
--ignore=tests/integration/test_tracker_complex_tasks.py \
--ignore=tests/integration/test_serve_integration.py \
|| echo "Some integration tests failed"
- name: Upload coverage to Codecov
if: always()
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN || '' }}
files: src/praisonai/coverage.xml
flags: core-tests
fail_ci_if_error: false
+6 -8
View File
@@ -29,12 +29,11 @@ jobs:
- name: Install dependencies
run: |
cd src/praisonai
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
uv pip install --system duckduckgo_search
# Install knowledge dependencies from praisonai-agents
uv pip install --system "praisonaiagents[knowledge]"
- uses: ./.github/actions/install-monorepo-packages
with:
use-uv: "true"
wrapper-extras: ".[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
- name: Set environment variables
run: |
@@ -96,12 +95,11 @@ jobs:
- name: Install dependencies
run: |
cd src/praisonai
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
uv pip install --system pytest pytest-benchmark
# Install knowledge dependencies from praisonai-agents
uv pip install --system "praisonaiagents[knowledge]"
- uses: ./.github/actions/install-monorepo-packages
with:
use-uv: "true"
wrapper-extras: ".[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
- name: Set environment variables
run: |
+2 -4
View File
@@ -49,12 +49,10 @@ jobs:
- name: Install dependencies
run: |
cd src/praisonai
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
uv pip install --system duckduckgo_search
uv pip install --system pytest pytest-asyncio pytest-cov pytest-timeout
- uses: ./.github/actions/install-monorepo-packages
with:
use-uv: "true"
wrapper-extras: ".[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
- name: Set environment variables
run: |
+45 -171
View File
@@ -42,7 +42,7 @@ jobs:
# ============================================
smoke:
runs-on: ubuntu-latest
timeout-minutes: 8
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
@@ -64,78 +64,28 @@ jobs:
- name: Install minimal dependencies
run: |
pip install --upgrade pip
# Install praisonai-agents with all deps (required for tests)
cd src/praisonai-agents
pip install -e ".[all]"
cd ../praisonai
pip install -e ".[chat,code]"
pip install pytest pytest-asyncio pytest-timeout prompt_toolkit
- uses: ./.github/actions/install-monorepo-packages
with:
agents-extras: ".[all]"
wrapper-extras: ".[chat,code]"
- name: Run Smoke Tests
env:
PRAISONAI_TEST_TIER: smoke
PRAISONAI_ALLOW_NETWORK: '0'
OPENAI_MODEL_NAME: gpt-4o-mini
shell: bash
run: |
set -euo pipefail
cd src/praisonai
python -m pytest tests/unit/test_c5_backward_compat.py \
tests/unit/test_c7_1_boundaries.py \
-q --tb=line --timeout=30 --maxfail=5
# Run only CLI tests for smoke - fast and critical path
python -m pytest tests/unit/cli/ \
-m "not slow and not network" \
-q --tb=line \
--timeout=30 \
--ignore=tests/fixtures \
--ignore=tests/unit/cli/test_message_queue.py \
--maxfail=5
- name: Standalone praisonai-code import smoke
shell: bash
run: |
set -euo pipefail
python -m venv /tmp/praisonai-code-standalone
/tmp/praisonai-code-standalone/bin/pip install -q -e src/praisonai-agents -e src/praisonai-code
/tmp/praisonai-code-standalone/bin/python -c "
import praisonai_code
from praisonai_code.cli.configuration.resolver import ConfigResolver
from praisonai_code._version import get_package_version
from praisonai_code.llm.credentials import is_configured
from praisonai_code.llm.catalogue import ModelCatalogue
import toml
assert hasattr(ConfigResolver, '__name__')
assert callable(is_configured)
assert ModelCatalogue().list_models(provider='openai')
import praisonai_code.cli.main
import praisonai_code.cli.app
from praisonai_code.tool_resolver import ToolResolver
assert ToolResolver is not None
print('standalone ok', get_package_version(), 'toml', toml.__version__)
"
/tmp/praisonai-code-standalone/bin/praisonai-code --version
/tmp/praisonai-code-standalone/bin/praisonai-code run --help
cd src/praisonai-code
for f in praisonai_code/cli/main.py praisonai_code/cli/app.py \
praisonai_code/cli/commands/run.py praisonai_code/cli/commands/chat.py \
praisonai_code/cli/commands/code.py; do
if grep -E '^from praisonai\.|^import praisonai' "$f"; then
echo "FAIL: module-level wrapper import in $f"
exit 1
fi
done
echo "C7 hot-path import gate ok"
bash "$GITHUB_WORKSPACE/scripts/check_c7_imports.sh"
# Standalone run must not fail on wrapper ImportError (API auth failure is OK)
set +e
RUN_OUT=$(timeout 90 /tmp/praisonai-code-standalone/bin/praisonai-code run "Say hello in one word" 2>&1)
RUN_CODE=$?
set -e
echo "$RUN_OUT" | tail -20
if echo "$RUN_OUT" | grep -qE 'ImportError|ModuleNotFoundError: No module named .praisonai'; then
echo "FAIL: standalone run hit wrapper import error"
exit 1
fi
echo "standalone run import gate ok (exit $RUN_CODE)"
--maxfail=5 \
|| echo "Some smoke tests failed"
- name: Smoke Test Summary
if: always()
@@ -148,7 +98,7 @@ jobs:
main:
needs: smoke
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 10
strategy:
matrix:
python-version: ['3.11']
@@ -174,38 +124,36 @@ jobs:
- name: Install dependencies
run: |
pip install --upgrade pip
# Install praisonai-agents with all deps (required for tests)
cd src/praisonai-agents
pip install -e ".[all]"
cd ../praisonai
pip install -e ".[crewai,autogen]"
pip install pytest pytest-asyncio pytest-timeout pytest-cov pytest-xdist
- uses: ./.github/actions/install-monorepo-packages
with:
agents-extras: ".[all]"
wrapper-extras: ".[crewai,autogen,ui,chat]"
- name: Run Main Tests
env:
PRAISONAI_TEST_TIER: main
PRAISONAI_ALLOW_NETWORK: '0'
OPENAI_MODEL_NAME: gpt-4o-mini
OPENAI_API_KEY: 'test-key'
ANTHROPIC_API_KEY: 'test-key'
GOOGLE_API_KEY: 'test-key'
shell: bash
run: |
set -euo pipefail
cd src/praisonai
python -m pytest tests/unit/ \
-m "not slow and not network and not local_service and not provider_anthropic and not provider_google and not provider_ollama and not provider_grok_xai and not provider_groq and not provider_cohere" \
python -m pytest tests/unit/ tests/integration/ \
-m "not provider_anthropic and not provider_google and not provider_ollama and not provider_grok_xai and not provider_groq and not provider_cohere" \
-q --tb=line \
--timeout=60 \
--ignore=tests/fixtures \
--ignore=tests/e2e \
--ignore=tests/live \
--ignore=tests/unit/jobs \
--ignore=tests/unit/sandbox \
--ignore=tests/unit/test_langflow_components.py \
--ignore=tests/unit/api \
--cov=praisonai \
--cov-report=xml \
-n 2 --dist loadfile \
--maxfail=10
-n 4 \
--maxfail=10 \
|| echo "Some tests failed"
- name: Upload coverage
if: matrix.python-version == '3.11'
@@ -262,12 +210,11 @@ jobs:
if: steps.check-key.outputs.has_key == 'true'
run: |
pip install --upgrade pip
cd src/praisonai-agents
pip install -e ".[all]"
cd ../praisonai
pip install -e ".[openai]"
pip install pytest pytest-asyncio pytest-timeout
- uses: ./.github/actions/install-monorepo-packages
if: steps.check-key.outputs.has_key == 'true'
with:
agents-extras: ".[all]"
wrapper-extras: ".[openai]"
- name: Run OpenAI Live Tests
if: steps.check-key.outputs.has_key == 'true'
@@ -326,12 +273,11 @@ jobs:
if: steps.check-key.outputs.has_key == 'true'
run: |
pip install --upgrade pip
cd src/praisonai-agents
pip install -e ".[all]"
cd ../praisonai
pip install -e ".[anthropic]"
pip install pytest pytest-asyncio pytest-timeout
- uses: ./.github/actions/install-monorepo-packages
if: steps.check-key.outputs.has_key == 'true'
with:
agents-extras: ".[all]"
wrapper-extras: ".[anthropic]"
- name: Run Anthropic Tests
if: steps.check-key.outputs.has_key == 'true'
@@ -386,12 +332,11 @@ jobs:
if: steps.check-key.outputs.has_key == 'true'
run: |
pip install --upgrade pip
cd src/praisonai-agents
pip install -e ".[all]"
cd ../praisonai
pip install -e ".[google]"
pip install pytest pytest-asyncio pytest-timeout
- uses: ./.github/actions/install-monorepo-packages
if: steps.check-key.outputs.has_key == 'true'
with:
agents-extras: ".[all]"
wrapper-extras: ".[google]"
- name: Run Google/Gemini Tests
if: steps.check-key.outputs.has_key == 'true'
@@ -446,11 +391,11 @@ jobs:
if: steps.check-key.outputs.has_key == 'true'
run: |
pip install --upgrade pip
cd src/praisonai-agents
pip install -e ".[all]"
cd ../praisonai
pip install -e .
pip install pytest pytest-asyncio pytest-timeout
- uses: ./.github/actions/install-monorepo-packages
if: steps.check-key.outputs.has_key == 'true'
with:
agents-extras: ".[all]"
- name: Run Groq Tests
if: steps.check-key.outputs.has_key == 'true'
@@ -505,11 +450,11 @@ jobs:
if: steps.check-key.outputs.has_key == 'true'
run: |
pip install --upgrade pip
cd src/praisonai-agents
pip install -e ".[all]"
cd ../praisonai
pip install -e .
pip install pytest pytest-asyncio pytest-timeout
- uses: ./.github/actions/install-monorepo-packages
if: steps.check-key.outputs.has_key == 'true'
with:
agents-extras: ".[all]"
- name: Run xAI/Grok Tests
if: steps.check-key.outputs.has_key == 'true'
@@ -564,12 +509,11 @@ jobs:
if: steps.check-key.outputs.has_key == 'true'
run: |
pip install --upgrade pip
cd src/praisonai-agents
pip install -e ".[all]"
cd ../praisonai
pip install -e ".[cohere]"
pip install pytest pytest-asyncio pytest-timeout
- uses: ./.github/actions/install-monorepo-packages
if: steps.check-key.outputs.has_key == 'true'
with:
agents-extras: ".[all]"
wrapper-extras: ".[cohere]"
- name: Run Cohere Tests
if: steps.check-key.outputs.has_key == 'true'
@@ -591,76 +535,6 @@ jobs:
if: steps.check-key.outputs.has_key == 'false'
run: echo "✅ Cohere tests skipped (no API key available)"
# ============================================
# LEGACY UNIT TESTS - Post-C6 re-enabled suite
# Nightly / extended tier only (~401 tests)
# ============================================
legacy-unit:
if: github.event_name == 'schedule' || github.event.inputs.tier == 'extended' || github.event.inputs.tier == 'nightly'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install --upgrade pip
pip install pytest pytest-asyncio pytest-timeout
- uses: ./.github/actions/install-monorepo-packages
with:
agents-extras: ".[all]"
wrapper-extras: ".[chat,code,crewai,autogen]"
- name: Run post-C6 legacy unit tests
env:
PRAISONAI_TEST_TIER: extended
PRAISONAI_ALLOW_NETWORK: '0'
PRAISONAI_ALLOW_JOB_WORKFLOWS: 'true'
PRAISONAI_TOOL_SAFETY: 'off'
OPENAI_API_KEY: 'test-key'
run: |
set -euo pipefail
cd src/praisonai
LEGACY_FILES=(
tests/unit/test_warm_runtime.py
tests/unit/test_yaml_cli_backend.py
tests/unit/test_tool_resolver.py
tests/unit/test_serve_unified.py
tests/unit/test_security_fixes_core.py
tests/unit/test_query_rewriter_cli.py
tests/unit/test_kanban_sqlite_store.py
tests/unit/test_job_workflow_agent_steps.py
tests/unit/test_hybrid_workflow.py
tests/unit/test_gateway_session.py
tests/unit/test_gateway_gaps.py
tests/unit/test_discord_approval.py
tests/unit/test_decorator_simple.py
tests/unit/test_db_adapter_tracing_init.py
tests/unit/test_bot_wiring.py
tests/unit/test_bot_history.py
tests/unit/test_bot_fixes.py
tests/unit/test_auto_lazy_loading.py
tests/unit/test_async_daemon_deployment.py
tests/unit/test_approval_interactive.py
tests/unit/test_approval_agent_integration.py
tests/unit/test_agents_yaml_validation.py
tests/unit/test_agent_os_wrapper.py
tests/unit/test_lazy_imports.py
tests/unit/test_profiler.py
tests/unit/test_profiler_advanced.py
tests/unit/test_serverless_postgres.py
)
python -m pytest "${LEGACY_FILES[@]}" \
-q --tb=line --timeout=120 --maxfail=10
# ============================================
# TEST SUMMARY
# ============================================
+3 -4
View File
@@ -61,12 +61,11 @@ jobs:
- name: Install dependencies
run: |
cd src/praisonai
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
uv pip install --system pytest pytest-asyncio pytest-cov
# Install knowledge dependencies from praisonai-agents
uv pip install --system "praisonaiagents[knowledge]"
- uses: ./.github/actions/install-monorepo-packages
with:
use-uv: "true"
wrapper-extras: ".[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
- name: Set environment variables
run: |
+11 -5
View File
@@ -7,14 +7,12 @@ on:
- 'src/**'
- 'pyproject.toml'
- '.github/workflows/test-windows.yml'
- '.github/actions/install-monorepo-packages/**'
pull_request:
branches: [main, develop]
paths:
- 'src/**'
- 'pyproject.toml'
- '.github/workflows/test-windows.yml'
- '.github/actions/install-monorepo-packages/**'
workflow_dispatch:
jobs:
@@ -35,10 +33,18 @@ jobs:
with:
python-version: "3.11"
- name: Install monorepo packages
run: python -m pip install --upgrade pip
- name: Install praisonaiagents
run: |
python -m pip install --upgrade pip
cd src/praisonai-agents
pip install -e .
shell: bash
- name: Install praisonai wrapper
run: |
cd src/praisonai
pip install -e .
shell: bash
- uses: ./.github/actions/install-monorepo-packages
# ==================== CORE IMPORTS ====================
- name: Test core imports
-1
View File
@@ -22,7 +22,6 @@ other
output
!src/praisonai/praisonai/cli/output/
!src/praisonai-agents/praisonaiagents/output/
!src/praisonai-code/praisonai_code/cli/output/
.files
threads.db
+1 -2
View File
@@ -7,5 +7,4 @@ You are working on the PraisonAI project.
- Be concise and helpful in responses
- Test implementation thoroughly
- Ensure backward compatibility with existing APIs
- Follow protocol-driven design: core protocols in `praisonaiagents/`, agentic terminal CLI in `praisonai-code/`, bot/channel and heavy implementations in `praisonai/`
- Preserve old `praisonai.cli.*` import paths via shims when changing moved CLI code (see §2.3 in `src/praisonai-agents/AGENTS.md`)
- Follow protocol-driven design: core protocols in praisonaiagents/, heavy implementations in praisonai/
+35 -133
View File
@@ -1,27 +1,24 @@
# PraisonAI Architecture
> **Last updated:** 2026-07-02 (v4.6.110 — C7.1 package boundaries)
> **Last updated:** 2026-06-24
>
> Strategic architecture document for PraisonAI — a multi-agent AI framework.
> Covers **Python three-tier package model (C7.1)**, system design, runtime
> architecture, data contracts, reliability, observability, and the road map.
> Covers system design, runtime architecture, data contracts, reliability,
> observability, and the 2-quarter road map.
---
## Table of Contents
1. [Executive Summary](#1-executive-summary)
2. [Python Three-Tier Package Model (C7.1)](#2-python-three-tier-package-model-c71)
3. [System Overview](#3-system-overview)
4. [Layered Architecture](#4-layered-architecture)
5. [Core Data Contracts](#5-core-data-contracts)
6. [Reliability by Design](#6-reliability-by-design)
7. [Observability & Telemetry](#7-observability--telemetry)
8. [Replay & Checkpointing](#8-replay--checkpointing)
9. [Implementation Roadmap](#9-implementation-roadmap)
10. [Success Metrics](#10-success-metrics)
**Related boundary docs:** [`src/praisonai/tests/C7.1_BOUNDARIES.md`](src/praisonai/tests/C7.1_BOUNDARIES.md) · [`src/praisonai/tests/C7_VERIFICATION.md`](src/praisonai/tests/C7_VERIFICATION.md) · [`src/praisonai/tests/C8_BACKLOG.md`](src/praisonai/tests/C8_BACKLOG.md) · [`src/praisonai-agents/AGENTS.md`](src/praisonai-agents/AGENTS.md) §2.4
2. [System Overview](#2-system-overview)
3. [Layered Architecture](#3-layered-architecture)
4. [Core Data Contracts](#4-core-data-contracts)
5. [Reliability by Design](#5-reliability-by-design)
6. [Observability & Telemetry](#6-observability--telemetry)
7. [Replay & Checkpointing](#7-replay--checkpointing)
8. [Implementation Roadmap](#8-implementation-roadmap)
9. [Success Metrics](#9-success-metrics)
---
@@ -51,85 +48,7 @@ Orchestration + Observability** core to unlock adoption and trust:
---
## 2. Python Three-Tier Package Model (C7.1)
**Release:** v4.6.110 · `praisonaiagents` 1.6.110 · `praisonai` 4.6.110 · `praisonai-code` 0.0.8
The Python monorepo publishes three tiers with strict dependency direction. C7
delivered a standalone agentic hot path; C7.1 formalised ownership and import
gates.
```mermaid
flowchart TB
subgraph tier1 [Tier 1 — Core SDK]
Agents["praisonaiagents<br/>Agent, tools, memory, hooks, protocols"]
end
subgraph tier2 [Tier 2 — Terminal CLI]
Code["praisonai-code<br/>run, chat, code, Typer, runtime, LLM, tools"]
end
subgraph tier3 [Tier 3 — Wrapper]
Wrapper["praisonai<br/>gateway, bots, framework_adapters, train, serve"]
end
Agents --> Code
Agents --> Wrapper
Code -.->|"lazy bridge only<br/>no PyPI dep"| Wrapper
```
| Tier | Package | Owns | Must not depend on |
|------|---------|------|-------------------|
| 1 | `src/praisonai-agents/` | Agent, tools, memory, hooks, `frameworks/` protocols | `praisonai`, `praisonai-code` |
| 2 | `src/praisonai-code/` | `run`/`chat`/`code`, Typer, runtime, LLM, tool resolution | **`praisonai` as a PyPI dependency** (optional lazy imports via `_wrapper_bridge` only) |
| 3 | `src/praisonai/` | Gateway, bots, `framework_adapters/`, capabilities, train, serve | — |
**Publish order:** `praisonaiagents` → `praisonai-code` → `praisonai`
### Dependency rule (validated)
| Question | Answer |
|----------|--------|
| Does `praisonai-code` declare `praisonai` in `pyproject.toml`? | **No** — only `praisonaiagents` + CLI/runtime deps |
| Does `praisonai` declare `praisonai-code`? | **Yes** — one-way chain, no PyPI cycle |
| Can `praisonai-code` import `praisonai.*` at runtime? | **Only lazily** via `praisonai_code._wrapper_bridge` for optional features; **not** on the agentic hot path |
| Does standalone `pip install praisonai-code` work? | **Yes** — CI smoke validates imports + `praisonai-code run` without the wrapper |
### CLI routing
```mermaid
flowchart LR
User[User] --> Entry{Entry point}
Entry -->|pip install praisonai-code| CodeCLI["praisonai-code run/chat/code"]
Entry -->|pip install praisonai| WrapperCLI["praisonai …"]
WrapperCLI --> Router["praisonai.__main__"]
Router --> CodePath["praisonai_code.cli.app"]
Router --> Legacy["praisonai.cli.main"]
CodePath --> HotPath["run / chat / code"]
CodePath --> Bridge{"wrapper_available?"}
Bridge -->|yes| WrapperCmds["bot, gateway, pairing, …"]
Bridge -->|no| Hidden["Wrapper commands hidden"]
```
### Import gates (CI enforced)
- **Hot path:** no module-level `from praisonai` in `main.py`, `app.py`, `run.py`, `chat.py`, `code.py`
- **Regression baseline:** 225 wrapper import lines max (`scripts/check_c7_imports.sh`)
- **Allowlist:** reviewed files only (`scripts/c7_wrapper_import_allowlist.txt`)
### C7 / C7.1 status
| Milestone | Status |
|-----------|--------|
| Standalone agentic hot path | **Complete** |
| `_wrapper_bridge` hardening | **Complete** |
| Import gate + allowlist | **Complete** |
| Boundary tests + CI smoke | **Complete** |
| C8+ (zero lazy imports, package splits) | **Future** — see [`C8_BACKLOG.md`](src/praisonai/tests/C8_BACKLOG.md) |
---
## 3. System Overview
## 2. System Overview
```
User/SDK/CLI
@@ -156,42 +75,33 @@ User/SDK/CLI
### Existing Architecture (Python SDK)
The Python SDK is organised as three publishable tiers (see [§2](#2-python-three-tier-package-model-c71)):
The Python SDK is the primary runtime and is organised as follows:
- `src/praisonai-agents/` — Core SDK (`Agent`, tools, memory, hooks, protocols)
- `src/praisonai-code/` — Terminal CLI (`run`, `chat`, `code`, Typer, runtime, LLM)
- `src/praisonai/` — Wrapper (gateway, bots, `framework_adapters/`, integrations)
Core execution modules live in `praisonaiagents`:
- `agent/` — Agent class, handoff, autonomy
- `llm/` — Model runtime with provider routing, rate limiting, failover
- `tools/` — Tool runtime with sandbox, approval, retry
- `memory/` — Memory runtime (in-memory, SQLite, MongoDB, Mem0 adapters)
- `knowledge/` — Knowledge management (indexing, retrieval, chunking)
- `workflows/` — Workflow engine (YAML/SDK-based orchestration)
- `hooks/`, `bus/` — Hook system and event bus
- `mcp/` — MCP protocol support
- `a2a/`, `a2ui/` — Agent-to-agent and agent-to-UI protocols (wrapper/SDK)
Wrapper-only surfaces remain in `src/praisonai/`:
- `gateway/`, `bots/` — Multi-bot orchestration (BotOS)
- `framework_adapters/` — CrewAI, AutoGen, PraisonAI adapters
- `cli/commands/` — Wrapper commands (`bot`, `gateway`, `pairing`, …)
- `src/praisonai/` — Core runtime and agent abstractions
- `agents/` — Agent types (chat, code, realtime, audio, vision, etc.)
- `llm/` — Model runtime with provider routing, rate limiting, failover
- `tools/` — Tool runtime with sandbox, approval, retry
- `memory/` — Memory runtime (in-memory, SQLite, MongoDB, Mem0 adapters)
- `knowledge/` — Knowledge management (indexing, retrieval, chunking)
- `workflow/` — Workflow engine (YAML/SDK-based orchestration)
- `telemetry/` — Observability, monitoring, token tracking
- `reliability/` — Error classification, circuit breaker, failover
- `skill/` — Plugin/skill system
- `loop_detection/` — Doom loop detection and prevention
- `mcp/` — MCP protocol support
- `a2a/`, `a2ui/` — Agent-to-agent and agent-to-UI protocols
### Multi-SDK Layout
- **Python Core SDK** — `src/praisonai-agents/` (`praisonaiagents`)
- **Python Terminal CLI** — `src/praisonai-code/` (`praisonai-code`)
- **Python Wrapper** — `src/praisonai/` (`praisonai`)
- **Python SDK** — Primary runtime (`src/praisonai/`)
- **TypeScript SDK** — JS/TS runtime (`ts-sdk/`)
- **Rust SDK** — High-performance Rust runtime (`rust-sdk/`)
- **CLI** — Command-line interface (`cli/`)
- **UI** — Web UI (`ui/`)
---
## 4. Layered Architecture
## 3. Layered Architecture
```mermaid
flowchart TB
@@ -287,7 +197,7 @@ flowchart TB
---
## 5. Core Data Contracts
## 4. Core Data Contracts
### RunRequest
@@ -345,7 +255,7 @@ Checkpoint {
---
## 6. Reliability by Design
## 5. Reliability by Design
### Error Taxonomy
@@ -398,7 +308,7 @@ def test_agent_workflow():
---
## 7. Observability & Telemetry
## 6. Observability & Telemetry
### Current Telemetry Stack
@@ -425,7 +335,7 @@ PraisonAI already includes:
---
## 8. Replay & Checkpointing
## 7. Replay & Checkpointing
### Design
@@ -466,15 +376,7 @@ sequenceDiagram
---
## 9. Implementation Roadmap
### Completed (C7 / C7.1 — v4.6.110)
| Project | Description | Status |
|---------|-------------|--------|
| C7 hot path | Standalone `praisonai-code run/chat/code` without wrapper import | **Complete** |
| C7.1 boundaries | Three-tier ownership, `_wrapper_bridge`, import gates | **Complete** |
| CI parity | Smoke standalone block + pre-existing test fixes (#2560) | **Complete** |
## 8. Implementation Roadmap
### Quarter 1: Trust Foundation (Q3 2026)
@@ -512,7 +414,7 @@ gantt
---
## 10. Success Metrics
## 9. Success Metrics
### KPI Targets
+1 -1
View File
@@ -568,7 +568,7 @@ praisonai agents.yaml
| Query Rewriter Agent | [Example](examples/python/agents/query-rewriter-agent.py) | [📖](https://docs.praison.ai/docs/agents/query-rewriter) |
| Native Web Search | [Example](examples/python/agents/websearch-agent.py) | [📖](https://docs.praison.ai/docs/agents/websearch) |
| Built-in Search Tools | [Example](examples/python/agents/websearch-agent.py) | [📖](https://docs.praison.ai/docs/tools/tavily) |
| Unified Web Search | [Example](examples/python/web_search_example.py) | [📖](https://docs.praison.ai/docs/tools/web-search) |
| Unified Web Search | [Example](src/praisonai-agents/examples/web_search_example.py) | [📖](https://docs.praison.ai/docs/tools/web-search) |
| Web Fetch (Anthropic) | [Example](examples/python/agents/web-fetch-agent.py) | [📖](https://docs.praison.ai/docs/features/model-capabilities) |
</details>
+369 -11
View File
@@ -18,7 +18,6 @@ Methods:
* <code title="class HandoffResult">HandoffResult.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">from_outcome</a>(outcome: AgentRunOutcome, target_agent: Optional[str] = None, source_agent: Optional[str] = None, handoff_depth: int = 0) -> 'HandoffResult'</code>
* <code title="class TaskOutput">TaskOutput.<a href="./src/praisonai-agents/praisonaiagents/main.py">json</a>() -> Optional[str]</code>
* <code title="class TaskOutput">TaskOutput.<a href="./src/praisonai-agents/praisonaiagents/main.py">to_dict</a>() -> dict</code>
* <code title="class ToolResult">ToolResult.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">is_multimodal</a>() -> bool</code>
* <code title="class ToolResult">ToolResult.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">to_dict</a>() -> Dict[str, Any]</code>
# Agents
@@ -483,27 +482,20 @@ Methods:
* <code title="class SkillLoader">SkillLoader.<a href="./src/praisonai-agents/praisonaiagents/skills/loader.py">load_scripts</a>(skill: LoadedSkill) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">activate</a>(skill: LoadedSkill) -> bool</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">activate_by_name</a>(name: str) -> bool</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">add_bundle</a>(manifest: 'BundleManifest') -> None</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">add_skill</a>(skill_path: str) -> Optional[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">add_skill_by_name</a>(name: str, skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> Optional[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">approve</a>(identifier: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">archive_skill</a>(name: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">bundle_names</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">bundles</a>() -> List['BundleManifest']</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">clear</a>() -> None</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">create</a>(name: str, content: str, category: Optional[str] = None, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">create_skill</a>(name: str, content: str, category: str = None, agent_created: bool = True, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete</a>(name: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete_skill</a>(name: str, hard: bool = False, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">discover</a>(skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> int</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">discover_bundles</a>(skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> int</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit</a>(name: str, content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit_skill</a>(name: str, content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_allowed_tools</a>(name: str) -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills</a>() -> List[SkillMetadata]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills_by_state</a>(state: SkillState) -> List[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_bundle</a>(name: str) -> Optional['BundleManifest']</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_bundle_instructions</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_instructions</a>(name: str) -> Optional[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skill</a>(name: str) -> Optional[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skills_diagnostics</a>() -> Dict[str, ValidationResult]</code>
@@ -517,7 +509,6 @@ Methods:
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">reject</a>(identifier: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_file</a>(name: str, file_path: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_skill_file</a>(name: str, file_path: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">resolve</a>(selectors: List[str]) -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">restore_skill</a>(name: str, skill_dir: Optional[str] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">rollback_skill</a>(name: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skill_names</a>() -> List[str]</code>
@@ -813,7 +804,7 @@ Methods:
Types:
```python
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run, run_integrated_gateway
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, PraisonAI, __version__, arun, run, run_integrated_gateway
```
# CLI
@@ -821,6 +812,31 @@ from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProv
Methods:
* <code title="cli">praisonai <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
* <code title="cli">praisonai acp acp-main <a href="./src/praisonai/praisonai/cli/commands/acp.py">--help</a></code>
* <code title="cli">praisonai agent list <a href="./src/praisonai/praisonai/cli/commands/agent.py">--help</a></code>
* <code title="cli">praisonai agent show <a href="./src/praisonai/praisonai/cli/commands/agent.py">--help</a></code>
* <code title="cli">praisonai agents create <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
* <code title="cli">praisonai agents info <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
* <code title="cli">praisonai agents list <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
* <code title="cli">praisonai app app <a href="./src/praisonai/praisonai/cli/commands/app.py">--help</a></code>
* <code title="cli">praisonai attach attach-main <a href="./src/praisonai/praisonai/cli/commands/attach.py">--help</a></code>
* <code title="cli">praisonai audit agent-centric <a href="./src/praisonai/praisonai/cli/commands/audit.py">--help</a></code>
* <code title="cli">praisonai auth list <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
* <code title="cli">praisonai auth login <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
* <code title="cli">praisonai auth logout <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
* <code title="cli">praisonai auth status <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
* <code title="cli">praisonai batch batch-run <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
* <code title="cli">praisonai batch list <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
* <code title="cli">praisonai batch report <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
* <code title="cli">praisonai batch stats <a href="./src/praisonai/praisonai/cli/commands/batch.py">--help</a></code>
* <code title="cli">praisonai benchmark agent <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai benchmark benchmark-callback <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai benchmark cli <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai benchmark compare <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai benchmark litellm <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai benchmark profile <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai benchmark sdk <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai benchmark workflow <a href="./src/praisonai/praisonai/cli/commands/benchmark.py">--help</a></code>
* <code title="cli">praisonai bot agentmail <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot bot-callback <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot discord <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
@@ -834,12 +850,112 @@ Methods:
* <code title="cli">praisonai bot start <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot telegram <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot whatsapp <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai browser browser-callback <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
* <code title="cli">praisonai browser click <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
* <code title="cli">praisonai browser navigate <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
* <code title="cli">praisonai browser open <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
* <code title="cli">praisonai browser profiles <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
* <code title="cli">praisonai browser screenshot <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
* <code title="cli">praisonai browser snapshot <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
* <code title="cli">praisonai browser status <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
* <code title="cli">praisonai browser type <a href="./src/praisonai/praisonai/cli/commands/browser.py">--help</a></code>
* <code title="cli">praisonai call call-main <a href="./src/praisonai/praisonai/cli/commands/call.py">--help</a></code>
* <code title="cli">praisonai chat <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
* <code title="cli">praisonai chat chat-main <a href="./src/praisonai/praisonai/cli/commands/chat.py">--help</a></code>
* <code title="cli">praisonai checkpoint delete <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
* <code title="cli">praisonai checkpoint diff <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
* <code title="cli">praisonai checkpoint list <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
* <code title="cli">praisonai checkpoint restore <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
* <code title="cli">praisonai checkpoint save <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
* <code title="cli">praisonai claw claw <a href="./src/praisonai/praisonai/cli/commands/claw.py">--help</a></code>
* <code title="cli">praisonai code <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
* <code title="cli">praisonai code code-main <a href="./src/praisonai/praisonai/cli/commands/code.py">--help</a></code>
* <code title="cli">praisonai command list <a href="./src/praisonai/praisonai/cli/commands/command.py">--help</a></code>
* <code title="cli">praisonai command show <a href="./src/praisonai/praisonai/cli/commands/command.py">--help</a></code>
* <code title="cli">praisonai commit commit-main <a href="./src/praisonai/praisonai/cli/commands/commit.py">--help</a></code>
* <code title="cli">praisonai completion bash <a href="./src/praisonai/praisonai/cli/commands/completion.py">--help</a></code>
* <code title="cli">praisonai completion completion-callback <a href="./src/praisonai/praisonai/cli/commands/completion.py">--help</a></code>
* <code title="cli">praisonai completion fish <a href="./src/praisonai/praisonai/cli/commands/completion.py">--help</a></code>
* <code title="cli">praisonai completion zsh <a href="./src/praisonai/praisonai/cli/commands/completion.py">--help</a></code>
* <code title="cli">praisonai config doctor <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config env <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config get <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config list <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config path <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config reset <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config set <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config show <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config sources <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai config validate <a href="./src/praisonai/praisonai/cli/commands/config.py">--help</a></code>
* <code title="cli">praisonai context add <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context clear <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context compact <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context export <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context grep <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context list <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context show <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context stats <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai context tail <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
* <code title="cli">praisonai daemon daemon-main <a href="./src/praisonai/praisonai/cli/commands/daemon.py">--help</a></code>
* <code title="cli">praisonai daemon start <a href="./src/praisonai/praisonai/cli/commands/daemon.py">--help</a></code>
* <code title="cli">praisonai daemon status <a href="./src/praisonai/praisonai/cli/commands/daemon.py">--help</a></code>
* <code title="cli">praisonai daemon stop <a href="./src/praisonai/praisonai/cli/commands/daemon.py">--help</a></code>
* <code title="cli">praisonai dashboard unified <a href="./src/praisonai/praisonai/cli/commands/dashboard.py">--help</a></code>
* <code title="cli">praisonai debug acp <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
* <code title="cli">praisonai debug debug-callback <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
* <code title="cli">praisonai debug interactive <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
* <code title="cli">praisonai debug lsp <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
* <code title="cli">praisonai debug trace <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
* <code title="cli">praisonai deploy aws <a href="./src/praisonai/praisonai/cli/commands/deploy.py">--help</a></code>
* <code title="cli">praisonai deploy azure <a href="./src/praisonai/praisonai/cli/commands/deploy.py">--help</a></code>
* <code title="cli">praisonai deploy docker <a href="./src/praisonai/praisonai/cli/commands/deploy.py">--help</a></code>
* <code title="cli">praisonai deploy gcp <a href="./src/praisonai/praisonai/cli/commands/deploy.py">--help</a></code>
* <code title="cli">praisonai diag diag-callback <a href="./src/praisonai/praisonai/cli/commands/diag.py">--help</a></code>
* <code title="cli">praisonai diag export <a href="./src/praisonai/praisonai/cli/commands/diag.py">--help</a></code>
* <code title="cli">praisonai docs api-md <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
* <code title="cli">praisonai docs generate <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
* <code title="cli">praisonai docs list <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
* <code title="cli">praisonai docs report <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
* <code title="cli">praisonai docs run <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
* <code title="cli">praisonai docs run-all <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
* <code title="cli">praisonai docs serve <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
* <code title="cli">praisonai docs stats <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
* <code title="cli">praisonai doctor cleanup <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor docker <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor env <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor llm-providers <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor mcp <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor memory-store <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor metadata-store <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor network <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor packaging <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor performance <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor runtime <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor selftest <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor skills <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor troubleshoot <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai endpoints list <a href="./src/praisonai/praisonai/cli/commands/endpoints.py">--help</a></code>
* <code title="cli">praisonai endpoints test <a href="./src/praisonai/praisonai/cli/commands/endpoints.py">--help</a></code>
* <code title="cli">praisonai environment check <a href="./src/praisonai/praisonai/cli/commands/environment.py">--help</a></code>
* <code title="cli">praisonai environment doctor <a href="./src/praisonai/praisonai/cli/commands/environment.py">--help</a></code>
* <code title="cli">praisonai environment view <a href="./src/praisonai/praisonai/cli/commands/environment.py">--help</a></code>
* <code title="cli">praisonai eval accuracy <a href="./src/praisonai/praisonai/cli/commands/eval.py">--help</a></code>
* <code title="cli">praisonai eval judge <a href="./src/praisonai/praisonai/cli/commands/eval.py">--help</a></code>
* <code title="cli">praisonai eval list-judges <a href="./src/praisonai/praisonai/cli/commands/eval.py">--help</a></code>
* <code title="cli">praisonai eval performance <a href="./src/praisonai/praisonai/cli/commands/eval.py">--help</a></code>
* <code title="cli">praisonai examples find <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
* <code title="cli">praisonai examples info <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
* <code title="cli">praisonai examples list <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
* <code title="cli">praisonai examples report <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
* <code title="cli">praisonai examples run <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
* <code title="cli">praisonai examples run-all <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
* <code title="cli">praisonai examples stats <a href="./src/praisonai/praisonai/cli/commands/examples.py">--help</a></code>
* <code title="cli">praisonai flow export <a href="./src/praisonai/praisonai/cli/commands/flow.py">--help</a></code>
* <code title="cli">praisonai flow flow-start <a href="./src/praisonai/praisonai/cli/commands/flow.py">--help</a></code>
* <code title="cli">praisonai flow import <a href="./src/praisonai/praisonai/cli/commands/flow.py">--help</a></code>
* <code title="cli">praisonai flow list <a href="./src/praisonai/praisonai/cli/commands/flow.py">--help</a></code>
* <code title="cli">praisonai flow version <a href="./src/praisonai/praisonai/cli/commands/flow.py">--help</a></code>
* <code title="cli">praisonai gateway channels <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway doctor <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway gateway-callback <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway install <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway logs <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
@@ -852,10 +968,15 @@ Methods:
* <code title="cli">praisonai gateway status <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway stop <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway uninstall <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai github triage <a href="./src/praisonai/praisonai/cli/commands/github.py">--help</a></code>
* <code title="cli">praisonai hooks add <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
* <code title="cli">praisonai hooks list <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
* <code title="cli">praisonai hooks remove <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
* <code title="cli">praisonai identity import <a href="./src/praisonai/praisonai/cli/commands/identity.py">--help</a></code>
* <code title="cli">praisonai identity link <a href="./src/praisonai/praisonai/cli/commands/identity.py">--help</a></code>
* <code title="cli">praisonai identity list <a href="./src/praisonai/praisonai/cli/commands/identity.py">--help</a></code>
* <code title="cli">praisonai identity unlink <a href="./src/praisonai/praisonai/cli/commands/identity.py">--help</a></code>
* <code title="cli">praisonai init init <a href="./src/praisonai/praisonai/cli/commands/init.py">--help</a></code>
* <code title="cli">praisonai kanban block <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban boards <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban comment <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
@@ -868,12 +989,249 @@ Methods:
* <code title="cli">praisonai kanban reclaim <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban show <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban unblock <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai knowledge add <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
* <code title="cli">praisonai knowledge index <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
* <code title="cli">praisonai knowledge list <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
* <code title="cli">praisonai knowledge search <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
* <code title="cli">praisonai langextract render <a href="./src/praisonai/praisonai/cli/commands/langextract.py">--help</a></code>
* <code title="cli">praisonai langextract view <a href="./src/praisonai/praisonai/cli/commands/langextract.py">--help</a></code>
* <code title="cli">praisonai langfuse config <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
* <code title="cli">praisonai langfuse connect <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
* <code title="cli">praisonai langfuse init <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
* <code title="cli">praisonai langfuse langfuse-start <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
* <code title="cli">praisonai langfuse sessions <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
* <code title="cli">praisonai langfuse show <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
* <code title="cli">praisonai langfuse status <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
* <code title="cli">praisonai langfuse stop <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
* <code title="cli">praisonai langfuse test <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
* <code title="cli">praisonai langfuse traces <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
* <code title="cli">praisonai langfuse version <a href="./src/praisonai/praisonai/cli/commands/langfuse.py">--help</a></code>
* <code title="cli">praisonai loop help <a href="./src/praisonai/praisonai/cli/commands/loop.py">--help</a></code>
* <code title="cli">praisonai loop loop-main <a href="./src/praisonai/praisonai/cli/commands/loop.py">--help</a></code>
* <code title="cli">praisonai lsp logs <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
* <code title="cli">praisonai lsp lsp-callback <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
* <code title="cli">praisonai lsp start <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
* <code title="cli">praisonai lsp status <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
* <code title="cli">praisonai lsp stop <a href="./src/praisonai/praisonai/cli/commands/lsp.py">--help</a></code>
* <code title="cli">praisonai managed delete <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed get <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed list <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed managed-callback <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed multi <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed restore <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed resume <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed run <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed save <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed show <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed update <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai mcp add <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp auth <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp describe <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp list <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp logout <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp mcp-callback <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp remove <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp run <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp status <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp sync <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp test <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai mcp tools <a href="./src/praisonai/praisonai/cli/commands/mcp.py">--help</a></code>
* <code title="cli">praisonai memory add <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
* <code title="cli">praisonai memory clear <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
* <code title="cli">praisonai memory search <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
* <code title="cli">praisonai memory show <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
* <code title="cli">praisonai memory status <a href="./src/praisonai/praisonai/cli/commands/memory.py">--help</a></code>
* <code title="cli">praisonai models describe <a href="./src/praisonai/praisonai/cli/commands/models.py">--help</a></code>
* <code title="cli">praisonai models list <a href="./src/praisonai/praisonai/cli/commands/models.py">--help</a></code>
* <code title="cli">praisonai models validate <a href="./src/praisonai/praisonai/cli/commands/models.py">--help</a></code>
* <code title="cli">praisonai n8n export <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
* <code title="cli">praisonai n8n import <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
* <code title="cli">praisonai n8n list <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
* <code title="cli">praisonai n8n preview <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
* <code title="cli">praisonai n8n pull <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
* <code title="cli">praisonai n8n push <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
* <code title="cli">praisonai n8n test <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
* <code title="cli">praisonai obs obs-fallback <a href="./src/praisonai/praisonai/cli/commands/obs.py">--help</a></code>
* <code title="cli">praisonai onboard onboard-callback <a href="./src/praisonai/praisonai/cli/commands/onboard.py">--help</a></code>
* <code title="cli">praisonai package install <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
* <code title="cli">praisonai package list <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
* <code title="cli">praisonai package uninstall <a href="./src/praisonai/praisonai/cli/commands/package.py">--help</a></code>
* <code title="cli">praisonai pairing approve <a href="./src/praisonai/praisonai/cli/commands/pairing.py">--help</a></code>
* <code title="cli">praisonai pairing clear <a href="./src/praisonai/praisonai/cli/commands/pairing.py">--help</a></code>
* <code title="cli">praisonai pairing list <a href="./src/praisonai/praisonai/cli/commands/pairing.py">--help</a></code>
* <code title="cli">praisonai pairing revoke <a href="./src/praisonai/praisonai/cli/commands/pairing.py">--help</a></code>
* <code title="cli">praisonai paths show <a href="./src/praisonai/praisonai/cli/commands/paths.py">--help</a></code>
* <code title="cli">praisonai permissions allow <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions ask <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions deny <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions export <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions import-rules <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions list <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions remove <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai permissions reset <a href="./src/praisonai/praisonai/cli/commands/permissions.py">--help</a></code>
* <code title="cli">praisonai plugins create <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins disable <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins discover <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins doctor <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins enable <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins info <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins install <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins list <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins plugins-callback <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai plugins remove <a href="./src/praisonai/praisonai/cli/commands/plugins.py">--help</a></code>
* <code title="cli">praisonai port check <a href="./src/praisonai/praisonai/cli/commands/port.py">--help</a></code>
* <code title="cli">praisonai port kill <a href="./src/praisonai/praisonai/cli/commands/port.py">--help</a></code>
* <code title="cli">praisonai port list <a href="./src/praisonai/praisonai/cli/commands/port.py">--help</a></code>
* <code title="cli">praisonai port port-callback <a href="./src/praisonai/praisonai/cli/commands/port.py">--help</a></code>
* <code title="cli">praisonai profile imports <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
* <code title="cli">praisonai profile optimize <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
* <code title="cli">praisonai profile profile-callback <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
* <code title="cli">praisonai profile query <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
* <code title="cli">praisonai profile snapshot <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
* <code title="cli">praisonai profile startup <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
* <code title="cli">praisonai profile suite <a href="./src/praisonai/praisonai/cli/commands/profile.py">--help</a></code>
* <code title="cli">praisonai publish publish-main <a href="./src/praisonai/praisonai/cli/commands/publish.py">--help</a></code>
* <code title="cli">praisonai publish pypi <a href="./src/praisonai/praisonai/cli/commands/publish.py">--help</a></code>
* <code title="cli">praisonai rag chat <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
* <code title="cli">praisonai rag eval <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
* <code title="cli">praisonai rag index <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
* <code title="cli">praisonai rag query <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
* <code title="cli">praisonai rag serve <a href="./src/praisonai/praisonai/cli/commands/rag.py">--help</a></code>
* <code title="cli">praisonai realtime realtime-main <a href="./src/praisonai/praisonai/cli/commands/realtime.py">--help</a></code>
* <code title="cli">praisonai recipe apply <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe create <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe info <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe install <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe judge <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe list <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe optimize <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe run <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe serve <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai registry list <a href="./src/praisonai/praisonai/cli/commands/registry.py">--help</a></code>
* <code title="cli">praisonai registry serve <a href="./src/praisonai/praisonai/cli/commands/registry.py">--help</a></code>
* <code title="cli">praisonai replay cleanup <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay context <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay dashboard <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay delete <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay flow <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay list <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay show <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai research research-main <a href="./src/praisonai/praisonai/cli/commands/research.py">--help</a></code>
* <code title="cli">praisonai retrieval index <a href="./src/praisonai/praisonai/cli/commands/retrieval.py">--help</a></code>
* <code title="cli">praisonai retrieval query <a href="./src/praisonai/praisonai/cli/commands/retrieval.py">--help</a></code>
* <code title="cli">praisonai retrieval search <a href="./src/praisonai/praisonai/cli/commands/retrieval.py">--help</a></code>
* <code title="cli">praisonai rules add <a href="./src/praisonai/praisonai/cli/commands/rules.py">--help</a></code>
* <code title="cli">praisonai rules clear <a href="./src/praisonai/praisonai/cli/commands/rules.py">--help</a></code>
* <code title="cli">praisonai rules list <a href="./src/praisonai/praisonai/cli/commands/rules.py">--help</a></code>
* <code title="cli">praisonai run <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
* <code title="cli">praisonai run run-main <a href="./src/praisonai/praisonai/cli/commands/run.py">--help</a></code>
* <code title="cli">praisonai sandbox explain <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai sandbox list <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai sandbox recreate <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai sandbox sandbox-callback <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai sandbox status <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai schedule add <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule blueprint <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule blueprint-list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule delete <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule describe <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule logs <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule restart <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule schedule-callback <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule start <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule stats <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule stop <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule suggestion-accept <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule suggestion-dismiss <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule suggestion-propose <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule suggestions <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai serve a2a <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve a2u <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve acp <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve agents <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve docs <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve gateway <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve lsp <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve mcp <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve openai <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve rag <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve recipe <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve registry <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve scheduler <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve serve-callback <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve start <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve status <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve stop <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve ui <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve ui-gateway <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve unified <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai session delete <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
* <code title="cli">praisonai session export <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
* <code title="cli">praisonai session import <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
* <code title="cli">praisonai session list <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
* <code title="cli">praisonai session resume <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
* <code title="cli">praisonai session show <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
* <code title="cli">praisonai setup config <a href="./src/praisonai/praisonai/cli/commands/setup.py">--help</a></code>
* <code title="cli">praisonai setup reset <a href="./src/praisonai/praisonai/cli/commands/setup.py">--help</a></code>
* <code title="cli">praisonai setup setup-callback <a href="./src/praisonai/praisonai/cli/commands/setup.py">--help</a></code>
* <code title="cli">praisonai setup wizard <a href="./src/praisonai/praisonai/cli/commands/setup.py">--help</a></code>
* <code title="cli">praisonai skills check <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills create <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills eligible <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills info <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills install <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills learn <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills list <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills search <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills validate <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai standardise check <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai standardise fix <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai standardise init <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai standardise report <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai templates create <a href="./src/praisonai/praisonai/cli/commands/templates.py">--help</a></code>
* <code title="cli">praisonai templates list <a href="./src/praisonai/praisonai/cli/commands/templates.py">--help</a></code>
* <code title="cli">praisonai test info <a href="./src/praisonai/praisonai/cli/commands/test.py">--help</a></code>
* <code title="cli">praisonai test interactive <a href="./src/praisonai/praisonai/cli/commands/test.py">--help</a></code>
* <code title="cli">praisonai test run <a href="./src/praisonai/praisonai/cli/commands/test.py">--help</a></code>
* <code title="cli">praisonai todo add <a href="./src/praisonai/praisonai/cli/commands/todo.py">--help</a></code>
* <code title="cli">praisonai todo done <a href="./src/praisonai/praisonai/cli/commands/todo.py">--help</a></code>
* <code title="cli">praisonai todo list <a href="./src/praisonai/praisonai/cli/commands/todo.py">--help</a></code>
* <code title="cli">praisonai tools info <a href="./src/praisonai/praisonai/cli/commands/tools.py">--help</a></code>
* <code title="cli">praisonai tools list <a href="./src/praisonai/praisonai/cli/commands/tools.py">--help</a></code>
* <code title="cli">praisonai tools test <a href="./src/praisonai/praisonai/cli/commands/tools.py">--help</a></code>
* <code title="cli">praisonai tools validate <a href="./src/praisonai/praisonai/cli/commands/tools.py">--help</a></code>
* <code title="cli">praisonai traces disable <a href="./src/praisonai/praisonai/cli/commands/traces.py">--help</a></code>
* <code title="cli">praisonai traces enable <a href="./src/praisonai/praisonai/cli/commands/traces.py">--help</a></code>
* <code title="cli">praisonai traces list <a href="./src/praisonai/praisonai/cli/commands/traces.py">--help</a></code>
* <code title="cli">praisonai traces status <a href="./src/praisonai/praisonai/cli/commands/traces.py">--help</a></code>
* <code title="cli">praisonai tracker batch <a href="./src/praisonai/praisonai/cli/commands/tracker.py">--help</a></code>
* <code title="cli">praisonai tracker judge <a href="./src/praisonai/praisonai/cli/commands/tracker.py">--help</a></code>
* <code title="cli">praisonai tracker run <a href="./src/praisonai/praisonai/cli/commands/tracker.py">--help</a></code>
* <code title="cli">praisonai tracker tools <a href="./src/praisonai/praisonai/cli/commands/tracker.py">--help</a></code>
* <code title="cli">praisonai tracker tracker-main <a href="./src/praisonai/praisonai/cli/commands/tracker.py">--help</a></code>
* <code title="cli">praisonai train agents <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
* <code title="cli">praisonai train apply <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
* <code title="cli">praisonai train list <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
* <code title="cli">praisonai train llm <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
* <code title="cli">praisonai train show <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
* <code title="cli">praisonai train train-callback <a href="./src/praisonai/praisonai/cli/commands/train.py">--help</a></code>
* <code title="cli">praisonai ui agents <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
* <code title="cli">praisonai ui bot <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
* <code title="cli">praisonai ui realtime <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
* <code title="cli">praisonai ui ui <a href="./src/praisonai/praisonai/cli/commands/ui.py">--help</a></code>
* <code title="cli">praisonai up logs <a href="./src/praisonai/praisonai/cli/commands/up.py">--help</a></code>
* <code title="cli">praisonai up status <a href="./src/praisonai/praisonai/cli/commands/up.py">--help</a></code>
* <code title="cli">praisonai up up-start <a href="./src/praisonai/praisonai/cli/commands/up.py">--help</a></code>
* <code title="cli">praisonai validate check <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
* <code title="cli">praisonai validate schema <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
* <code title="cli">praisonai validate validate <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
* <code title="cli">praisonai version check <a href="./src/praisonai/praisonai/cli/commands/version.py">--help</a></code>
* <code title="cli">praisonai version show <a href="./src/praisonai/praisonai/cli/commands/version.py">--help</a></code>
* <code title="cli">praisonai version version-callback <a href="./src/praisonai/praisonai/cli/commands/version.py">--help</a></code>
* <code title="cli">praisonai workflow create <a href="./src/praisonai/praisonai/cli/commands/workflow.py">--help</a></code>
* <code title="cli">praisonai workflow list <a href="./src/praisonai/praisonai/cli/commands/workflow.py">--help</a></code>
* <code title="cli">praisonai workflow run <a href="./src/praisonai/praisonai/cli/commands/workflow.py">--help</a></code>
# TypeScript
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.111" \
"praisonai>=4.6.80" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.111" \
"praisonai>=4.6.80" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.111" \
"praisonai>=4.6.80" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -1,25 +0,0 @@
"""Example third-party framework adapter (entry-point template)."""
from praisonaiagents.frameworks.base import BaseFrameworkAdapter
class MyFrameworkAdapter(BaseFrameworkAdapter):
name = "my_framework"
install_hint = "pip install my-framework-bridge"
requires_tools_extra = False
def is_available(self) -> bool:
return True
def run(
self,
config,
llm_config,
topic,
*,
tools_dict=None,
agent_callback=None,
task_callback=None,
cli_config=None,
) -> str:
return f"my_framework ran: {topic}"
@@ -1,14 +0,0 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "my-framework-bridge"
version = "0.0.1"
dependencies = ["praisonaiagents"]
[tool.setuptools]
py-modules = ["my_framework_adapter"]
[project.entry-points."praisonai.framework_adapters"]
my_framework = "my_framework_adapter:MyFrameworkAdapter"
+16 -29
View File
@@ -1,5 +1,5 @@
"""
Simple test script for 24/7 Agent Scheduler.
Simple test script for 24/7 Agent Scheduler
This script demonstrates the basic usage of AgentScheduler
for running agents periodically.
@@ -8,56 +8,42 @@ Usage:
python simple_test.py
"""
import os
import sys
import time
from unittest.mock import AsyncMock, Mock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../.."))
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../..'))
from unittest.mock import Mock
from praisonai.scheduler import AgentScheduler
# ---------------------------------------------------------------------
# Create a mock agent
#
# The scheduler now supports both asynchronous (astart) and synchronous
# (start) execution. This example exposes both methods so either dispatch
# path can be exercised.
# ---------------------------------------------------------------------
mock_agent = Mock(spec=["name", "start", "astart"])
# Create a mock agent for testing
mock_agent = Mock()
mock_agent.name = "TestAgent"
mock_agent.astart = AsyncMock(
return_value="Test execution successful!"
)
mock_agent.start = Mock(
return_value="Test execution successful!"
)
mock_agent.start = Mock(return_value="Test execution successful!")
# Create scheduler
scheduler = AgentScheduler(
agent=mock_agent,
task="Test task execution",
task="Test task execution"
)
print("🤖 Testing AgentScheduler")
print("=" * 60)
# Execute once
# Test 1: Execute once
print("\n✅ Test 1: Execute once")
result = scheduler.execute_once()
print(f"Result: {result}")
# Statistics
# Test 2: Get stats
print("\n✅ Test 2: Get statistics")
stats = scheduler.get_stats()
print(f"Stats: {stats}")
# Periodic execution
# Test 3: Start scheduler (will run every 5 seconds)
print("\n✅ Test 3: Start scheduler (*/5s)")
scheduler.start("*/5s", run_immediately=True)
import time
print("Running for 12 seconds...")
time.sleep(12)
@@ -65,10 +51,11 @@ time.sleep(12)
print("\n✅ Test 4: Stop scheduler")
scheduler.stop()
# Final statistics
# Final stats
print("\n📊 Final Statistics:")
for key, value in scheduler.get_stats().items():
final_stats = scheduler.get_stats()
for key, value in final_stats.items():
print(f" {key}: {value}")
print("\n✅ All tests passed!")
print("=" * 60)
print("=" * 60)
-106
View File
@@ -1,106 +0,0 @@
"""Use Xquik tweet search as a PraisonAI custom tool.
Set XQUIK_API_KEY before running this example. The tool calls the public
Xquik tweet search endpoint and returns compact tweet summaries for the agent.
"""
from __future__ import annotations
import json
import os
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from praisonaiagents import Agent, tool
XQUIK_SEARCH_URL = "https://xquik.com/api/v1/x/tweets/search"
def _xquik_api_key_available() -> tuple[bool, str]:
return bool(os.environ.get("XQUIK_API_KEY")), "Set XQUIK_API_KEY before using search_x_posts."
def _as_record(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
return {}
def _as_text(value: Any) -> str:
if isinstance(value, str):
return " ".join(value.split())
if value is None:
return ""
return " ".join(str(value).split())
def _tweet_line(tweet: dict[str, Any]) -> str:
author_record = _as_record(tweet.get("author") or tweet.get("user"))
author = _as_text(
tweet.get("author_username")
or tweet.get("username")
or author_record.get("username")
or author_record.get("screen_name")
or author_record.get("handle")
)
text = _as_text(tweet.get("text") or tweet.get("full_text") or tweet.get("content"))
tweet_id = _as_text(tweet.get("id") or tweet.get("tweet_id") or tweet.get("tweetId"))
prefix = f"@{author}: " if author else ""
suffix = f" ({tweet_id})" if tweet_id else ""
return f"{prefix}{text}{suffix}".strip()
def _bounded_limit(value: Any) -> int:
try:
parsed = int(float(value))
except (TypeError, ValueError, OverflowError):
return 5
return max(1, min(parsed, 10))
@tool(availability=_xquik_api_key_available)
def search_x_posts(query: str, limit: int = 5) -> str:
"""Search recent public X posts with Xquik."""
api_key = os.environ.get("XQUIK_API_KEY")
if not api_key:
return "Set XQUIK_API_KEY before using search_x_posts."
query_text = _as_text(query)
if not query_text:
return "Provide a non-empty query before using search_x_posts."
bounded_limit = _bounded_limit(limit)
params = {"q": query_text, "limit": bounded_limit, "queryType": "Latest"}
url = f"{XQUIK_SEARCH_URL}?{urlencode(params)}"
request = Request(
url,
headers={"X-API-Key": api_key, "Accept": "application/json"},
)
try:
with urlopen(request, timeout=20) as response:
payload = json.loads(response.read().decode("utf-8"))
except HTTPError as error:
return f"Xquik returned HTTP {error.code}."
except (URLError, TimeoutError, json.JSONDecodeError) as error:
return f"Xquik search failed: {error.__class__.__name__}."
tweets = _as_record(payload).get("tweets")
if not isinstance(tweets, list) or not tweets:
return "No posts found."
lines = [_tweet_line(_as_record(tweet)) for tweet in tweets[:bounded_limit]]
return "\n".join(line for line in lines if line) or "No readable posts found."
agent = Agent(
instructions="You find recent public X posts and summarize the useful patterns.",
tools=[search_x_posts],
)
if __name__ == "__main__":
agent.start("Search X for recent AI agent framework discussions.")
-53
View File
@@ -1,53 +0,0 @@
# Files allowed to contain lazy ``from praisonai.*`` imports in praisonai-code.
# New direct imports require review; prefer praisonai_code._wrapper_bridge.
# Regenerate counts: bash scripts/check_c7_imports.sh
#
# Format: path # reason (approx line count after C7.1)
cli/main.py # legacy argparse, auto, agents_generator (wrapper-enhanced)
cli/features/recipe.py # recipe runner (wrapper integrations)
cli/features/templates.py # template store in wrapper
cli/commands/serve.py # hybrid; gateway serve subpaths need wrapper
cli/commands/train.py # training uses wrapper capabilities
cli/commands/docs.py # docs server wrapper paths
cli/commands/replay.py # trace replay wrapper
cli/features/deploy.py # deployment integrations
cli/commands/standardise.py # migration tooling
cli/commands/schedule.py # scheduler wrapper
cli/commands/n8n.py # n8n integration
cli/commands/examples.py # example runners
cli/features/persistence.py # persistence backends
cli/commands/langfuse.py # observability
cli/commands/flow.py # Langflow
cli/features/recipe_optimizer.py # recipe tooling
cli/interactive/async_tui.py # interactive wrapper hooks
cli/commands/mcp.py # MCP server management
cli/commands/validate.py # validation against wrapper schemas
cli/commands/context.py # context features
cli/commands/batch.py # batch runner
cli/features/agent_scheduler.py # scheduler integration
cli/features/eval.py # eval harness
cli/features/tools.py # tool discovery
cli/features/sandbox_cli.py # sandbox wrapper
cli/features/registry.py # plugin registry wrapper
cli/features/ollama.py # ollama wrapper
cli/features/workflow.py # workflow wrapper
cli/features/tui/app.py # TUI wrapper hooks
cli/features/interactive_runtime.py # runtime bridge
cli/features/endpoints.py # API endpoints
cli/features/background.py # background jobs
cli/features/agent_tools.py # agent tools wrapper
cli/features/acp.py # ACP wrapper
cli/commands/rag.py # RAG server
cli/commands/realtime.py # realtime API
cli/commands/knowledge.py # knowledge CLI
cli/commands/langextract.py # langextract
cli/commands/profile.py # profiler
cli/commands/recipe.py # recipe command
cli/commands/audit.py # audit
cli/interactive/core.py # interactive core
cli/commands/managed.py # managed deployment (wrapper integrations)
cli/commands/app.py # legacy app launcher (wrapper-enhanced)
cli/features/doctor/checks/performance_checks.py # perf checks probe wrapper import cost
_version.py # optional wrapper version fallback
cli/langfuse_client.py # langfuse shim path
-86
View File
@@ -1,86 +0,0 @@
#!/usr/bin/env bash
# C7 import-direction gate for praisonai-code (standalone hot path).
set -euo pipefail
ROOT="${1:-src/praisonai-code/praisonai_code}"
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Module-level hot path: praisonai wrapper only (not praisonaiagents / praisonai_code).
HOT_PATH_RE='^from praisonai([[:space:]]|\.|$)|^import praisonai([[:space:]]|\.|$)'
# Line-level wrapper imports (exclude praisonaiagents, praisonai_code, praisonai-tools).
ANY_WRAPPER_RE='(^[[:space:]]*from praisonai([[:space:]]|\.)|^import praisonai($|\.))'
ALLOWLIST="scripts/c7_wrapper_import_allowlist.txt"
echo "== C7 hot-path module-level gate =="
for f in \
"$ROOT/cli/main.py" \
"$ROOT/cli/app.py" \
"$ROOT/cli/commands/run.py" \
"$ROOT/cli/commands/chat.py" \
"$ROOT/cli/commands/code.py"
do
if grep -E "$HOT_PATH_RE" "$f" 2>/dev/null; then
echo "FAIL: module-level wrapper import in $f"
exit 1
fi
done
echo "hot-path module-level gate ok"
echo "== C7 strict gate (run/chat/code/app — no wrapper imports) =="
for f in \
"$ROOT/cli/app.py" \
"$ROOT/cli/commands/run.py" \
"$ROOT/cli/commands/chat.py" \
"$ROOT/cli/commands/code.py"
do
if grep -E "$ANY_WRAPPER_RE" "$f" 2>/dev/null; then
echo "FAIL: wrapper import in $f"
exit 1
fi
done
echo "strict hot-path gate ok"
echo "== C7 regression baseline (total wrapper import lines) =="
BASELINE="${C7_WRAPPER_IMPORT_BASELINE:-225}"
# Count wrapper import lines with a portable tool. Prefer ripgrep when present
# (fast, respects the same regex) and fall back to POSIX grep -r otherwise, so
# the gate does not exit 127 on runners without rg. The `|| true` keeps a
# no-match (grep/rg exit 1) from aborting the script under `set -o pipefail`.
if command -v rg >/dev/null 2>&1; then
COUNT="$( { rg -c "$ANY_WRAPPER_RE" "$ROOT" --glob '*.py' 2>/dev/null || true; } | awk -F: '{s+=$2} END {print s+0}')"
else
COUNT="$( { grep -rEc --include='*.py' "$ANY_WRAPPER_RE" "$ROOT" 2>/dev/null || true; } | awk -F: '{s+=$2} END {print s+0}')"
fi
echo "wrapper import lines: $COUNT (baseline $BASELINE)"
if [ "$COUNT" -gt "$BASELINE" ]; then
echo "FAIL: wrapper import count regressed above baseline ($COUNT > $BASELINE)"
exit 1
fi
echo "regression baseline ok"
echo "== C7 allowlist enforcement =="
if [ -f "$ALLOWLIST" ]; then
# Files (relative to $ROOT) that carry wrapper imports.
if command -v rg >/dev/null 2>&1; then
OFFENDERS="$( { rg -l "$ANY_WRAPPER_RE" "$ROOT" --glob '*.py' 2>/dev/null || true; } )"
else
OFFENDERS="$( { grep -rlE --include='*.py' "$ANY_WRAPPER_RE" "$ROOT" 2>/dev/null || true; } )"
fi
# Strip the reasons/comments and blank/comment lines from the allowlist.
ALLOWED="$(sed -E 's/[[:space:]]*#.*$//; /^[[:space:]]*$/d' "$ALLOWLIST" | sed -E 's/[[:space:]]+$//')"
FAIL=0
while IFS= read -r f; do
[ -z "$f" ] && continue
rel="${f#"$ROOT/"}"
if ! printf '%s\n' "$ALLOWED" | grep -Fxq "$rel"; then
echo "FAIL: non-allowlisted wrapper import in $rel (add to $ALLOWLIST after review)"
FAIL=1
fi
done <<< "$OFFENDERS"
if [ "$FAIL" -ne 0 ]; then
exit 1
fi
echo "allowlist enforcement ok"
else
echo "allowlist file missing: $ALLOWLIST (skipping enforcement)"
fi
+11 -140
View File
@@ -42,11 +42,7 @@ Each feature runs 3 ways: **CLI, YAML, Python**.
│ │ └── pyproject.toml # Package config
│ │
│ ├── praisonai/ # Wrapper (praisonai)
│ │ ├── praisonai/ # Bot/channel CLI, integrations, heavy impls
│ │ └── pyproject.toml
│ │
│ ├── praisonai-code/ # Agentic terminal CLI (praisonai_code)
│ │ ├── praisonai_code/ # run, chat, code, runtime, cli_backends
│ │ ├── praisonai/ # CLI, integrations, heavy impls
│ │ └── pyproject.toml
│ │
│ └── praisonai-ts/ # TypeScript SDK
@@ -84,10 +80,7 @@ Each feature runs 3 ways: **CLI, YAML, Python**.
```
┌─────────────────────────────────────────────────────────────────┐
│ praisonai (Wrapper) │
│ Bot/channel CLI • Gateway • Integrations • Heavy impls │
├─────────────────────────────────────────────────────────────────┤
│ praisonai-code (Terminal agent) │
│ run • chat • code • doctor • runtime • cli_backends • Typer │
│ CLI • Integrations • Heavy Implementations • Optional Deps │
├─────────────────────────────────────────────────────────────────┤
│ praisonaiagents (Core SDK) │
│ Protocols • Hooks • Adapters • Base Classes • Decorators │
@@ -97,123 +90,6 @@ Each feature runs 3 ways: **CLI, YAML, Python**.
└─────────────────────────────────────────────────────────────────┘
```
### 2.3 `praisonai-code` — Agentic Terminal CLI Split (C5/C6)
The **agentic terminal CLI** (`run`, `chat`, `code`, warm runtime, CLI backends, Typer app) lives in the separate **`praisonai-code`** PyPI package (`praisonai_code`). The **`praisonai`** wrapper keeps bot/channel orchestration, gateway, and heavy integrations. **`pip install praisonai`** still installs everything; old import paths are preserved via shims.
| Product | Location | Stays out of |
|---------|----------|--------------|
| Agentic terminal CLI | `src/praisonai-code/praisonai_code/` | Must **not** depend on `praisonai` (PyPI cycle) |
| Bot/channel runtime | `src/praisonai/praisonai/` | Gateway, BotOS, onboarding, dashboard |
| Core SDK | `src/praisonai-agents/praisonaiagents/` | Heavy CLI / integrations |
**Moved into `praisonai_code` (~304 modules):**
| Step | Content |
|------|---------|
| C1 | `runtime/`, `cli_backends/` |
| C2 | `cli/interactive/`, `execution/`, `ui/`, `output/`, `state/` |
| C3 | `cli/commands/*` (80 Typer command modules) |
| C4 | `cli/features/*` (158 feature handlers; bot features excepted) |
| C5 | `cli/main.py`, `app.py`, `configuration/`, `session/`, `utils/`, schema modules |
**Intentionally retained in the wrapper (not shims):**
- **Commands:** `gateway`, `bot`, `pairing`, `identity`, `onboard`, `kanban`, `dashboard`, `claw`
- **Features:** `gateway`, `bots_cli`, `onboard`, `approval`, `serve`
- **Domains:** `praisonai.gateway.*`, `praisonai.bots.*`, integrations, endpoints
**Backward-compat shim patterns** (old `praisonai.*` paths must keep working):
| Pattern | Used for | Module identity |
|---------|----------|-----------------|
| `sys.modules[__name__] = _impl` | `cli/main`, `cli/app`, each moved `cli/commands/*`, schema modules | Same object as `praisonai_code.*` |
| `alias_package()` + `_AliasFinder` | `cli/output`, `interactive`, `execution`, `ui`, `state`, `session`, `configuration`, `utils` | Same submodule objects |
| Package `__getattr__` + submodule alias | `runtime/`, `cli_backends/` | Submodules identical; package object differs |
| `features/__init__.py` `__path__` extension | Extracted features; five bot features stay local | Handler imports via `__getattr__` |
**Typer lazy-load:** `praisonai_code.cli.app._WRAPPER_COMMANDS` lists bot/channel commands that resolve via **`praisonai.cli.commands.*`** (wrapper), not `praisonai_code.cli.commands.*`.
**Monorepo bootstrap:** `praisonai._bootstrap.ensure_praisonai_code()` adds sibling `src/praisonai-code` to `sys.path` so `PYTHONPATH=src/praisonai-agents:src/praisonai` works without an explicit code entry.
**Install order (CI and local dev):** `praisonai-agents` → `praisonai-code` → `praisonai` (see `.github/actions/install-monorepo-packages`).
**Invocation paths that must remain valid:**
```bash
praisonai … # console script → praisonai.__main__
python -m praisonai …
python -m praisonai.cli.main …
python -m praisonai.runtime …
from praisonai.cli.main import PraisonAI
from praisonai.cli.commands.run import …
unittest.mock.patch("praisonai.cli.commands.run.X") # same module object
```
**Regression gate:** `src/praisonai/tests/unit/test_c5_backward_compat.py` (also run in optimised CI smoke). Standalone `pip install praisonai-code` requires declared deps (e.g. `toml` for config resolver).
**Key files:**
| What | Where |
|------|-------|
| Typer app + lazy commands | `praisonai_code/cli/app.py` |
| Shim helper | `praisonai/cli/_shim.py` |
| Bootstrap | `praisonai/_bootstrap.py` |
| CLI main shim | `praisonai/cli/main.py` |
| Runtime / backends shims | `praisonai/runtime/`, `praisonai/cli_backends/` |
**Do not** recreate `src/praisonai/praisonai_code/` — it shadows the real package.
**PyPI publish order:** `praisonaiagents` → `praisonai-code` → `praisonai` (see `src/praisonai/scripts/bump_and_release.py` and `.github/workflows/pypi-release.yml`). Gateway/bots changes release via wrapper only; agentic CLI changes also bump `praisonai-code`.
#### C6 integration gate (#2519)
C6 is the **sign-off step** after C0–C5 file moves: no further moves, only regression verification and release wiring.
| Check | Command / artefact |
|-------|-------------------|
| Smoke `--help` | `praisonai`, `praisonai run/chat/code/daemon/attach/gateway/bot/doctor --help` |
| Compat regression | `pytest src/praisonai/tests/unit/test_c5_backward_compat.py` |
| CLI shard | `pytest src/praisonai/tests/unit/cli/` |
| Warm runtime | `pytest src/praisonai/tests/unit/test_warm_runtime.py` |
| Doctor shard | `pytest src/praisonai/tests/unit/doctor/` |
| Import perf | `pytest src/praisonai/tests/unit/test_performance_benchmarks.py` (< 500ms) |
| Real LLM smoke | `RUN_REAL_KEY_TESTS=1 PRAISONAI_ALLOW_NETWORK=1 PRAISONAI_TEST_PROVIDERS=all pytest src/praisonai/tests/integration/test_real_key_smoke.py` |
| Verification log | `src/praisonai/tests/C6_VERIFICATION.md` |
**Command split (wrapper vs code):**
| Install target | Agentic CLI (`run`, `chat`, `code`, …) | Bot/gateway (`bot`, `gateway`, `pairing`, …) |
|----------------|----------------------------------------|---------------------------------------------|
| `pip install praisonai` | Yes (via `praisonai-code` dep + shims) | Yes |
| `pip install praisonai-code` only | Yes — agentic hot path (`run`, `chat`, `code`, warm runtime) without wrapper import | No — wrapper-only commands hidden when `praisonai` absent |
**C7 (hot path complete):** Core agentic CLI imports no longer require the `praisonai`
wrapper at module level. Remaining lazy wrapper imports (~211, regression-gated) are optional commands
and features (train, capabilities, bots, framework adapters) — use
`praisonai_code._wrapper_bridge` or `pip install praisonai`. See
`src/praisonai/tests/C7_VERIFICATION.md`. CI gate: `scripts/check_c7_imports.sh`.
### 2.4 Package ownership (C7.1)
| Tier | Package | Owns |
|------|---------|------|
| 1 | `praisonaiagents` | Agent, tools, memory, hooks, `frameworks/` protocols |
| 2 | `praisonai-code` | `run`/`chat`/`code`, Typer, runtime, llm, tool resolution |
| 3 | `praisonai` | Gateway, bots, `framework_adapters/`, capabilities, train, serve orchestration |
**Cross-tier rule:** `praisonai-code` must not declare a PyPI dependency on `praisonai`. Use
[`praisonai_code._wrapper_bridge`](../praisonai-code/praisonai_code/_wrapper_bridge.py) for lazy wrapper access.
**framework_adapters:** protocols in SDK; registry + CrewAI/AutoGen/PraisonAI bodies stay in
[`praisonai/framework_adapters/`](../praisonai/praisonai/framework_adapters/). Do not move to `praisonai-code`.
**Typer `_WRAPPER_COMMANDS`:** only for commands whose implementation lives in the wrapper
(`bot`, `gateway`, `pairing`, `identity`, `onboard`, `kanban`, `dashboard`, `claw`). Commands
implemented in `praisonai_code` (`train`, `serve`) stay local; bridge internal wrapper imports.
Full boundary doc: [`src/praisonai/tests/C7.1_BOUNDARIES.md`](../praisonai/tests/C7.1_BOUNDARIES.md).
---
## 3. Core SDK Architecture (praisonaiagents)
@@ -302,14 +178,15 @@ mongodb = ["pymongo>=4.6.3", "motor>=3.4.0"]
### 4.1 Protocol-Driven Core (MUST)
```
Core SDK (praisonaiagents) praisonai-code (terminal CLI) Wrapper (praisonai)
━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━
✅ Protocols ✅ run/chat/code/doctor/setup ✅ Bot/gateway CLI
✅ Hooks ✅ runtime, cli_backends ✅ Integrations
✅ Adapters ✅ Typer app, moved commands ✅ Heavy implementations
✅ Base classes ✅ Depends on agents only ✅ Optional deps
✅ Decorators ❌ Must not import praisonai ✅ UI, DB adapters
❌ Heavy implementations ❌ Bot/channel commands ❌ Core logic
Core SDK (praisonaiagents) Wrapper (praisonai)
━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━
✅ Protocols ✅ Heavy implementations
✅ Hooks ✅ CLI commands
✅ Adapters ✅ External integrations
✅ Base classes ✅ Optional deps
✅ Decorators ✅ UI components
✅ Dataclasses ✅ Database adapters
❌ Heavy implementations ❌ Core logic
```
### 4.2 No Performance Impact (MUST)
@@ -862,12 +739,6 @@ from praisonaiagents import Workflow, Route, Parallel, Loop
| Policy engine | `praisonaiagents/policy/engine.py` |
| Scheduler module | `praisonaiagents/scheduler/` |
| Schedule tools | `praisonaiagents/tools/schedule_tools.py` |
| Agentic CLI (Typer) | `praisonai_code/cli/app.py` |
| CLI main / run | `praisonai_code/cli/main.py` |
| Warm runtime | `praisonai_code/runtime/` |
| CLI backends | `praisonai_code/cli_backends/` |
| Back-compat shims | `praisonai/cli/_shim.py`, `praisonai/cli/main.py` |
| C5 regression tests | `praisonai/tests/unit/test_c5_backward_compat.py` |
---
+66 -67
View File
@@ -1,6 +1,6 @@
# Documentation Parity Tracker (Python)
> **Categories:** 66 | **Documented:** 66 | **Parity:** 100.0%
> **Categories:** 66 | **Documented:** 65 | **Parity:** 98.5%
This report compares **Python SDK feature categories** against **Python documentation** (docs/concepts, docs/features, etc.).
@@ -9,86 +9,85 @@ This report compares **Python SDK feature categories** against **Python document
| Metric | Count |
|--------|-------|
| Feature Categories | 66 |
| **Documented Categories** | **66** |
| **Undocumented Categories** | **0** |
| **Parity** | **100.0%** |
| **Documented Categories** | **65** |
| **Undocumented Categories** | **1** |
| **Parity** | **98.5%** |
## Documented Categories
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 420 |
| ✅ Agent | 21 | 43 | 12014 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2951 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 585 |
| ✅ Approval | 1 | 5 | 2106 |
| ✅ Audio | 2 | 10 | 658 |
| ✅ Auto Generation | 5 | 7 | 2046 |
| ✅ Autonomy | 3 | 6 | 2163 |
| ✅ Bots | 7 | 24 | 8599 |
| ✅ Agent | 21 | 42 | 11340 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2880 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 575 |
| ✅ Approval | 1 | 3 | 1344 |
| ✅ Audio | 2 | 10 | 649 |
| ✅ Auto Generation | 5 | 6 | 1637 |
| ✅ Autonomy | 3 | 5 | 1919 |
| ✅ Bots | 7 | 23 | 7714 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 111 | 28360 |
| ✅ Chunking | 2 | 2 | 372 |
| ✅ CLI | 5 | 108 | 26975 |
| ✅ Chunking | 2 | 2 | 288 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 2 | 13 | 3752 |
| ✅ Conditions | 1 | 3 | 1124 |
| ✅ Configuration | 3 | 7 | 2299 |
| ✅ Context Management | 16 | 33 | 11478 |
| ✅ Database | 1 | 40 | 5744 |
| ✅ Deep Research | 8 | 2 | 497 |
| ✅ Display | 6 | 3 | 648 |
| ✅ Embeddings | 6 | 23 | 1990 |
| ✅ Evaluation | 1 | 6 | 2511 |
| ✅ Events | 1 | 2 | 735 |
| ✅ Execution | 3 | 4 | 1455 |
| ✅ Failover | 2 | 1 | 400 |
| ✅ Files | 2 | 6 | 1845 |
| ✅ Flow | 1 | 3 | 777 |
| ✅ Gateway | 7 | 36 | 13110 |
| ✅ Guardrails | 4 | 4 | 1642 |
| ✅ Handoffs | 11 | 6 | 2654 |
| ✅ Hooks | 2 | 9 | 3564 |
| ✅ Image | 1 | 11 | 1113 |
| ✅ Knowledge | 4 | 12 | 3658 |
| ✅ LLM | 3 | 12 | 4292 |
| ✅ Loops | 4 | 3 | 797 |
| ✅ MCP | 1 | 52 | 11155 |
| ✅ Memory | 6 | 17 | 6141 |
| ✅ OCR | 2 | 1 | 237 |
| ✅ Observability | 2 | 23 | 2768 |
| ✅ Optimizer | 1 | 2 | 750 |
| ✅ Output | 3 | 5 | 1102 |
| ✅ Parallel Execution | 3 | 2 | 392 |
| ✅ Planning | 6 | 6 | 1418 |
| ✅ Plugins | 8 | 3 | 994 |
| ✅ Prompts | 2 | 7 | 1201 |
| ✅ Providers | 1 | 54 | 7047 |
| ✅ Query | 1 | 2 | 623 |
| ✅ RAG | 5 | 15 | 2891 |
| ✅ Code Execution | 2 | 13 | 3554 |
| ✅ Conditions | 1 | 3 | 1085 |
| ✅ Configuration | 3 | 7 | 2160 |
| ✅ Context Management | 16 | 33 | 10280 |
| ✅ Database | 1 | 40 | 5373 |
| ✅ Deep Research | 8 | 2 | 437 |
| ✅ Display | 6 | 2 | 1175 |
| ✅ Embeddings | 6 | 23 | 1966 |
| ✅ Evaluation | 1 | 6 | 2539 |
| ✅ Events | 1 | 2 | 764 |
| ✅ Execution | 3 | 3 | 1099 |
| ✅ Failover | 2 | 1 | 372 |
| ✅ Files | 2 | 6 | 1632 |
| ✅ Flow | 1 | 2 | 435 |
| ✅ Gateway | 7 | 21 | 7477 |
| ✅ Guardrails | 4 | 4 | 1619 |
| ✅ Handoffs | 11 | 6 | 2499 |
| ✅ Hooks | 2 | 8 | 3150 |
| ✅ Image | 1 | 11 | 1096 |
| ✅ Knowledge | 4 | 24 | 4525 |
| ✅ LLM | 3 | 10 | 3674 |
| ✅ Loops | 4 | 3 | 774 |
| ✅ MCP | 1 | 51 | 10939 |
| ✅ Memory | 6 | 16 | 5879 |
| ✅ OCR | 2 | 1 | 226 |
| ✅ Observability | 2 | 23 | 2754 |
| ✅ Optimizer | 1 | 2 | 653 |
| ✅ Output | 3 | 4 | 860 |
| ✅ Parallel Execution | 3 | 2 | 399 |
| ✅ Planning | 6 | 5 | 1198 |
| ✅ Plugins | 8 | 3 | 1502 |
| ✅ Prompts | 2 | 7 | 1569 |
| ✅ Providers | 1 | 52 | 6214 |
| ✅ Query | 1 | 2 | 616 |
| ✅ RAG | 5 | 15 | 3193 |
| ✅ Realtime | 2 | 5 | 584 |
| ✅ Reflection | 3 | 3 | 665 |
| ✅ Retrieval | 2 | 5 | 915 |
| ✅ Routing | 1 | 2 | 390 |
| ✅ Sandbox | 5 | 6 | 2464 |
| ✅ Security | 1 | 3 | 2775 |
| ✅ Sessions | 4 | 12 | 4292 |
| ✅ Skills | 6 | 13 | 4856 |
| ✅ Tasks | 2 | 6 | 2715 |
| ✅ Telemetry | 1 | 2 | 576 |
| ✅ Templates | 1 | 8 | 1657 |
| ✅ Tools | 12 | 125 | 31358 |
| ✅ Reflection | 3 | 2 | 477 |
| ✅ Retrieval | 2 | 5 | 945 |
| ✅ Routing | 1 | 2 | 597 |
| ✅ Sandbox | 5 | 6 | 2512 |
| ✅ Security | 1 | 3 | 2563 |
| ✅ Sessions | 4 | 12 | 3793 |
| ✅ Skills | 6 | 10 | 3663 |
| ✅ Tasks | 2 | 6 | 3064 |
| ✅ Telemetry | 1 | 2 | 560 |
| ✅ Templates | 1 | 7 | 1411 |
| ✅ Tools | 12 | 118 | 25895 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Vector Store | 1 | 12 | 1139 |
| ✅ Video | 2 | 6 | 510 |
| ✅ Vision | 2 | 1 | 283 |
| ✅ Web | 3 | 8 | 1910 |
| ✅ Workflows | 5 | 16 | 6764 |
| ✅ Web | 3 | 7 | 1537 |
| ✅ Workflows | 4 | 14 | 5893 |
## Documentation Without Features
## Undocumented Categories (Need Documentation)
These docs exist but don't match any implemented feature category:
- ℹ️ Documents (1 docs, 787 lines)
| Category | Features |
|----------|----------|
| ❌ Vector Store | 1 |
---
@@ -183,7 +183,6 @@ _LAZY_IMPORTS = {
'Include': ('praisonaiagents.workflows', 'Include'),
'MAX_NESTING_DEPTH': ('praisonaiagents.workflows', 'MAX_NESTING_DEPTH'),
'YAMLWorkflowParser': ('praisonaiagents.workflows', 'YAMLWorkflowParser'),
'WorkflowHooksConfig': ('praisonaiagents.workflows', 'WorkflowHooksConfig'),
# Conditions (Protocol-driven condition evaluation)
'ConditionProtocol': ('praisonaiagents.conditions.protocols', 'ConditionProtocol'),
@@ -534,10 +533,6 @@ _LAZY_IMPORTS = {
# Managed backend protocol (implementation + config in praisonai wrapper)
'ManagedBackendProtocol': ('praisonaiagents.agent.protocols', 'ManagedBackendProtocol'),
# Framework adapter protocol (implementations in praisonai wrapper / praisonai-frameworks)
'FrameworkAdapterProtocol': ('praisonaiagents.frameworks.protocols', 'FrameworkAdapterProtocol'),
'BaseFrameworkAdapter': ('praisonaiagents.frameworks.base', 'BaseFrameworkAdapter'),
# Managed agent events (provider-agnostic)
'ManagedEvent': ('praisonaiagents.managed.events', 'ManagedEvent'),
@@ -261,44 +261,13 @@ class Agent(SteeringMixin, SandboxMixin, SkillReviewMixin, UnifiedExecutionMixin
cls._env_output_checked = True
return cls._env_output_mode
# Ordered (credential env-var, provider-appropriate default model). The
# first provider whose credential is present wins. OpenAI is first so
# existing OpenAI users keep their default; any other single configured
# provider yields a matching default instead of a failing OpenAI default.
_PROVIDER_DEFAULT_MODELS = (
("OPENAI_API_KEY", "gpt-4o-mini"),
("ANTHROPIC_API_KEY", "anthropic/claude-3-5-sonnet-latest"),
("GEMINI_API_KEY", "gemini/gemini-1.5-flash"),
("GOOGLE_API_KEY", "google/gemini-1.5-flash"),
("GROQ_API_KEY", "groq/llama-3.3-70b-versatile"),
("COHERE_API_KEY", "cohere/command-r"),
("OLLAMA_HOST", "ollama/llama3.2"),
)
@classmethod
def _resolve_default_model(cls):
"""Resolve a provider-aware default model from present credentials.
Precedence: ``OPENAI_MODEL_NAME`` (backward compatible) > first
provider whose credential env var is set > hardcoded ``gpt-4o-mini``.
Keeps core free of provider-detection policy beyond a simple env scan;
the wrapper layer owns recency state and first-run notices.
"""
explicit = os.getenv('OPENAI_MODEL_NAME')
if explicit:
return explicit
for key_var, model in cls._PROVIDER_DEFAULT_MODELS:
if os.environ.get(key_var):
return model
return 'gpt-4o-mini'
@classmethod
def _get_default_model(cls):
"""Get cached provider-aware default model name (thread-safe)."""
"""Get cached default model name from OPENAI_MODEL_NAME env var (thread-safe)."""
if not cls._default_model_checked:
with cls._env_cache_lock:
if not cls._default_model_checked:
cls._default_model = cls._resolve_default_model()
cls._default_model = os.getenv('OPENAI_MODEL_NAME', 'gpt-4o-mini')
cls._default_model_checked = True
return cls._default_model
@@ -306,22 +275,6 @@ class Agent(SteeringMixin, SandboxMixin, SkillReviewMixin, UnifiedExecutionMixin
"""Lazy-create LLM instance from deferred init params (avoids import at Agent())."""
if self._llm_instance is not None:
return self._llm_instance
if self._panel_descriptor is not None:
try:
from ..llm.panel import create_panel_llm
# Forward the same Agent-level connection/execution settings a
# normal model selection preserves (base_url, api_key, auth,
# metrics, max_iter, web_search, web_fetch, prompt_caching,
# claude_memory) so the panel aggregator behaves identically.
self._llm_instance = create_panel_llm(
self._panel_descriptor, **self._panel_llm_kwargs
)
except ImportError as e:
raise ImportError(
"LLM features requested but dependencies not installed. "
"Please install with: pip install \"praisonaiagents[llm]\""
) from e
return self._llm_instance
if self._llm_init_params:
try:
from ..llm.llm import LLM
@@ -1594,8 +1547,6 @@ class Agent(SteeringMixin, SandboxMixin, SkillReviewMixin, UnifiedExecutionMixin
self._using_custom_llm = False
self._llm_instance = None
self._llm_init_params = None
self._panel_descriptor = None
self._panel_llm_kwargs = {}
# Flag to track if final result has been displayed to prevent duplicates
self._final_display_shown = False
@@ -1663,38 +1614,8 @@ class Agent(SteeringMixin, SandboxMixin, SkillReviewMixin, UnifiedExecutionMixin
self.base_url = base_url
self.api_key = api_key
# Panel (multi-model) descriptor: "panel:<name>" or {"provider": "panel"}.
# Resolved lazily into a PanelLLM; composes with the normal tool loop.
# Detected inline (no heavy import) to keep Agent() construction lazy.
_is_panel = (
(isinstance(llm, str) and llm.startswith("panel:"))
or (isinstance(llm, dict) and llm.get("provider") == "panel")
)
if _is_panel:
self._panel_descriptor = llm
self._using_custom_llm = True
self.llm = llm if isinstance(llm, str) else "panel"
# Capture the same connection/execution options the normal LLM paths
# forward, so the panel aggregator (and references) behave identically
# to a regular model selection. Stored now because some (e.g. auth)
# are not retained as Agent attributes.
self._panel_llm_kwargs = {
k: v
for k, v in (
("base_url", base_url),
("api_key", api_key),
("auth", auth),
("metrics", metrics),
("max_iter", max_iter),
("web_search", web_search),
("web_fetch", web_fetch),
("prompt_caching", prompt_caching),
("claude_memory", claude_memory),
)
if v is not None
}
# If base_url is provided, defer custom LLM instance creation
elif base_url:
if base_url:
if isinstance(llm, dict):
llm_config = llm.copy()
llm_config['base_url'] = base_url
@@ -1984,8 +1905,7 @@ Your Goal: {self.goal}
# export PRAISONAI_TOOL_SAFETY=off. This adds zero Agent kwargs.
_raw_safety_env = os.environ.get("PRAISONAI_TOOL_SAFETY")
_safety_env = (_raw_safety_env or "").strip().lower()
_bypass_safety = _safety_env in ("off", "full", "none", "0", "false")
if not _bypass_safety:
if _safety_env not in ("off", "full", "none", "0", "false"):
from ..approval.registry import PERMISSION_PRESETS
_resolved_safety_env = _safety_env or "default"
_preset_deny = PERMISSION_PRESETS.get(_resolved_safety_env)
@@ -2000,27 +1920,7 @@ Your Goal: {self.goal}
)
_resolved_safety_env = "default"
_preset_deny = PERMISSION_PRESETS.get(_resolved_safety_env)
# Safe-by-default ask: when no approval kwarg was supplied
# (``approval is None``) and we are attached to an interactive
# terminal, route dangerous built-in tools (shell exec, file
# writes, etc.) through an interactive approval prompt instead
# of silently hard-denying them. This makes a fresh, un-flagged
# agent safe *and* usable — the user is asked once per dangerous
# call and can allow/deny.
#
# In non-interactive contexts (pipes, CI) — or when the user
# explicitly passed ``approval=False`` to opt out of prompting —
# we keep the existing deny-by-default preset so dangerous tools
# never execute unattended without an explicit policy. The full
# bypass remains ``PRAISONAI_TOOL_SAFETY=off`` / ``approval="bypass"``.
if approval is None and self._is_interactive_session():
from ..approval.backends import ConsoleBackend
self._approval_backend = ConsoleBackend()
# Leave _perm_deny empty so dangerous tools reach the backend
# (which classifies them via DEFAULT_DANGEROUS_TOOLS) and the
# user is prompted to allow/deny rather than auto-denied.
elif _preset_deny is not None:
if _preset_deny is not None:
self._perm_deny = _preset_deny
elif isinstance(approval, ApprovalConfig):
self._approval_backend = approval.backend
@@ -2717,40 +2617,10 @@ Summary:"""
from ..skills import SkillManager
self._skill_manager = SkillManager()
# Discover bundles so '@bundle' selectors in self._skills can be
# expanded into their member skill names before injection. Honour
# the configured discovery scope: only fall back to default skill
# dirs when auto_discover is enabled or no explicit dirs are set.
try:
self._skill_manager.discover_bundles(
self._skills_dirs,
include_defaults=auto_discover or not self._skills_dirs,
)
except Exception as e: # noqa: BLE001 - bundles are optional
logging.debug("Bundle discovery skipped: %s", e)
# Add explicit skill selectors. '@bundle' entries expand to their
# member skill names (resolved by name against discovered skills);
# plain paths pass through unchanged and load directly. Member
# discovery honours the configured scope (only fall back to default
# skill dirs when auto_discover is on), and a missing member is
# logged so a typo'd bundle entry is not silently dropped.
# Add explicit skill paths
if self._skills:
from ..skills.bundles import is_bundle_selector
for selector in self._skills:
if is_bundle_selector(selector):
for member in self._skill_manager.resolve([selector]):
loaded = self._skill_manager.add_skill_by_name(
member, self._skills_dirs,
include_defaults=auto_discover,
)
if loaded is None:
logging.warning(
"Skill bundle member '%s' (from '%s') not "
"found; skipping.", member, selector,
)
else:
self._skill_manager.add_skill(selector)
for skill_path in self._skills:
self._skill_manager.add_skill(skill_path)
# Discover skills from directories; honour SkillsConfig.auto_discover
# by falling back to default locations when requested.
@@ -4197,6 +4067,77 @@ Summary:"""
# Runtime Capability Management
# -------------------------------------------------------------------------
def _resolve_runtime_config(self, runtime):
"""Resolve runtime configuration parameter.
Args:
runtime: Runtime parameter (bool, str, dict, or RuntimeConfig)
Returns:
RuntimeConfig instance or None
"""
try:
from ..config import resolve_runtime
return resolve_runtime(runtime)
except ImportError:
raise ImportError(
"Runtime capability features requested but configuration not available. "
"This should not happen in a properly installed praisonaiagents package."
)
def _validate_runtime_capabilities(self):
"""Validate that current runtime supports required capabilities.
Performs fail-fast validation of runtime capabilities against
agent requirements at config/creation time.
Raises:
CapabilityValidationError: If validation fails
"""
if not self._runtime_config or not self._runtime_config.required_capabilities:
return # No requirements to validate
try:
from ..runtime import RuntimeCapability, validate_capabilities, get_native_runtime_capabilities
# Convert string capability names to enum values
required_set = set()
for cap in self._runtime_config.required_capabilities:
if isinstance(cap, str):
# Convert string to enum value
cap_upper = cap.upper()
if hasattr(RuntimeCapability, cap_upper):
required_set.add(getattr(RuntimeCapability, cap_upper))
else:
raise ValueError(f"Unknown capability: {cap}")
else:
# Assume it's already a RuntimeCapability enum
required_set.add(cap)
# Get the actual runtime capabilities based on the selected runtime
runtime_name = self._runtime_config.preferred_runtime or "native"
# Determine which capability matrix to use based on runtime type
if runtime_name in ["native", "praisonai"]:
runtime_matrix = get_native_runtime_capabilities()
elif runtime_name in ["plugin", "harness", "reduced", "plugin-harness", "claude-code"]:
# Use reduced capabilities for plugin/harness runtimes
from praisonaiagents.runtime.capabilities import get_reduced_harness_capabilities
runtime_matrix = get_reduced_harness_capabilities()
else:
# For unknown runtimes, use reduced capabilities as safe default
from praisonaiagents.runtime.capabilities import get_reduced_harness_capabilities
runtime_matrix = get_reduced_harness_capabilities()
# Perform validation
validate_capabilities(runtime_matrix, required_set, runtime_name)
except ImportError:
raise ImportError(
"Runtime capability validation requested but runtime module not available. "
"This should not happen in a properly installed praisonaiagents package."
)
def _run_verification_hooks(self) -> List[Dict[str, Any]]:
"""Run all registered verification hooks.
@@ -5111,22 +5052,6 @@ Answer:"""
except Exception as e:
logging.error(f"Error processing knowledge item: {knowledge_item}, error: {e}")
@staticmethod
def _is_interactive_session() -> bool:
"""Return True when running attached to an interactive terminal.
Used to decide whether dangerous built-in tools should be routed
through an interactive approval prompt (safe-by-default ``ask``) or
hard-denied (non-interactive deny-by-default). A session counts as
interactive only when both stdin and stdout are TTYs, so pipes, CI
runners and redirected I/O fall back to the deny-by-default policy.
"""
try:
import sys
return bool(sys.stdin.isatty() and sys.stdout.isatty())
except Exception:
return False
def _setup_guardrail(self):
"""Setup the guardrail function based on the provided guardrail parameter."""
if self.guardrail is None:
@@ -6,15 +6,113 @@ Extracted from agent.py for better modularity and maintainability.
"""
import logging
import time
from typing import Optional, Any, Dict, List, TYPE_CHECKING
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
class ChatHandlerMixin:
"""Mixin reserved for chat coordination on the Agent class.
This is a documented placeholder retained for the public mixin surface.
Concrete chat logic (``chat``/``achat``, history management, and response
processing) lives in ``ChatMixin``/``SessionManagerMixin``.
"""Mixin providing chat handling methods for the Agent class.
This mixin focuses on high-level chat coordination and response processing,
while the actual LLM interaction is handled by ChatMixin.
"""
pass
def _format_chat_response(self, response: str, metadata: Optional[Dict[str, Any]] = None) -> str:
"""Format chat response with any necessary post-processing.
Args:
response: Raw response from the LLM
metadata: Optional metadata about the response
Returns:
Formatted response string
"""
if not response:
return ""
# Basic response formatting - can be extended with additional logic
formatted = response.strip()
# Add any metadata-based formatting if needed
if metadata and metadata.get('add_timestamp'):
timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
formatted = f"[{timestamp}] {formatted}"
return formatted
def _validate_chat_input(self, prompt: str) -> bool:
"""Validate chat input before processing.
Args:
prompt: Input prompt to validate
Returns:
True if valid, False otherwise
"""
if not prompt or not isinstance(prompt, str):
logger.warning("Invalid chat input: prompt must be a non-empty string")
return False
if len(prompt.strip()) == 0:
logger.warning("Invalid chat input: prompt cannot be empty or whitespace only")
return False
return True
def _prepare_chat_context(self, prompt: str, **kwargs) -> Dict[str, Any]:
"""Prepare context for chat processing.
Args:
prompt: Input prompt
**kwargs: Additional context parameters
Returns:
Context dictionary for chat processing
"""
context = {
'prompt': prompt,
'timestamp': time.time(),
'agent_name': getattr(self, 'name', None) or 'Agent',
}
# Add any additional context from kwargs
context.update(kwargs)
return context
def _handle_chat_error(self, error: Exception, prompt: str) -> str:
"""Handle chat processing errors gracefully.
Args:
error: The exception that occurred
prompt: The original prompt that caused the error
Returns:
Error response message
"""
logger.error(f"Chat error occurred: {error}", exc_info=True)
# Return a user-friendly error message without exposing internal details.
# Include a timestamp reference so users can correlate with server logs.
error_ref = time.strftime('%Y%m%d%H%M%S')
return f"I encountered an error while processing your request (ref: {error_ref}). Please try again."
def _log_chat_interaction(self, prompt: str, response: str, duration: Optional[float] = None):
"""Log chat interaction for debugging and analytics.
Args:
prompt: Input prompt
response: Generated response
duration: Processing duration in seconds
"""
agent_name = getattr(self, 'name', None) or 'Agent'
duration_str = f" (took {duration:.2f}s)" if duration else ""
logger.debug(f"[{agent_name}] Chat interaction{duration_str}")
logger.debug(f"[{agent_name}] Prompt: {prompt[:100]}{'...' if len(prompt) > 100 else ''}")
logger.debug(f"[{agent_name}] Response: {response[:100]}{'...' if len(response) > 100 else ''}")
@@ -192,10 +192,6 @@ Your Goal: {self.goal}"""
logging.warning(f"Could not extract tool name from {tool}: {e}")
continue
# Prune permission-denied tool names so the advertised surface in the
# system prompt matches the function schema (and execution-time rules).
tool_names = [n for n in tool_names if self._tool_name_allowed(n)]
if tool_names:
system_prompt += f"\n\nYou have access to the following tools: {', '.join(tool_names)}. Use these tools when appropriate to help complete your tasks. Always use tools when they can help provide accurate information or perform actions."
system_prompt += "\n\nExplain Before Acting: Before calling a tool, provide a brief one-sentence explanation of what you are about to do and why. Skip explanations only for repetitive low-level operations where narration would be noisy. When performing a batch of similar operations (e.g. searching for multiple items), explain the group once rather than narrating each call individually."
@@ -524,12 +520,6 @@ Your Goal: {self.goal}"""
cleaned_tools = sort_formatted_tools(cleaned_tools)
# Prune permission-denied tools from the advertised set so the model is
# only ever offered tools it can actually call. Mirrors the execution-time
# enforcement in tool_execution.py (_perm_deny / _perm_allow). No-op when
# no deny set and no allow set are configured (backward compatible).
cleaned_tools = self._prune_denied_tools(cleaned_tools)
# Cache the formatted tools with LRU eviction, including tool search metadata
self._cache_put(
self._formatted_tools_cache,
@@ -538,126 +528,6 @@ Your Goal: {self.goal}"""
)
return cleaned_tools
def _build_before_tool_definitions_input(self, formatted_tools):
"""Build the BEFORE_TOOL_DEFINITIONS hook input.
A deep copy of ``formatted_tools`` is used so that in-place hook
mutations never leak back into ``_format_tools_for_completion``'s cache
and corrupt subsequent requests.
"""
import copy
from ..hooks import HookEvent, BeforeToolDefinitionsInput
return BeforeToolDefinitionsInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
event_name=HookEvent.BEFORE_TOOL_DEFINITIONS,
timestamp=str(time.time()),
agent_name=self.name,
model=self.llm if isinstance(self.llm, str) else str(self.llm),
tool_definitions=copy.deepcopy(formatted_tools),
)
def _apply_before_tool_definitions_hook(self, formatted_tools):
"""Fire the BEFORE_TOOL_DEFINITIONS hook so hooks/plugins can inspect or
rewrite the advertised tool definitions before they reach the LLM.
Mirrors how BEFORE_LLM lets a hook mutate its payload in place. No-op
(returns the input unchanged) when no hook runner or no tools exist.
Returns the possibly-mutated list of tool definitions. Synchronous
path; use ``_aapply_before_tool_definitions_hook`` in async contexts.
"""
if not formatted_tools or not getattr(self, '_hook_runner', None):
return formatted_tools
try:
from ..hooks import HookEvent
_inp = self._build_before_tool_definitions_input(formatted_tools)
self._hook_runner.execute_sync(HookEvent.BEFORE_TOOL_DEFINITIONS, _inp)
# Adopt mutations, mirroring how BEFORE_LLM adopts its payload.
return _inp.tool_definitions
except Exception as _e:
logging.debug(f"[before-tool-definitions] hook skipped: {_e}")
return formatted_tools
async def _aapply_before_tool_definitions_hook(self, formatted_tools):
"""Async variant of ``_apply_before_tool_definitions_hook``.
``execute_sync`` raises inside a running event loop, so the async chat
path must await the hook runner directly to actually run the hook.
"""
if not formatted_tools or not getattr(self, '_hook_runner', None):
return formatted_tools
try:
from ..hooks import HookEvent
_inp = self._build_before_tool_definitions_input(formatted_tools)
await self._hook_runner.execute(HookEvent.BEFORE_TOOL_DEFINITIONS, _inp)
return _inp.tool_definitions
except Exception as _e:
logging.debug(f"[before-tool-definitions] async hook skipped: {_e}")
return formatted_tools
def _prune_denied_tools(self, formatted_tools):
"""Remove tools whose permission resolves to ``deny`` from the payload.
The advertised tool surface is shaped by the agent's effective
permission tier (resolved at ``__init__`` into ``_perm_deny`` /
``_perm_allow``) so the model is never offered a tool it cannot call.
``ask``/``allow`` tools stay advertised — approval still happens at
execution time (defence in depth). This is a no-op when no ``deny`` set
and no ``allow`` set are configured, preserving backward compatibility.
Args:
formatted_tools: List of OpenAI-schema tool definitions.
Returns:
The filtered list (a new list only when something is pruned).
"""
if not formatted_tools:
return formatted_tools
perm_deny = getattr(self, "_perm_deny", None)
perm_allow = getattr(self, "_perm_allow", None)
# Fast path: no permission shaping configured -> advertise everything.
if not perm_deny and perm_allow is None:
return formatted_tools
def _tool_id(tool):
if isinstance(tool, dict) and tool.get("type") == "function":
return str(tool.get("function", {}).get("name") or "")
return ""
pruned = []
for tool in formatted_tools:
name = _tool_id(tool)
if not name:
# Keep unrecognized schemas as-is; cannot evaluate permission.
pruned.append(tool)
continue
if not self._tool_name_allowed(name):
logging.debug("Pruning permission-denied tool from payload: %s", name)
continue
pruned.append(tool)
return pruned
def _tool_name_allowed(self, name):
"""Return ``True`` if ``name`` is callable under the agent's permissions.
Mirrors the execution-time enforcement in ``tool_execution.py``
(``_perm_deny`` / ``_perm_allow``): a tool is denied if it is in the
deny set or, when an allow set is configured, absent from it.
``ask``/``allow`` tools remain callable (approval happens at execution
time). No-op (always allowed) when no permission shaping is configured.
"""
if not name:
return True
perm_deny = getattr(self, "_perm_deny", None)
perm_allow = getattr(self, "_perm_allow", None)
if perm_deny and name in perm_deny:
return False
if perm_allow is not None and name not in perm_allow:
return False
return True
def _build_multimodal_prompt(
self,
prompt: str,
@@ -1116,17 +986,14 @@ Your Goal: {self.goal}"""
# Use the new _format_tools_for_completion helper method
formatted_tools = self._format_tools_for_completion(tools)
# Let hooks/plugins inspect or rewrite advertised tool definitions
formatted_tools = self._apply_before_tool_definitions_hook(formatted_tools)
# Smart fallback for streaming: try streaming first, fall back to non-streaming if unsupported
streaming_response = None
if stream is None:
# Auto-detect: prefer streaming for better UX, fallback if adapter doesn't support it
try:
# First attempt: try with streaming enabled for better user experience
stream_callback = self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
streaming_response = self._chat_completion_with_retry(
final_response = self._chat_completion_with_retry(
messages=messages,
temperature=temperature,
tools=formatted_tools,
@@ -1139,6 +1006,7 @@ Your Goal: {self.goal}"""
stream_callback=stream_callback,
emit_events=True
)
return final_response
except ValueError as e:
if "Streaming is not supported" in str(e):
# Fallback: retry with non-streaming for sync adapters
@@ -1161,22 +1029,19 @@ Your Goal: {self.goal}"""
# UNIFIED: Single protocol-driven dispatch path (fixes DRY violation)
# All LLM providers now go through unified dispatcher for consistency and maintainability
stream_callback = self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
if streaming_response is not None:
final_response = streaming_response
else:
final_response = self._chat_completion_with_retry(
messages=messages,
temperature=temperature,
tools=formatted_tools,
stream=stream,
reasoning_steps=reasoning_steps,
task_name=task_name,
task_description=task_description,
task_id=task_id,
response_format=response_format,
stream_callback=stream_callback,
emit_events=True,
)
final_response = self._chat_completion_with_retry(
messages=messages,
temperature=temperature,
tools=formatted_tools,
stream=stream,
reasoning_steps=reasoning_steps,
task_name=task_name,
task_description=task_description,
task_id=task_id,
response_format=response_format,
stream_callback=stream_callback,
emit_events=True,
)
# Emit LLM response trace event with token usage
_duration_ms = (time.time() - start_time) * 1000
@@ -1733,26 +1598,6 @@ Your Goal: {self.goal}"""
# Cache the dispatcher
self._unified_dispatcher = dispatcher
# Pre-call budget guard (parity with sync _chat_completion). Bots and
# other async callers route through here, not _chat_completion.
if self._max_budget and self._on_budget_exceeded == "stop":
_est_min_cost = self._estimate_min_call_cost(
messages, getattr(self, 'max_tokens', None)
)
with self._cost_lock:
_projected_cost = self._total_cost + _est_min_cost
_current_cost = self._total_cost
if _projected_cost >= self._max_budget:
raise BudgetExceededError(
f"Agent '{self.name}' would exceed budget before call: "
f"${_current_cost:.4f} + est ${_est_min_cost:.4f} >= "
f"${self._max_budget:.4f}",
budget_type="cost",
limit=self._max_budget,
used=_current_cost,
agent_id=self.name
)
# Execute unified async dispatch with all necessary parameters
# Includes all parameters from both legacy paths to ensure full compatibility
@@ -1794,47 +1639,8 @@ Your Goal: {self.goal}"""
or None
),
)
# Post-call budget accounting (parity with sync _chat_completion).
_prompt_tokens = 0
_completion_tokens = 0
_cost_usd = 0.0
if final_response:
_usage = getattr(final_response, 'usage', None)
if _usage:
_prompt_tokens = getattr(_usage, 'prompt_tokens', 0) or 0
_completion_tokens = getattr(_usage, 'completion_tokens', 0) or 0
_cost_usd = self._calculate_llm_cost(
_prompt_tokens, _completion_tokens, response=final_response
)
with self._cost_lock:
self._total_cost += _cost_usd
self._total_tokens_in += _prompt_tokens
self._total_tokens_out += _completion_tokens
self._llm_call_count += 1
budget_exceeded = self._max_budget and self._total_cost >= self._max_budget
current_cost = self._total_cost
if budget_exceeded:
if self._on_budget_exceeded == "stop":
raise BudgetExceededError(
f"Agent '{self.name}' exceeded budget: ${current_cost:.4f} >= ${self._max_budget:.4f}",
budget_type="cost",
limit=self._max_budget,
used=current_cost,
agent_id=self.name
)
elif self._on_budget_exceeded == "warn":
logging.warning(
f"[budget] {self.name}: ${current_cost:.4f} exceeded "
f"${self._max_budget:.4f} budget"
)
elif callable(self._on_budget_exceeded):
self._on_budget_exceeded(current_cost, self._max_budget)
return final_response
except BudgetExceededError:
raise
except Exception as e:
from ..errors import LLMError
# Apply the same structured error classification for async path
@@ -2999,8 +2805,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Use the new _format_tools_for_completion helper method
formatted_tools = self._format_tools_for_completion(tools)
# Let hooks/plugins inspect or rewrite advertised tool definitions
formatted_tools = await self._aapply_before_tool_definitions_hook(formatted_tools)
# NEW: Unified protocol dispatch path (Issue #1304) - Async version
# Enable unified dispatch by default for DRY and feature parity (sync/async consistent)
@@ -3806,11 +3610,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
]
self._append_to_chat_history(assistant_message)
# Execute tool calls and add results to chat history.
# Media-bearing follow-up messages are deferred until all
# tool replies for this turn are appended, keeping the
# tool replies consecutive (provider contract).
_deferred_media_followups = []
# Execute tool calls and add results to chat history
for tool_call in tool_calls_data:
if tool_call['id'] and tool_call['function']['name']:
try:
@@ -3826,22 +3626,12 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
parsed_args,
tool_call_id=tool_call.get('id')
)
# Add tool result to chat history (multimodal-aware)
from .tool_execution import build_tool_result_message_pair
_pair = build_tool_result_message_pair(
tool_result, tool_call['id'],
function_name=tool_call['function']['name'],
)
if _pair:
_tool_msg, _followup_msg = _pair
self._append_to_chat_history(_tool_msg)
_deferred_media_followups.append(_followup_msg)
else:
self._append_to_chat_history({
"role": "tool",
"tool_call_id": tool_call['id'],
"content": str(tool_result)
})
# Add tool result to chat history
self._append_to_chat_history({
"role": "tool",
"tool_call_id": tool_call['id'],
"content": str(tool_result)
})
except Exception as tool_error:
logging.error(f"Tool execution error in streaming: {tool_error}")
# Add error result to chat history
@@ -3850,10 +3640,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
"tool_call_id": tool_call['id'],
"content": f"Error: {str(tool_error)}"
})
# Flush deferred media follow-ups after all tool replies.
for _m in _deferred_media_followups:
self._append_to_chat_history(_m)
else:
# Add complete response to chat history (text-only response)
if response_text:
@@ -10,7 +10,7 @@ import time
from unittest.mock import Mock, patch, MagicMock, AsyncMock
import asyncio
from praisonaiagents.agent.handoff import Handoff, HandoffConfig, ContextPolicy, HandoffResult
from .handoff import Handoff, HandoffConfig, ContextPolicy, HandoffResult
class MockAgent:
@@ -14,7 +14,6 @@ import asyncio
import inspect
import contextvars
import concurrent.futures
import threading
import random
from typing import List, Optional, Any, Dict, Union, TYPE_CHECKING
from ..errors import ToolExecutionError
@@ -50,238 +49,6 @@ class BackoffPolicy:
return base + jitter_amount
# Cap on encoded image bytes injected back into the conversation to avoid
# blowing up the context window. ~5MB of base64 (~3.75MB raw) by default.
MULTIMODAL_IMAGE_BYTE_LIMIT = 5_000_000
def _content_part_to_data_uri(part: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Convert a structured content part into an OpenAI-style message part.
Returns a ``{"type": "image_url", "image_url": {...}}`` part for images, a
``{"type": "text", ...}`` part for text, or ``None`` if it cannot be
represented as a model-visible part (callers should fall back to text).
"""
import base64
if not isinstance(part, dict):
return None
ptype = part.get("type")
if ptype == "text":
text = part.get("text")
if text is None:
return None
return {"type": "text", "text": str(text)}
if ptype in ("image", "image_url"):
# Already-formed OpenAI image_url part
if ptype == "image_url" and isinstance(part.get("image_url"), dict):
return {"type": "image_url", "image_url": part["image_url"]}
url = part.get("url")
if url:
return {"type": "image_url", "image_url": {"url": url}}
data = part.get("data")
if data is None:
return None
mime = part.get("mime") or "image/png"
if isinstance(data, (bytes, bytearray)):
if len(data) > MULTIMODAL_IMAGE_BYTE_LIMIT:
logging.warning(
"Multimodal image part (%d bytes) exceeds limit %d; skipping",
len(data), MULTIMODAL_IMAGE_BYTE_LIMIT,
)
return None
encoded = base64.b64encode(bytes(data)).decode("utf-8")
else:
# Assume already base64-encoded string (strip any data URI prefix)
encoded = str(data)
if encoded.startswith("data:"):
return {"type": "image_url", "image_url": {"url": encoded}}
if len(encoded) > MULTIMODAL_IMAGE_BYTE_LIMIT * 2:
logging.warning(
"Multimodal image part (encoded %d chars) exceeds limit; skipping",
len(encoded),
)
return None
return {
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{encoded}"},
}
# Files / other media are referenced as text (most providers cannot ingest
# arbitrary binaries inline). Surface name/mime so the model is aware.
if ptype == "file":
name = part.get("name") or "file"
mime = part.get("mime") or "application/octet-stream"
return {"type": "text", "text": f"[file: {name} ({mime})]"}
return None
def _normalize_multimodal_result(result: Any) -> Optional[List[Dict[str, Any]]]:
"""Detect a multimodal tool result and return its content parts.
Recognises:
- ``ToolResult`` with ``content`` parts
- a bare list of content-part dicts (``[{"type": "image"|"text"|...}]``)
- a single content-part dict
- MCP-style content blocks (``{"type": "image", "data": ..., "mimeType": ...}``)
Returns ``None`` for plain text/JSON results so the existing path is used.
"""
# ToolResult carrying structured content
content = getattr(result, "content", None)
if content and isinstance(content, list):
return _coerce_parts(content)
if isinstance(result, dict):
parts = _coerce_parts([result])
if parts and any(p.get("type") in ("image", "file") for p in parts):
return parts
return None
if isinstance(result, list) and result:
if all(isinstance(p, dict) and p.get("type") for p in result):
parts = _coerce_parts(result)
if parts and any(p.get("type") in ("image", "file") for p in parts):
return parts
return None
def _coerce_parts(raw_parts: List[Any]) -> List[Dict[str, Any]]:
"""Normalize assorted part shapes (incl. MCP) into the canonical schema."""
parts: List[Dict[str, Any]] = []
for p in raw_parts:
if not isinstance(p, dict):
continue
ptype = p.get("type")
if ptype == "text":
parts.append({"type": "text", "text": p.get("text", "")})
elif ptype in ("image", "image_url"):
norm = dict(p)
norm["type"] = "image" if ptype == "image" else "image_url"
# MCP uses 'mimeType'; normalize to 'mime'
if "mimeType" in norm and "mime" not in norm:
norm["mime"] = norm.pop("mimeType")
parts.append(norm)
elif ptype in ("file", "blob", "resource"):
norm = dict(p)
norm["type"] = "file"
if "mimeType" in norm and "mime" not in norm:
norm["mime"] = norm.pop("mimeType")
parts.append(norm)
return parts
def format_tool_result_messages(
result: Any,
tool_call_id: str,
text_fallback: Optional[str] = None,
function_name: Optional[str] = None,
) -> Optional[List[Dict[str, Any]]]:
"""Build LLM messages from a (possibly multimodal) tool result.
For a plain text/JSON result this returns ``None`` so callers keep their
existing single ``tool`` message behaviour (no regression). For a multimodal
result it returns a list of messages: a ``tool`` message (satisfying the
tool_call_id contract) followed by a ``user`` message carrying the
model-visible image/text parts.
Most providers do not accept ``image_url`` parts inside a ``tool`` role
message, so images are delivered via a follow-up ``user`` message.
When ``function_name`` belongs to an external/untrusted tool, any text
parts are wrapped in the same prompt-injection fence used by the text path
(``wrap_if_external``) so external instructions cannot reach the model as
unfenced content.
"""
pair = build_tool_result_message_pair(
result, tool_call_id, text_fallback=text_fallback, function_name=function_name
)
if pair is None:
return None
tool_message, followup_message = pair
return [tool_message, followup_message]
def build_tool_result_message_pair(
result: Any,
tool_call_id: str,
text_fallback: Optional[str] = None,
function_name: Optional[str] = None,
) -> Optional[tuple]:
"""Return ``(tool_message, followup_user_message)`` for a multimodal result.
Separating the ``tool`` reply from the ``user`` media follow-up lets callers
that process multiple tool calls in one assistant turn keep all ``tool``
replies consecutive (the provider contract) and append the media
``user`` message(s) only after the whole batch is handled.
Returns ``None`` for plain text/JSON results (existing path, no regression).
"""
parts = _normalize_multimodal_result(result)
if not parts:
return None
is_external = False
if function_name:
try:
from ..tools.trust import is_external_tool
is_external = is_external_tool(function_name)
except Exception:
is_external = False
visible_parts: List[Dict[str, Any]] = []
text_summary_chunks: List[str] = []
has_media = False
for part in parts:
formatted = _content_part_to_data_uri(part)
if formatted is None:
continue
if formatted.get("type") == "image_url":
has_media = True
visible_parts.append(formatted)
else:
text_value = formatted.get("text", "")
if is_external and text_value:
text_value = _fence_external_text(function_name, text_value)
formatted = {"type": "text", "text": text_value}
text_summary_chunks.append(text_value)
visible_parts.append(formatted)
if not visible_parts or not has_media:
# Nothing model-visible beyond text -> let the text path handle it
return None
text_summary = "\n".join(c for c in text_summary_chunks if c).strip()
tool_text = text_fallback or text_summary or "[tool returned media; see attached content]"
tool_message = {
"role": "tool",
"tool_call_id": tool_call_id,
"content": tool_text,
}
followup_message = {
"role": "user",
"content": visible_parts,
}
return tool_message, followup_message
def _fence_external_text(function_name: str, text: str) -> str:
"""Apply the external-content prompt-injection fence to a text string."""
try:
wrapped = wrap_if_external(function_name, text)
return wrapped if isinstance(wrapped, str) else text
except Exception:
return text
class ToolExecutionMixin:
"""Mixin providing toolexecution methods for the Agent class."""
@@ -458,7 +225,7 @@ class ToolExecutionMixin:
"""
from ..tools.injected import with_injection_context
from ..trace.context_events import get_context_emitter
from ..streaming.events import StreamEvent, StreamEventType, tool_progress_channel
from ..streaming.events import StreamEvent, StreamEventType
import time as _time
# Emit tool call start event (zero overhead when not set)
@@ -481,17 +248,6 @@ class ToolExecutionMixin:
},
agent_id=self.name,
))
# Build a progress sink so tools can stream incremental output while they
# run (e.g. line-buffered stdout from a long shell command). The sink
# forwards TOOL_PROGRESS events to the stream emitter; it is only active
# when callbacks are registered, so there is zero overhead otherwise.
_progress_sink = None
if _stream_emitter is not None and _stream_emitter.has_callbacks:
def _progress_sink(_event): # noqa: ANN001 — StreamEvent forwarder
_event.tool_call = {"name": function_name, "id": tool_call_id}
_event.agent_id = self.name
_stream_emitter.emit(_event)
try:
# Check for steering messages before tool execution
@@ -595,46 +351,28 @@ class ToolExecutionMixin:
# P8/G11: Apply tool timeout if configured
tool_timeout = getattr(self, '_tool_timeout', None)
if tool_timeout and tool_timeout > 0:
# Guard the sink so a tool that keeps running after a
# timeout (future.cancel() cannot stop a started thread)
# can no longer emit progress once the result is decided.
_attempt_sink = _progress_sink
_progress_active = None
if _progress_sink is not None:
_progress_active = threading.Event()
_progress_active.set()
def _attempt_sink(_event, _src=_progress_sink, _flag=_progress_active): # noqa: ANN001
if _flag.is_set():
_src(_event)
# Activate the progress channel BEFORE copy_context so the
# sink propagates into the executor thread via contextvars.
with tool_progress_channel(_attempt_sink):
# Use copy_context to preserve injection context in executor thread
ctx = contextvars.copy_context()
def execute_with_context():
with with_injection_context(state):
return self._execute_tool_with_circuit_breaker(function_name, arguments)
# Use reusable executor to prevent resource leaks
if not hasattr(self, '_tool_executor'):
self._tool_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=2, thread_name_prefix=f"tool-{self.name}"
)
future = self._tool_executor.submit(ctx.run, execute_with_context)
try:
result = future.result(timeout=tool_timeout)
except concurrent.futures.TimeoutError:
if _progress_active is not None:
_progress_active.clear()
future.cancel()
logging.warning(f"Tool {function_name} timed out after {tool_timeout}s")
result = {"error": f"Tool timed out after {tool_timeout}s", "timeout": True}
# Use copy_context to preserve injection context in executor thread
ctx = contextvars.copy_context()
def execute_with_context():
with with_injection_context(state):
return self._execute_tool_with_circuit_breaker(function_name, arguments)
# Use reusable executor to prevent resource leaks
if not hasattr(self, '_tool_executor'):
self._tool_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=2, thread_name_prefix=f"tool-{self.name}"
)
future = self._tool_executor.submit(ctx.run, execute_with_context)
try:
result = future.result(timeout=tool_timeout)
except concurrent.futures.TimeoutError:
future.cancel()
logging.warning(f"Tool {function_name} timed out after {tool_timeout}s")
result = {"error": f"Tool timed out after {tool_timeout}s", "timeout": True}
else:
with tool_progress_channel(_progress_sink), with_injection_context(state):
with with_injection_context(state):
result = self._execute_tool_with_circuit_breaker(function_name, arguments)
# Check if the result indicates a retryable error
@@ -761,20 +499,13 @@ class ToolExecutionMixin:
# Don't let middleware failures break tool execution
logger.warning(f"Runtime middleware failed for {runtime_id}: {e}")
# Detect multimodal tool results (images/files) BEFORE any trust
# wrapping or truncation mutates/stringifies them. Multimodal results
# are returned as-is so the message-building layer can emit
# model-visible image parts via format_tool_result_messages().
_is_multimodal_result = _normalize_multimodal_result(result) is not None
# Apply prompt injection protection for external tools
# Zero-cost for trusted tools, wraps external content in security markers
if not _is_multimodal_result:
result = wrap_if_external(function_name, result)
result = wrap_if_external(function_name, result)
# Apply tool output truncation to prevent context overflow
# Uses context manager budget if enabled, otherwise applies default limit
if result and not _is_multimodal_result:
if result:
try:
result_str = str(result)
@@ -1230,28 +961,17 @@ class ToolExecutionMixin:
trust_level = tool_registry.get_trust_level(tool_name)
if backend is not None:
# Also honour approval requirements registered through the public
# registry (e.g. a plugin/startup hook marking a custom destructive
# tool as requiring approval). Without this, the backend path would
# only gate the built-in DEFAULT_DANGEROUS_TOOLS and silently skip
# registry-required tools.
approval_registry = get_approval_registry()
registry_required = approval_registry.is_required(tool_name)
# Check if tool needs approval based on multiple criteria
needs_approval = (
approve_all
or tool_name in DEFAULT_DANGEROUS_TOOLS
or registry_required
or (trust_level == "external") # External tools need approval
)
if needs_approval:
request = ApprovalRequest(
tool_name=tool_name,
arguments=tool_args,
risk_level=(
approval_registry.get_risk_level(tool_name)
or DEFAULT_DANGEROUS_TOOLS.get(tool_name, "medium")
),
risk_level=DEFAULT_DANGEROUS_TOOLS.get(tool_name, "medium"),
agent_name=getattr(self, 'name', None),
)
@@ -36,8 +36,6 @@ DEFAULT_DANGEROUS_TOOLS: Dict[str, str] = {
"execute_code": "critical",
"acp_execute_command": "critical",
"write_file": "high",
"edit_file": "high",
"apply_patch": "high",
"delete_file": "high",
"move_file": "high",
"copy_file": "high",
@@ -13,10 +13,6 @@ from typing import Any, Callable, Dict, Optional
from dataclasses import dataclass, field
from concurrent.futures import ThreadPoolExecutor, Future
from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
class JobStatus(str, Enum):
"""Status of a background job."""
@@ -37,11 +33,6 @@ class JobInfo:
completed_at: Optional[float] = None
result: Any = None
error: Optional[str] = None
# Optional origin/delivery context captured at spawn time so a gateway
# can route the result back to the originating conversation on completion
# (mirrors the scheduler's ``deliver="origin"`` grammar). Empty by default,
# preserving the pull-only behaviour when no delivery target is set.
origin: Dict[str, Any] = field(default_factory=dict)
@property
def duration(self) -> Optional[float]:
@@ -96,8 +87,6 @@ class BackgroundJobManager:
self,
func: Callable[[], Any],
job_id: Optional[str] = None,
on_complete: Optional[Callable[["JobInfo"], None]] = None,
origin: Optional[Dict[str, Any]] = None,
) -> str:
"""
Start a new background job.
@@ -105,18 +94,6 @@ class BackgroundJobManager:
Args:
func: The function to execute in the background
job_id: Optional custom job ID (auto-generated if not provided)
on_complete: Optional callback invoked with the terminal
:class:`JobInfo` once the job reaches a terminal state
(completed or failed). This is the observable completion
signal a gateway subscribes to in order to proactively
deliver a background result back to the originating chat
without an active turn. Best-effort — any exception it
raises is swallowed so a delivery failure never crashes the
worker thread. Defaults to ``None`` (pull-only behaviour).
origin: Optional origin/delivery context (e.g. ``platform``,
``chat_id``, ``thread_id``, ``session_id``, ``deliver``)
stored on the :class:`JobInfo` so ``on_complete`` can route
the result. Empty/omitted preserves today's behaviour.
Returns:
The job ID
@@ -126,27 +103,11 @@ class BackgroundJobManager:
job_info = JobInfo(
job_id=job_id,
status=JobStatus.PENDING,
origin=dict(origin) if origin else {},
)
with self._lock:
self._jobs[job_id] = job_info
def _fire_complete() -> None:
if on_complete is None:
return
with self._lock:
info = self._jobs.get(job_id)
if info is None:
return
try:
on_complete(info)
except Exception as e: # noqa: BLE001 — delivery must never crash worker
logger.debug(
"on_complete callback for job %s raised (non-fatal): %s",
job_id, e,
)
def _run_job():
with self._lock:
self._jobs[job_id].status = JobStatus.RUNNING
@@ -158,14 +119,12 @@ class BackgroundJobManager:
self._jobs[job_id].status = JobStatus.COMPLETED
self._jobs[job_id].completed_at = time.time()
self._jobs[job_id].result = result
_fire_complete()
return result
except Exception as e:
with self._lock:
self._jobs[job_id].status = JobStatus.FAILED
self._jobs[job_id].completed_at = time.time()
self._jobs[job_id].error = str(e)
_fire_complete()
raise
future = self._executor.submit(_run_job)
@@ -59,7 +59,7 @@ from .agent_reply import (
AgentReply,
extract_presentation,
)
from .config import BotConfig, BotOSConfig, DisplayPolicy, resolve_display_policy
from .config import BotConfig, BotOSConfig
__all__ = [
"BotProtocol",
@@ -70,8 +70,6 @@ __all__ = [
"BotUser",
"BotChannel",
"BotConfig",
"DisplayPolicy",
"resolve_display_policy",
"MessageType",
"ChatCommandInfo",
"ChatCommandProtocol",
@@ -198,225 +198,6 @@ class BotConfig:
return channel_id in self.allowed_channels
# ---------------------------------------------------------------------------
# Display / verbosity policy
# ---------------------------------------------------------------------------
# Allowed values for each DisplayPolicy field.
_DISPLAY_STREAMING = {"off", "draft", "progress"}
_DISPLAY_TOOL_PROGRESS = {"off", "inline"}
_DISPLAY_FOOTER = {"off", "compact"}
# Platform → tier mapping. Tiers describe the rendering surface so sensible
# defaults can be applied without per-platform configuration.
# edit — edit-capable personal chats (stream live, hide tool spam)
# work — workspace chats (post discrete progress steps)
# noedit — no-edit chats (single final message)
# batch — batch-only channels (one final message, no interim)
_PLATFORM_TIERS: Dict[str, str] = {
"telegram": "edit",
"discord": "edit",
"whatsapp": "noedit",
"slack": "work",
"teams": "work",
"mattermost": "work",
"email": "batch",
"sms": "batch",
}
@dataclass
class DisplayPolicy:
"""Per-platform display / verbosity policy.
Describes the operator's *policy* for how output should be presented on a
channel (as opposed to ``PlatformCapabilities`` which describes what a
platform *can* render).
Attributes:
streaming: Reply streaming mode.
``"off"`` (single final message) | ``"draft"`` (edit in place) |
``"progress"`` (compact status then final).
tool_progress: Whether tool execution progress is surfaced.
``"off"`` (hidden) | ``"inline"`` (inline progress bubbles).
interim_assistant_messages: Show partial assistant messages, or only
the final reply.
footer: Runtime footer / status-line appended to replies.
``"off"`` | ``"compact"`` (e.g. ``model · ctx% · cwd``).
"""
streaming: str = "off"
tool_progress: str = "off"
interim_assistant_messages: bool = False
footer: str = "off"
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"streaming": self.streaming,
"tool_progress": self.tool_progress,
"interim_assistant_messages": self.interim_assistant_messages,
"footer": self.footer,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "DisplayPolicy":
"""Create a policy from a (partial) dict, ignoring unknown keys."""
base = cls()
return cls(
streaming=_coerce_choice(
data.get("streaming", base.streaming), _DISPLAY_STREAMING, base.streaming
),
tool_progress=_coerce_choice(
data.get("tool_progress", base.tool_progress),
_DISPLAY_TOOL_PROGRESS,
base.tool_progress,
),
interim_assistant_messages=_coerce_bool(
data.get(
"interim_assistant_messages", base.interim_assistant_messages
),
base.interim_assistant_messages,
),
footer=_coerce_choice(
data.get("footer", base.footer), _DISPLAY_FOOTER, base.footer
),
)
# Built-in per-tier defaults. Most operators configure nothing and still get
# correct UX on every channel.
_TIER_DEFAULTS: Dict[str, DisplayPolicy] = {
# edit-capable personal chats: stream live, hide tool spam, no footer
"edit": DisplayPolicy(streaming="draft", tool_progress="off", footer="off"),
# workspace chats: post discrete progress steps
"work": DisplayPolicy(streaming="off", tool_progress="inline", footer="off"),
# no-edit chats: single final message
"noedit": DisplayPolicy(streaming="off", tool_progress="off", footer="off"),
# batch-only channels: one final message, no interim
"batch": DisplayPolicy(
streaming="off",
tool_progress="off",
interim_assistant_messages=False,
footer="off",
),
}
def _coerce_choice(value: Any, allowed: set, default: str) -> str:
"""Return ``value`` if it is an allowed choice, else ``default``."""
if isinstance(value, str) and value in allowed:
return value
return default
# String tokens treated as booleans for text-backed config (YAML/JSON/env).
_TRUE_TOKENS = {"true", "1", "yes", "on"}
_FALSE_TOKENS = {"false", "0", "no", "off"}
def _coerce_bool(value: Any, default: bool) -> bool:
"""Coerce ``value`` to a bool without ``bool("false") is True`` surprises.
Real booleans pass through. Strings are matched against known truthy/falsy
tokens (case-insensitive). Anything else falls back to ``default``.
"""
if isinstance(value, bool):
return value
if isinstance(value, str):
token = value.strip().lower()
if token in _TRUE_TOKENS:
return True
if token in _FALSE_TOKENS:
return False
return default
def resolve_display_policy(platform: str, config: Optional[Dict[str, Any]]) -> DisplayPolicy:
"""Resolve the effective :class:`DisplayPolicy` for a platform.
Precedence (highest first):
1. explicit ``display.platforms.<platform>.<setting>``
2. ``display.<setting>`` global default
3. platform-tier default (edit / work / noedit / batch)
4. built-in :class:`DisplayPolicy` default
Args:
platform: Platform name, e.g. ``"telegram"``.
config: A ``display`` config dict (the value of the ``display:`` block),
or a full config dict containing a ``display`` key. ``None`` is
treated as empty.
Returns:
The resolved :class:`DisplayPolicy`.
"""
display = _extract_display(config)
# Layer 4: built-in default.
policy = DisplayPolicy()
# Layer 3: platform-tier default.
tier = _PLATFORM_TIERS.get((platform or "").lower())
if tier and tier in _TIER_DEFAULTS:
policy = _merge_policy(policy, _TIER_DEFAULTS[tier].to_dict())
# Layer 2: global default (display.<setting>).
global_overrides = {
k: v for k, v in display.items() if k != "platforms"
}
policy = _merge_policy(policy, global_overrides)
# Layer 1: explicit platform override.
platforms = display.get("platforms") or {}
if isinstance(platforms, dict):
platform_overrides = _lookup_platform(platforms, platform)
if isinstance(platform_overrides, dict):
policy = _merge_policy(policy, platform_overrides)
return policy
def _lookup_platform(platforms: Dict[str, Any], platform: str) -> Any:
"""Look up a platform's overrides, tolerating key casing differences.
Tries the exact key, then the lower-cased key, then a case-insensitive
scan so natural config keys (e.g. ``Telegram``) still match ``telegram``.
"""
if platform in platforms:
return platforms[platform]
normalized = (platform or "").lower()
if normalized in platforms:
return platforms[normalized]
for key, value in platforms.items():
if isinstance(key, str) and key.lower() == normalized:
return value
return None
def _extract_display(config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Return the ``display`` mapping from ``config``.
Accepts either the ``display`` block directly or a full config dict
containing a ``display`` key.
"""
if not isinstance(config, dict):
return {}
if "display" in config and isinstance(config.get("display"), dict):
return config["display"]
return config
def _merge_policy(policy: DisplayPolicy, overrides: Dict[str, Any]) -> DisplayPolicy:
"""Return a new policy with valid ``overrides`` applied over ``policy``."""
if not isinstance(overrides, dict):
return policy
data = policy.to_dict()
for key in data:
if key in overrides:
data[key] = overrides[key]
return DisplayPolicy.from_dict(data)
@dataclass
class BotOSConfig:
"""Configuration for BotOS — multi-platform bot orchestrator.
@@ -98,10 +98,6 @@ class PlatformCapabilities:
re-dispatch, upgrading delivery from at-least-once to
effectively-once. When False, delivery remains at-least-once and a
crash mid-send may re-send the message.
supports_media: Whether the adapter can attach/upload media files on
outbound sends (photos, documents). When False, the gateway's
delivery router degrades gracefully and reports media as not
attached rather than silently dropping it.
supports_idempotency_token: Whether the transport accepts a
provider-level idempotency token so the platform itself
de-duplicates a retried send. This is an informational capability
@@ -124,7 +120,6 @@ class PlatformCapabilities:
verifies_webhook_signature: bool = False
reconciles_unknown_send: bool = False
supports_idempotency_token: bool = False
supports_media: bool = False
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -143,7 +138,6 @@ class PlatformCapabilities:
"verifies_webhook_signature": self.verifies_webhook_signature,
"reconciles_unknown_send": self.reconciles_unknown_send,
"supports_idempotency_token": self.supports_idempotency_token,
"supports_media": self.supports_media,
}
@classmethod
@@ -164,7 +158,6 @@ class PlatformCapabilities:
verifies_webhook_signature=data.get("verifies_webhook_signature", False),
reconciles_unknown_send=data.get("reconciles_unknown_send", False),
supports_idempotency_token=data.get("supports_idempotency_token", False),
supports_media=data.get("supports_media", False),
)
@@ -515,7 +508,6 @@ def evaluate_channel_health(
health: HealthResult,
startup_grace_seconds: float = 60.0,
stale_after_seconds: float = 120.0,
stuck_after_seconds: float = 900.0,
current_time: Optional[float] = None,
) -> HealthReason:
"""Evaluate channel health and return a reason.
@@ -523,30 +515,10 @@ def evaluate_channel_health(
Pure function that evaluates a HealthResult and determines
the health reason based on various criteria.
Liveness is driven by passive *inbound* transport activity
(``health.last_activity``) rather than only an outbound probe, and the
evaluator is aware of in-flight agent runs (``health.active_runs``) so a
busy channel is never mistaken for a dead socket:
- busy with recent inbound progress -> ``BUSY`` (never restarted);
- busy but no progress beyond ``stuck_after_seconds`` -> ``STUCK``;
- idle and inbound stale beyond ``stale_after_seconds`` -> ``STALE_SOCKET``.
For the busy branch, "progress" is the most recent of inbound transport
activity (``health.last_activity``) and *in-run* progress
(``health.last_run_progress`` — tool calls, streamed draft edits, token
output, or a periodic heartbeat). A single long agent run that streams
output or emits tool events the whole time therefore stays ``BUSY``
indefinitely instead of being torn down mid-run once its wall-clock
crosses ``stuck_after_seconds``; only a run that emits nothing for
``stuck_after_seconds`` is flagged ``STUCK``.
Args:
health: The health result to evaluate
startup_grace_seconds: Grace period for startup
stale_after_seconds: Time after which no inbound activity is stale
stuck_after_seconds: Time after which a busy channel with no progress
is considered stuck
stale_after_seconds: Time after which no activity is considered stale
current_time: Current timestamp (for testing)
Returns:
@@ -571,28 +543,7 @@ def evaluate_channel_health(
if health.probe and not health.probe.ok:
return HealthReason.DISCONNECTED
# Run-aware liveness: a busy channel is never killed mid-run unless it has
# made no progress for an extended period (stuck). "Progress" is the most
# recent of inbound transport activity (``last_activity``) and *in-run*
# progress (``last_run_progress`` — tool/stream/token/heartbeat events),
# so a long run that is actively streaming or calling tools stays BUSY even
# though no new inbound message arrives during the turn. When neither
# timestamp was recorded, fall back to uptime so a hung run with no
# progress signal can still be detected as STUCK rather than staying BUSY
# (non-recoverable) forever.
if health.active_runs > 0:
progress_at = max(
health.last_activity or 0.0,
health.last_run_progress or 0.0,
)
if progress_at <= 0.0:
progress_at = current_time - (health.uptime_seconds or 0.0)
idle = current_time - progress_at
if idle > stuck_after_seconds:
return HealthReason.STUCK
return HealthReason.BUSY
# Check for stale socket (no inbound transport activity while idle)
# Check for stale socket (no transport activity)
if health.last_activity is not None:
time_since_activity = current_time - health.last_activity
if time_since_activity > stale_after_seconds:
@@ -619,12 +570,7 @@ class HealthResult:
error: Error message (if unhealthy)
details: Additional platform-specific health details
reason: Health status reason (optional)
last_activity: Last INBOUND transport activity timestamp (optional)
last_run_progress: Last IN-RUN progress timestamp (optional) — refreshed
on real run progress (tool calls, streamed draft edits, token output,
or a periodic heartbeat) so a long, actively-progressing run keeps the
channel BUSY instead of tripping STUCK on wall-clock alone.
active_runs: Number of in-flight agent turns (busy count)
last_activity: Last transport activity timestamp (optional)
"""
ok: bool
@@ -637,8 +583,6 @@ class HealthResult:
details: Dict[str, Any] = field(default_factory=dict)
reason: Optional[HealthReason] = None
last_activity: Optional[float] = None
last_run_progress: Optional[float] = None
active_runs: int = 0
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -653,8 +597,6 @@ class HealthResult:
"details": self.details,
"reason": self.reason.value if self.reason else None,
"last_activity": self.last_activity,
"last_run_progress": self.last_run_progress,
"active_runs": self.active_runs,
}
@@ -107,7 +107,7 @@ def is_path_like(value: str) -> bool:
if not isinstance(value, str):
return False
# Check for path indicators
# Check for path indicators (POSIX and Windows-style prefixes)
if value.startswith(("./", "../", "/", "~/", ".\\", "..\\", "\\\\", "\\", "~\\")):
return True
@@ -119,11 +119,13 @@ def is_path_like(value: str) -> bool:
and value[2] in ("\\", "/")
):
return True
# Check for directory indicator
if value.endswith(("/", "\\")):
return True
# Check for embedded backslash separators (relative Windows path like "knowledge\docs").
# Backslash is not valid in normal config string values, so it is a strong path signal.
if "\\" in value:
return True
@@ -0,0 +1 @@
"""TDD tests for db integration."""
@@ -28,8 +28,12 @@ def test_lazy_import_no_heavy_deps():
heavy_libs = ['psycopg2', 'pymysql', 'redis'] # DB-specific libs
loaded_heavy = [lib for lib in heavy_libs if lib in sys.modules]
assert not loaded_heavy, f"Heavy DB libs loaded by db module: {loaded_heavy}"
if loaded_heavy:
print(f" ❌ Heavy DB libs loaded by db module: {loaded_heavy}")
return False
print(" ✅ No heavy DB libs loaded by praisonaiagents.db module")
return True
def test_agent_accepts_db_parameter():
@@ -42,9 +46,16 @@ def test_agent_accepts_db_parameter():
sig = inspect.signature(Agent.__init__)
params = list(sig.parameters.keys())
assert 'db' in params, "Agent does not accept 'db' parameter"
assert 'session_id' in params, "Agent does not accept 'session_id' parameter"
if 'db' not in params:
print(" ❌ Agent does not accept 'db' parameter")
return False
if 'session_id' not in params:
print(" ❌ Agent does not accept 'session_id' parameter")
return False
print(" ✅ Agent accepts db and session_id parameters")
return True
class MockDbAdapter:
@@ -91,9 +102,16 @@ def test_db_hook_lifecycle():
)
# Verify db is stored
assert agent._db is mock_db, "Agent did not store db adapter"
assert agent._session_id == "test-session-001", "Agent did not store session_id"
if agent._db is not mock_db:
print(" ❌ Agent did not store db adapter")
return False
if agent._session_id != "test-session-001":
print(" ❌ Agent did not store session_id")
return False
print(" ✅ Agent stores db and session_id correctly")
return True
def test_db_none_unchanged_behavior():
@@ -109,13 +127,19 @@ def test_db_none_unchanged_behavior():
)
# Verify db is None
assert agent._db is None, "Agent._db should be None when not provided"
if agent._db is not None:
print(" ❌ Agent._db should be None when not provided")
return False
# Verify _init_db_session does nothing when db is None
agent._init_db_session()
assert not agent._db_initialized, "_db_initialized should remain False when db is None"
if agent._db_initialized:
print(" ❌ _db_initialized should remain False when db is None")
return False
print(" ✅ db=None behavior unchanged")
return True
def test_session_id_auto_generation():
@@ -136,11 +160,16 @@ def test_session_id_auto_generation():
# Trigger db initialization
agent._init_db_session()
assert agent._session_id is not None, "session_id should be auto-generated"
assert agent._session_id.startswith("session-"), (
f"session_id format unexpected: {agent._session_id}"
)
if agent._session_id is None:
print(" ❌ session_id should be auto-generated")
return False
if not agent._session_id.startswith("session-"):
print(f" ❌ session_id format unexpected: {agent._session_id}")
return False
print(f" ✅ session_id auto-generated: {agent._session_id}")
return True
def test_protocol_compliance():
@@ -151,7 +180,9 @@ def test_protocol_compliance():
# Check DbMessage fields
msg = DbMessage(role="user", content="test")
assert msg.role == "user" and msg.content == "test", "DbMessage fields not working"
if msg.role != "user" or msg.content != "test":
print(" ❌ DbMessage fields not working")
return False
# Check protocol methods
required_methods = [
@@ -165,11 +196,17 @@ def test_protocol_compliance():
mock = MockDbAdapter()
for method in required_methods:
assert hasattr(mock, method), f"MockDbAdapter missing {method}"
if not hasattr(mock, method):
print(f" ❌ MockDbAdapter missing {method}")
return False
# Check protocol isinstance
assert isinstance(mock, DbAdapter), "MockDbAdapter should implement DbAdapter protocol"
if not isinstance(mock, DbAdapter):
print(" ❌ MockDbAdapter should implement DbAdapter protocol")
return False
print(" ✅ DbAdapter protocol properly defined")
return True
def run_all_tests():
@@ -178,23 +215,14 @@ def run_all_tests():
print("PraisonAI Agents DB Integration - TDD Tests")
print("=" * 60)
tests = [
("Lazy Import", test_lazy_import_no_heavy_deps),
("Agent db Parameter", test_agent_accepts_db_parameter),
("DB Hook Lifecycle", test_db_hook_lifecycle),
("db=None Unchanged", test_db_none_unchanged_behavior),
("Session ID Auto-Gen", test_session_id_auto_generation),
("Protocol Compliance", test_protocol_compliance),
]
results = []
for name, test_fn in tests:
try:
test_fn()
results.append((name, True))
except AssertionError as exc:
print(f" ❌ {name}: {exc}")
results.append((name, False))
results.append(("Lazy Import", test_lazy_import_no_heavy_deps()))
results.append(("Agent db Parameter", test_agent_accepts_db_parameter()))
results.append(("DB Hook Lifecycle", test_db_hook_lifecycle()))
results.append(("db=None Unchanged", test_db_none_unchanged_behavior()))
results.append(("Session ID Auto-Gen", test_session_id_auto_generation()))
results.append(("Protocol Compliance", test_protocol_compliance()))
print("\n" + "=" * 60)
passed = sum(1 for _, r in results if r)
@@ -1,22 +0,0 @@
"""Framework adapter protocols and shared helpers (no third-party framework deps)."""
from typing import TYPE_CHECKING
__all__ = [
"FrameworkAdapterProtocol",
"BaseFrameworkAdapter",
]
if TYPE_CHECKING:
from .protocols import FrameworkAdapterProtocol
from .base import BaseFrameworkAdapter
def __getattr__(name: str):
if name == "FrameworkAdapterProtocol":
from .protocols import FrameworkAdapterProtocol
return FrameworkAdapterProtocol
if name == "BaseFrameworkAdapter":
from .base import BaseFrameworkAdapter
return BaseFrameworkAdapter
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -1,148 +0,0 @@
"""Shared base helpers for framework adapters (no third-party imports)."""
from __future__ import annotations
import asyncio
import logging
import os
import re
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, Dict, List, Optional
from .protocols import FrameworkAdapterProtocol
logger = logging.getLogger(__name__)
class BaseFrameworkAdapter:
"""Base class for framework adapters providing common functionality."""
name: str = ""
install_hint: str = ""
requires_tools_extra: bool = False
is_router: bool = False
# Adapters with a native async ``run`` path set this True and override
# ``arun``. When False (default), ``arun`` offloads the sync ``run`` to a
# bounded, per-adapter thread pool so one slow run cannot starve the
# process-wide default executor used by ``asyncio.to_thread``.
SUPPORTS_ASYNC: bool = False
# Bounded per-adapter offload pool. Kept small on purpose: framework runs
# are long-lived, so a large pool provides no benefit and just hides the
# lack of a native async path. Overridable per subclass / instance.
_THREAD_OFFLOAD_MAX_WORKERS: int = 4
DEFAULT_MODEL = "openai/gpt-4o-mini"
def __init__(self) -> None:
self._thread_pool: Optional[ThreadPoolExecutor] = None
def _resolve_llm(self, spec: Any, llm_config: Optional[List[Dict]]) -> str:
"""Resolve a model name from per-agent spec and shared llm_config."""
base = None
key = None
if llm_config and len(llm_config) > 0:
base = llm_config[0].get("base_url")
key = llm_config[0].get("api_key")
if isinstance(spec, str) and spec.strip():
model = spec.strip()
elif isinstance(spec, dict) and spec.get("model"):
model = spec["model"]
else:
model = os.environ.get("MODEL_NAME") or self.DEFAULT_MODEL
# Return model string; wrapper subclasses may upgrade to provider objects.
_ = (base, key)
return model
def _format_template(self, template: str, **kwargs: Any) -> str:
"""Safely format template string, preserving JSON-like braces."""
if not isinstance(template, str):
return template
def _sub(match: re.Match) -> str:
name = match.group(1)
return str(kwargs[name]) if name in kwargs else match.group(0)
return re.sub(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}", _sub, template)
def resolve(
self, *, config: Optional[Dict[str, Any]] = None
) -> FrameworkAdapterProtocol:
return self
def setup(self, *, framework_tag: str) -> None:
pass
def _get_thread_pool(self) -> ThreadPoolExecutor:
"""Lazily create the bounded, per-adapter offload executor."""
# Defensive: tolerate subclasses that don't call super().__init__().
if getattr(self, "_thread_pool", None) is None:
self._thread_pool = ThreadPoolExecutor(
max_workers=self._THREAD_OFFLOAD_MAX_WORKERS,
thread_name_prefix=f"{self.name or 'framework'}-arun",
)
return self._thread_pool
async def _thread_offload_run(self, *args: Any, **kwargs: Any) -> str:
"""Run the sync ``run`` on this adapter's bounded thread pool.
Unlike ``asyncio.to_thread`` (which shares the process-wide default
executor), this isolates the offload so one slow framework run cannot
starve every other async caller in the process.
"""
loop = asyncio.get_running_loop()
pool = self._get_thread_pool()
return await loop.run_in_executor(
pool, lambda: self.run(*args, **kwargs)
)
async def arun(
self,
config: Dict[str, Any],
llm_config: List[Dict],
topic: str,
*,
tools_dict: Optional[Dict[str, Any]] = None,
agent_callback: Optional[Callable] = None,
task_callback: Optional[Callable] = None,
cli_config: Optional[Dict[str, Any]] = None,
) -> str:
if not self.SUPPORTS_ASYNC:
logger.warning(
"Adapter %r has no native async path; delegating to a bounded "
"thread pool (max_workers=%d). Concurrent async callers will "
"contend for this pool.",
self.name or type(self).__name__,
self._THREAD_OFFLOAD_MAX_WORKERS,
)
return await self._thread_offload_run(
config,
llm_config,
topic,
tools_dict=tools_dict,
agent_callback=agent_callback,
task_callback=task_callback,
cli_config=cli_config,
)
def cleanup(self) -> None:
pool = getattr(self, "_thread_pool", None)
if pool is not None:
pool.shutdown(wait=False)
self._thread_pool = None
def __del__(self) -> None:
# Best-effort: shut down the offload pool when the adapter is garbage
# collected so idle worker threads do not outlive the adapter in
# long-lived services that forget to call cleanup(). Guarded because
# __del__ can run during interpreter shutdown when globals are gone.
try:
self.cleanup()
except Exception:
pass
def resolve_alias(self) -> str:
return self.name
@@ -1,65 +0,0 @@
"""Protocol for optional third-party agent framework adapters."""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
@runtime_checkable
class FrameworkAdapterProtocol(Protocol):
"""Contract for YAML-driven multi-framework execution backends."""
name: str
install_hint: str
requires_tools_extra: bool
is_router: bool
def is_available(self) -> bool:
"""Return True when this framework's optional dependencies are installed."""
...
def resolve(
self, *, config: Optional[Dict[str, Any]] = None
) -> "FrameworkAdapterProtocol":
"""Pick a concrete adapter variant (e.g. autogen v0.2 vs v0.4)."""
...
def setup(self, *, framework_tag: str) -> None:
"""Framework-specific pre-run hooks (observability, SDK init, etc.)."""
...
def run(
self,
config: Dict[str, Any],
llm_config: List[Dict],
topic: str,
*,
tools_dict: Optional[Dict[str, Any]] = None,
agent_callback: Optional[Callable] = None,
task_callback: Optional[Callable] = None,
cli_config: Optional[Dict[str, Any]] = None,
) -> str:
"""Execute the framework with shared agents.yaml configuration."""
...
async def arun(
self,
config: Dict[str, Any],
llm_config: List[Dict],
topic: str,
*,
tools_dict: Optional[Dict[str, Any]] = None,
agent_callback: Optional[Callable] = None,
task_callback: Optional[Callable] = None,
cli_config: Optional[Dict[str, Any]] = None,
) -> str:
"""Async execution; default implementations may offload sync run()."""
...
def cleanup(self) -> None:
"""Release resources after execution."""
...
def resolve_alias(self) -> str:
"""Return the concrete adapter name to dispatch to."""
...
@@ -52,36 +52,6 @@ from .protocols import (
GatewayIdlePolicyProtocol,
GatewayIdlePolicy, # backward-compat alias
ScaleToZeroPolicy,
# Gateway graceful-drain on shutdown (Issue #2375)
DrainDecision,
GatewayDrainPolicyProtocol,
DrainTimeoutPolicy,
# Gateway inbound admission control (Issue #2454)
AdmissionDecision,
GatewayConcurrencyPolicyProtocol,
GatewayConcurrencyPolicy, # backward-compat alias
ConcurrencyLimitPolicy,
# Gateway rate-limit admission (Issue #2532)
RateLimitDecision,
RateLimitPolicyProtocol,
RateLimitPolicy, # backward-compat alias
SlidingWindowRateLimitPolicy,
# Port-less, restart-safe external drain trigger (Issue #2390)
current_epoch,
DrainMarkerPolicy,
# Crash / shutdown forensics (Issue #2436)
ShutdownForensicsProtocol,
format_forensics_for_log,
is_supervised,
drain_timeout_has_headroom,
# Code-skew guard for hot operations (Issue #2460)
detect_code_skew,
# Restart-intent exit-code protocol (Issue #2437)
GATEWAY_OK_EXIT_CODE,
GATEWAY_RESTART_EXIT_CODE,
GATEWAY_FATAL_CONFIG_EXIT_CODE,
FatalConfigError,
classify_exit_reason,
# Protocol version negotiation
PROTOCOL_VERSION,
MIN_PROTOCOL_VERSION,
@@ -90,9 +60,6 @@ from .protocols import (
ProtocolHelloOk,
GapInfo,
ResumeSnapshot,
# Out-of-process platform-connector relay (Issue #2485)
CapabilityDescriptor,
RelayTransport,
)
from .hooks import (
HookAction,
@@ -195,35 +162,6 @@ __all__ = [
"GatewayIdlePolicyProtocol",
"GatewayIdlePolicy",
"ScaleToZeroPolicy",
"DrainDecision",
"GatewayDrainPolicyProtocol",
"DrainTimeoutPolicy",
# Gateway inbound admission control (Issue #2454)
"AdmissionDecision",
"GatewayConcurrencyPolicyProtocol",
"GatewayConcurrencyPolicy",
"ConcurrencyLimitPolicy",
# Gateway rate-limit admission (Issue #2532)
"RateLimitDecision",
"RateLimitPolicyProtocol",
"RateLimitPolicy",
"SlidingWindowRateLimitPolicy",
# Port-less, restart-safe external drain trigger (Issue #2390)
"current_epoch",
"DrainMarkerPolicy",
# Crash / shutdown forensics (Issue #2436)
"ShutdownForensicsProtocol",
"format_forensics_for_log",
"is_supervised",
"drain_timeout_has_headroom",
# Code-skew guard for hot operations (Issue #2460)
"detect_code_skew",
# Restart-intent exit-code protocol (Issue #2437)
"GATEWAY_OK_EXIT_CODE",
"GATEWAY_RESTART_EXIT_CODE",
"GATEWAY_FATAL_CONFIG_EXIT_CODE",
"FatalConfigError",
"classify_exit_reason",
# Protocol version negotiation
"PROTOCOL_VERSION",
"MIN_PROTOCOL_VERSION",
@@ -232,9 +170,6 @@ __all__ = [
"ProtocolHelloOk",
"GapInfo",
"ResumeSnapshot",
# Out-of-process platform-connector relay (Issue #2485)
"CapabilityDescriptor",
"RelayTransport",
# Inbound trigger / webhook contract (Issue #2281)
"HookAction",
"HookConfig",
@@ -255,11 +255,6 @@ class GatewayConfig:
ssl_key: Optional[str] = None
max_buffered_bytes: int = 1024 * 1024 # 1MB default
max_queued_frames: int = 1000 # Per-client outbound frame ceiling
# Issue #2454: gateway-wide inbound admission control. 0 disables the gate
# (today's behaviour: every inbound turn dispatches immediately).
max_concurrent_runs: int = 0 # Aggregate concurrency ceiling (0 = unlimited)
queue_depth: int = 0 # Bounded wait queue when at the ceiling
overflow_policy: str = "reject" # reject | queue | shed_oldest
push: PushConfig = field(default_factory=PushConfig)
auth_scopes: Optional[Dict[str, List[str]]] = None
@@ -281,16 +276,6 @@ class GatewayConfig:
raise ValueError("heartbeat_interval must be >= 0")
if self.reconnect_timeout < 0:
raise ValueError("reconnect_timeout must be >= 0")
if self.max_concurrent_runs < 0:
raise ValueError(
"max_concurrent_runs must be >= 0 (use 0 to disable admission control)"
)
if self.queue_depth < 0:
raise ValueError("queue_depth must be >= 0")
if self.overflow_policy not in ("reject", "queue", "shed_oldest"):
raise ValueError(
"overflow_policy must be one of 'reject', 'queue', 'shed_oldest'"
)
@property
def has_scope_policy(self) -> bool:
@@ -359,9 +344,6 @@ class GatewayConfig:
"ssl_enabled": bool(self.ssl_cert and self.ssl_key),
"max_buffered_bytes": self.max_buffered_bytes,
"max_queued_frames": self.max_queued_frames,
"max_concurrent_runs": self.max_concurrent_runs,
"queue_depth": self.queue_depth,
"overflow_policy": self.overflow_policy,
"push": self.push.to_dict(),
"scope_policy_enabled": self.has_scope_policy,
}
@@ -540,9 +522,6 @@ class MultiChannelGatewayConfig:
ssl_key=gw_data.get("ssl_key"),
max_buffered_bytes=int(gw_data.get("max_buffered_bytes", 1024 * 1024)),
max_queued_frames=int(gw_data.get("max_queued_frames", 1000)),
max_concurrent_runs=int(gw_data.get("max_concurrent_runs", 0) or 0),
queue_depth=int(gw_data.get("queue_depth", 0) or 0),
overflow_policy=str(gw_data.get("overflow_policy", "reject") or "reject"),
auth_scopes=auth_scopes,
)
File diff suppressed because it is too large. Load diff
@@ -63,7 +63,6 @@ __all__ = [
# Event-specific inputs
"BeforeToolInput",
"AfterToolInput",
"BeforeToolDefinitionsInput",
"BeforeAgentInput",
"AfterAgentInput",
"BeforeLLMInput",
@@ -80,7 +79,6 @@ __all__ = [
"GatewayStartInput",
"GatewayStopInput",
"ScheduleTriggerInput",
"JobCompletedInput",
# Middleware types
"InvocationContext",
"ModelRequest",
@@ -132,7 +130,6 @@ _LAZY_GROUPS = {
'events': {
'BeforeToolInput': ('praisonaiagents.hooks.events', 'BeforeToolInput'),
'AfterToolInput': ('praisonaiagents.hooks.events', 'AfterToolInput'),
'BeforeToolDefinitionsInput': ('praisonaiagents.hooks.events', 'BeforeToolDefinitionsInput'),
'BeforeAgentInput': ('praisonaiagents.hooks.events', 'BeforeAgentInput'),
'AfterAgentInput': ('praisonaiagents.hooks.events', 'AfterAgentInput'),
'BeforeLLMInput': ('praisonaiagents.hooks.events', 'BeforeLLMInput'),
@@ -147,7 +144,6 @@ _LAZY_GROUPS = {
'GatewayStartInput': ('praisonaiagents.hooks.events', 'GatewayStartInput'),
'GatewayStopInput': ('praisonaiagents.hooks.events', 'GatewayStopInput'),
'ScheduleTriggerInput': ('praisonaiagents.hooks.events', 'ScheduleTriggerInput'),
'JobCompletedInput': ('praisonaiagents.hooks.events', 'JobCompletedInput'),
},
'middleware_types': {
'InvocationContext': ('praisonaiagents.hooks.middleware', 'InvocationContext'),
@@ -47,29 +47,6 @@ class AfterToolInput(HookInput):
return base
@dataclass
class BeforeToolDefinitionsInput(HookInput):
"""Input for BeforeToolDefinitions hooks.
Fired right after the advertised tool definitions are assembled for a
request and before they reach the LLM. Hooks should modify or filter the
``tool_definitions`` list in place (e.g. ``data.tool_definitions[:] = ...``)
to redact parameters, append usage guidance to descriptions, or constrain
the advertised schema per request. This mirrors how BEFORE_LLM mutates its
payload in place; only in-place mutations are adopted by the runtime.
"""
tool_definitions: List[Dict[str, Any]] = field(default_factory=list)
model: str = ""
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"tool_definitions_count": len(self.tool_definitions),
"model": self.model,
})
return base
@dataclass
class BeforeAgentInput(HookInput):
"""Input for BeforeAgent hooks."""
@@ -348,37 +325,3 @@ class ScheduleTriggerInput(HookInput):
"message": self.message[:500] if self.message else "",
})
return base
@dataclass
class JobCompletedInput(HookInput):
"""Input for JOB_COMPLETED hooks (a background job finished).
Emitted when a background subagent/job launched via
``spawn_subagent(background=True)`` reaches a terminal state
(completed or failed). Carries the optional origin/delivery context
captured at spawn time so a gateway can route the result back to the
originating conversation without an active turn.
"""
job_id: str = ""
status: str = ""
result: Any = None
error: Optional[str] = None
deliver: str = ""
platform: str = ""
chat_id: str = ""
thread_id: str = ""
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"job_id": self.job_id,
"status": self.status,
"result": self.result,
"error": self.error,
"deliver": self.deliver,
"platform": self.platform,
"chat_id": self.chat_id,
"thread_id": self.thread_id,
})
return base
@@ -23,7 +23,6 @@ class HookEvent(str, Enum):
# Tool lifecycle
BEFORE_TOOL = "before_tool"
AFTER_TOOL = "after_tool"
BEFORE_TOOL_DEFINITIONS = "before_tool_definitions"
# Agent lifecycle
BEFORE_AGENT = "before_agent"
@@ -69,9 +68,6 @@ class HookEvent(str, Enum):
SCHEDULE_REMOVE = "schedule_remove"
SCHEDULE_TRIGGER = "schedule_trigger"
# Background job lifecycle
JOB_COMPLETED = "job_completed" # A background job finished (ok or error)
# Kanban task lifecycle (wrapper dispatcher + tools emit these)
KANBAN_TASK_CREATED = "kanban_task_created"
KANBAN_TASK_CLAIMED = "kanban_task_claimed"
@@ -9,23 +9,15 @@ interface without coupling the core to SQLite or other heavy implementations.
from praisonaiagents.kanban.protocols import (
KanbanStoreProtocol,
KanbanTaskProtocol,
KanbanRunProtocol,
KanbanRunsProtocol,
KanbanCommentingProtocol,
KanbanLinkingProtocol,
KanbanPromotionProtocol,
KanbanReclaimProtocol,
VALID_KANBAN_STATUSES,
)
__all__ = [
"KanbanStoreProtocol",
"KanbanTaskProtocol",
"KanbanRunProtocol",
"KanbanRunsProtocol",
"KanbanCommentingProtocol",
"KanbanLinkingProtocol",
"KanbanPromotionProtocol",
"KanbanReclaimProtocol",
"VALID_KANBAN_STATUSES",
]
@@ -42,33 +42,6 @@ class KanbanTaskProtocol(TypedDict, total=False):
board: str
created_at: float
updated_at: float
# Claim lease / reclamation fields (optional; populated while running).
# Timestamps are serialized as ISO 8601 strings by Task.to_dict().
claim_lock: str | None
claim_expires: str | None
worker_pid: int | None
last_heartbeat_at: str | None
# Retry / run-history fields (optional).
max_retries: int | None
consecutive_failures: int
current_run_id: int | None
class KanbanRunProtocol(TypedDict, total=False):
"""Typed dict shape for a single task attempt (run).
One row per attempt: outcome plus a structured summary/metadata handoff and
any error. Surfaced to retrying workers and linked children.
"""
id: int
task_id: str
profile: str
outcome: str | None # completed/blocked/crashed/failed/gave_up
summary: str
metadata: dict
error: str
started_at: str | None # ISO-8601 string (matches TaskRun.to_dict())
ended_at: str | None # ISO-8601 string; None while the run is open
@runtime_checkable
@@ -214,57 +187,6 @@ class KanbanCommentingProtocol(Protocol):
...
@runtime_checkable
class KanbanReclaimProtocol(Protocol):
"""Extension protocol for durable claim leases and stale-claim reclamation.
Stores implementing this protocol support recovering tasks stranded by
crashed, killed, or hung workers. The dispatcher tick calls
``reclaim_stale_claims`` to return such tasks to ``ready`` for re-dispatch.
This is kept separate from KanbanStoreProtocol so stores can adopt
reclamation incrementally without breaking isinstance checks on the core
protocol.
"""
def claim_task(
self,
task_id: str,
worker_id: str,
*,
ttl_seconds: int = 900,
worker_pid: int | None = None,
) -> bool:
"""Claim a ready task with a lease (TTL) and optional owner PID.
Returns:
True if the claim succeeded.
"""
...
def heartbeat(
self,
task_id: str,
worker_id: str,
*,
ttl_seconds: int | None = None,
) -> bool:
"""Record a worker heartbeat, optionally extending the claim lease.
Returns:
True if the heartbeat was recorded (worker owns the claim).
"""
...
def reclaim_stale_claims(self, *, stale_timeout_seconds: int = 1800) -> list[str]:
"""Reclaim running tasks whose lease expired and whose worker is dead/stale.
Returns:
List of task IDs returned to ``ready``.
"""
...
@runtime_checkable
class KanbanLinkingProtocol(Protocol):
"""Extension protocol for kanban task linking functionality.
@@ -296,85 +218,4 @@ class KanbanLinkingProtocol(Protocol):
Returns:
True if unlinked successfully, False otherwise
"""
...
@runtime_checkable
class KanbanPromotionProtocol(Protocol):
"""Extension protocol for dependency-driven task promotion.
Implemented separately from KanbanStoreProtocol so that stores can opt
into auto-promotion without breaking isinstance checks on the core
protocol. This is the engine that turns a linked parent->child DAG into
a self-driving pipeline: the dispatcher calls ``recompute_ready`` each
tick before claiming work.
"""
def recompute_ready(self) -> list[str]:
"""Promote dependent tasks to 'ready' when all parents are terminal.
Scans tasks waiting on dependencies and advances any whose parent
tasks are all in a terminal state ('done'/'archived') to 'ready'.
Returns:
List of task IDs promoted to 'ready' in this pass.
"""
...
@runtime_checkable
class KanbanRunsProtocol(Protocol):
"""Extension protocol for per-task attempt (run) history and retry.
Implemented separately from KanbanStoreProtocol so stores can optionally
support durable attempt history, structured handoff and a per-task
circuit-breaker without breaking isinstance checks on the core protocol.
"""
def record_run(
self,
task_id: str,
outcome: str,
*,
profile: str = "",
summary: str | None = None,
metadata: dict | None = None,
error: str | None = None,
) -> dict:
"""Record a completed attempt (open + close in one call).
Args:
task_id: Task that was attempted.
outcome: One of completed/blocked/crashed/failed/gave_up.
profile: Optional worker/profile identifier.
summary: Structured summary of what was done (handoff).
metadata: Structured handoff fields (e.g. changed_files, tests_run).
error: Error text for failed/crashed attempts.
Returns:
The recorded run data.
"""
...
def get_runs(self, task_id: str) -> list[dict]:
"""Return all attempts for a task, oldest first.
Args:
task_id: Task identifier.
Returns:
List of run data (KanbanRunProtocol shape).
"""
...
def record_failure(self, task_id: str, *, error: str | None = None) -> bool:
"""Increment the consecutive-failure counter; auto-block at the limit.
Args:
task_id: Task that just failed an attempt.
error: Optional last error to attach when auto-blocking.
Returns:
True if the task was circuit-broken (auto-blocked) by this call.
"""
...
@@ -171,26 +171,6 @@ def __getattr__(name):
from .sanitize import sanitize_text
_lazy_cache[name] = sanitize_text
return sanitize_text
elif name == "PanelLLM":
from .panel import PanelLLM
_lazy_cache[name] = PanelLLM
return PanelLLM
elif name == "create_panel_llm":
from .panel import create_panel_llm
_lazy_cache[name] = create_panel_llm
return create_panel_llm
elif name == "register_panel_preset":
from .panel import register_panel_preset
_lazy_cache[name] = register_panel_preset
return register_panel_preset
elif name == "resolve_panel_config":
from .panel import resolve_panel_config
_lazy_cache[name] = resolve_panel_config
return resolve_panel_config
elif name == "is_panel_descriptor":
from .panel import is_panel_descriptor
_lazy_cache[name] = is_panel_descriptor
return is_panel_descriptor
elif name == "ErrorCategory":
from .error_classifier import ErrorCategory
_lazy_cache[name] = ErrorCategory
@@ -235,12 +215,6 @@ __all__ = [
"OpenAIAdapter",
"UnifiedLLMDispatcher",
"create_llm_dispatcher",
# Panel (multi-model) provider
"PanelLLM",
"create_panel_llm",
"register_panel_preset",
"resolve_panel_config",
"is_panel_descriptor",
# Exceptions
"LLMProviderError",
"RateLimitError",
@@ -142,6 +142,22 @@ class AuthProfile:
self.last_error = None
self.last_error_time = None
@runtime_checkable
class FailoverProtocol(Protocol):
"""Protocol for failover management."""
def get_next_profile(self) -> Optional[AuthProfile]:
"""Get the next available profile."""
...
def mark_failure(self, profile: AuthProfile, error: str) -> None:
"""Mark a profile as failed."""
...
def mark_success(self, profile: AuthProfile) -> None:
"""Mark a profile as successful."""
...
@dataclass
class FailoverConfig:
"""Configuration for failover behavior.
@@ -387,27 +403,3 @@ class FailoverManager:
"""Reset all profiles to available status."""
for profile in self._profiles:
profile.reset()
@runtime_checkable
class FailoverProtocol(Protocol):
"""Deprecated structural protocol for failover managers.
Retained only for backward compatibility with code that imports the deep
path ``praisonaiagents.llm.failover.FailoverProtocol`` or uses it for
``isinstance`` checks. ``FailoverManager`` already satisfies this shape.
Prefer the public ``LLMFailoverProtocol`` (exported from
``praisonaiagents.llm``) for new code.
"""
def get_next_profile(self) -> Optional[AuthProfile]:
"""Get the next available profile."""
...
def mark_failure(self, profile: AuthProfile, error: str) -> None:
"""Mark a profile as failed."""
...
def mark_success(self, profile: AuthProfile) -> None:
"""Mark a profile as successful."""
...
+141 -364
View File
@@ -191,15 +191,6 @@ class LLM:
# Ollama-specific prompt constants
OLLAMA_TOOL_USAGE_PROMPT = "Please analyze the request and use the available tools to help answer the question. Start by identifying what information you need."
OLLAMA_FINAL_ANSWER_PROMPT = "Based on the tool results above, please provide the final answer to the original question."
# Step-limit finalisation constants (used when the tool loop hits max_iter)
STEP_LIMIT_WRAP_UP_PROMPT = (
"You have reached the step limit for this task. Do not call any tools. "
"Provide a final answer that summarises what you accomplished, what "
"remains unfinished, and any suggested next steps, using the tool "
"results already gathered."
)
STEP_LIMIT_FALLBACK_MESSAGE = "Reached the step limit before finishing this task."
# Force tool usage prompt template (used when model ignores tools)
FORCE_TOOL_USAGE_PROMPT = """You MUST use one of the available tools to answer this question.
@@ -932,156 +923,6 @@ Respond with ONLY a valid JSON tool call in this format:
self._cached_subscription_creds = provider.refresh()
return self._cached_subscription_creds
def _should_attempt_auth_refresh(self, e, attempt):
"""Whether an auth-credential refresh should be attempted this attempt.
Side-effect-free predicate shared by the sync and async retry loops so
the async loop can decide to offload the blocking refresh to a thread.
Uses the pure ``classify_error_kind`` classifier directly rather than
``resolve_failover_decision``: the latter mutates state (e.g. the idle
timeout circuit breaker) and is also called by ``_handle_retry_exception``
for the same exception, which would double-count those mutations.
"""
if not (self._auth_provider_id and attempt == 0):
return False
return self.classify_error_kind(e) == "auth"
def _try_refresh_subscription_creds(self):
"""Attempt a credential refresh, keeping failures non-fatal.
Wraps `_refresh_subscription_creds` so refresh errors never abort the
retry/failover flow. Returns the refreshed credentials or ``None``. This
is the blocking (network) step; the async loop runs it off the event
loop via ``asyncio.to_thread``.
"""
try:
logging.info("Authentication error detected - attempting credential refresh")
return self._refresh_subscription_creds()
except Exception as refresh_error: # noqa: BLE001 - refresh failures must stay non-fatal so retry/failover can continue
logging.warning(
"Failed to refresh subscription credentials for auth provider %r (%s). "
"Verify the provider login/configuration and retry or re-authenticate.",
self._auth_provider_id,
type(refresh_error).__name__,
)
return None
def _handle_retry_exception(self, e, attempt, kwargs, refreshed_creds=None):
"""Decide retry vs raise from a failover decision. No sleep/await.
Shared decision logic used by both the sync (`_call_with_retry`) and
async (`_call_with_retry_async`) retry loops. Performs failover
bookkeeping (profile rotation, legacy failover kwargs update) but never
sleeps or awaits, so it is identical for both paths.
The blocking credential refresh itself is performed by the caller: the
sync loop calls `_try_refresh_subscription_creds()` inline, while the
async loop offloads it to a thread via ``asyncio.to_thread`` so it never
blocks the event loop. The already-refreshed credentials (if any) are
passed in via ``refreshed_creds``.
Args:
e: The exception raised by the wrapped call.
attempt: Zero-based attempt index for the current iteration.
kwargs: Keyword arguments for the wrapped call (may be updated).
refreshed_creds: Credentials already refreshed by the caller (off
the event loop for async), or ``None`` if no refresh was done.
Returns:
Tuple of (should_raise, category, retry_delay, error_str, kwargs):
should_raise: Whether the caller should re-raise immediately.
category: The error category/reason from the decision.
retry_delay: Seconds to wait before the next attempt.
error_str: String form of the exception.
kwargs: The (possibly updated) keyword arguments.
"""
# Use new typed failover decision instead of old classification
decision = self.resolve_failover_decision(e, {"attempt": attempt + 1, "max_retries": self._max_retries})
error_str = str(e)
# Map decision to old variables for compatibility with existing logic
category = decision.reason
can_retry = decision.is_retryable
retry_delay = decision.backoff_ms / 1000.0 # Convert ms to seconds
# Apply refreshed subscription credentials (refresh I/O is done by the
# caller so the async loop can offload it off the event loop).
refreshed_auth = False
if category == "auth" and self._auth_provider_id and attempt == 0 and refreshed_creds:
# Rebuild parameters (don't clear cache), then re-apply the refreshed
# credentials *after* the rebuild. _build_completion_params merges the
# incoming kwargs last, so the failed attempt's stale api_key/base_url
# would otherwise overwrite the freshly refreshed credentials.
kwargs = self._build_completion_params(**kwargs)
kwargs["api_key"] = refreshed_creds.api_key
if refreshed_creds.base_url:
kwargs["base_url"] = refreshed_creds.base_url
if refreshed_creds.headers:
extra_headers = dict(kwargs.get("extra_headers") or {})
extra_headers.update(refreshed_creds.headers)
kwargs["extra_headers"] = extra_headers
# Retry immediately with refreshed credentials
can_retry = True
retry_delay = 0.0
refreshed_auth = True
logging.info("Subscription credentials refreshed, retrying...")
# A successful credential refresh fully resolves the auth error: retry
# immediately with fresh credentials and skip profile rotation so we
# neither mark the current profile failed nor overwrite the new creds.
if refreshed_auth:
return False, category, retry_delay, error_str, kwargs
# Handle different failover decision actions
if decision.action == "rotate_profile" and self._failover_manager:
if self._current_profile:
is_rate_limit = (category == "rate_limit")
self._failover_manager.mark_failure(
self._current_profile, error_str, is_rate_limit=is_rate_limit
)
next_profile = self._failover_manager.get_next_profile()
if next_profile and next_profile != self._current_profile:
self._switch_to_profile(next_profile)
self._current_profile = next_profile
# Update the kwargs with new profile values for the next retry
if "api_key" in kwargs:
kwargs["api_key"] = self.api_key
if "base_url" in kwargs:
kwargs["base_url"] = self.base_url
if "model" in kwargs:
kwargs["model"] = self.model
logging.info(f"Failover: switched to profile '{next_profile.name}'")
else:
# No alternate profile available - retrying would just hit the
# same failing profile, so surface the error instead.
can_retry = False
# Legacy failover for compatibility (when decision is retry but failover is configured)
elif self._failover_manager and self._current_profile and decision.action == "retry":
is_rate_limit = (category == "rate_limit")
self._failover_manager.mark_failure(
self._current_profile, error_str, is_rate_limit=is_rate_limit
)
next_profile = self._failover_manager.get_next_profile()
if next_profile and next_profile != self._current_profile:
self._switch_to_profile(next_profile)
self._current_profile = next_profile
# Update the kwargs with new profile values for the next retry
if "api_key" in kwargs:
kwargs["api_key"] = self.api_key
if "base_url" in kwargs:
kwargs["base_url"] = self.base_url
if "model" in kwargs:
kwargs["model"] = self.model
# Enable retry for profile switch even if originally non-retryable
can_retry = True
retry_delay = 0.0
logging.info(f"Failover: switched to profile '{next_profile.name}'")
should_raise = decision.action == "surface_error" or not can_retry
return should_raise, category, retry_delay, error_str, kwargs
def _call_with_retry(self, func, *args, **kwargs):
"""Call a function with automatic retry on rate limit errors and failover support.
@@ -1116,18 +957,74 @@ Respond with ONLY a valid JSON tool call in this format:
return result
except Exception as e:
# Use new typed failover decision instead of old classification
decision = self.resolve_failover_decision(e, {"attempt": attempt + 1, "max_retries": self._max_retries})
last_error = e
error_str = str(e)
# Map decision to old variables for compatibility with existing logic
category = decision.reason
can_retry = decision.is_retryable
retry_delay = decision.backoff_ms / 1000.0 # Convert ms to seconds
# Blocking credential refresh runs inline in the sync path.
refreshed_creds = None
if self._should_attempt_auth_refresh(e, attempt):
refreshed_creds = self._try_refresh_subscription_creds()
# Check for auth errors and try refreshing subscription credentials
if category == "auth" and self._auth_provider_id and attempt == 0:
try:
logging.info("Authentication error detected - attempting credential refresh")
refreshed_creds = self._refresh_subscription_creds()
if refreshed_creds:
# Update parameters with refreshed credentials (don't clear cache)
kwargs = self._build_completion_params(**kwargs)
# Retry immediately with refreshed credentials
can_retry = True
retry_delay = 0.0
logging.info("Subscription credentials refreshed, retrying...")
except Exception as refresh_error:
logging.warning(f"Failed to refresh subscription credentials: {refresh_error}")
should_raise, category, retry_delay, error_str, kwargs = (
self._handle_retry_exception(e, attempt, kwargs, refreshed_creds=refreshed_creds)
)
if should_raise:
# Handle different failover decision actions
if decision.action == "rotate_profile" and self._failover_manager:
if self._current_profile:
is_rate_limit = (category == "rate_limit")
self._failover_manager.mark_failure(
self._current_profile, error_str, is_rate_limit=is_rate_limit
)
next_profile = self._failover_manager.get_next_profile()
if next_profile and next_profile != self._current_profile:
self._switch_to_profile(next_profile)
self._current_profile = next_profile
# Update the kwargs with new profile values for the next retry
if "api_key" in kwargs:
kwargs["api_key"] = self.api_key
if "base_url" in kwargs:
kwargs["base_url"] = self.base_url
if "model" in kwargs:
kwargs["model"] = self.model
logging.info(f"Failover: switched to profile '{next_profile.name}'")
# Legacy failover for compatibility (when decision is retry but failover is configured)
elif self._failover_manager and self._current_profile and decision.action == "retry":
is_rate_limit = (category == "rate_limit")
self._failover_manager.mark_failure(
self._current_profile, error_str, is_rate_limit=is_rate_limit
)
next_profile = self._failover_manager.get_next_profile()
if next_profile and next_profile != self._current_profile:
self._switch_to_profile(next_profile)
self._current_profile = next_profile
# Update the kwargs with new profile values for the next retry
if "api_key" in kwargs:
kwargs["api_key"] = self.api_key
if "base_url" in kwargs:
kwargs["base_url"] = self.base_url
if "model" in kwargs:
kwargs["model"] = self.model
# Enable retry for profile switch even if originally non-retryable
can_retry = True
retry_delay = 0.0
logging.info(f"Failover: switched to profile '{next_profile.name}'")
if decision.action == "surface_error" or not can_retry:
raise
if attempt < self._max_retries:
@@ -1192,21 +1089,74 @@ Respond with ONLY a valid JSON tool call in this format:
return result
except Exception as e:
# Use new typed failover decision instead of old classification
decision = self.resolve_failover_decision(e, {"attempt": attempt + 1, "max_retries": self._max_retries})
last_error = e
error_str = str(e)
# Map decision to old variables for compatibility with existing logic
category = decision.reason
can_retry = decision.is_retryable
retry_delay = decision.backoff_ms / 1000.0 # Convert ms to seconds
# Offload the blocking credential refresh to a thread so it
# never blocks the event loop in the async path.
refreshed_creds = None
if self._should_attempt_auth_refresh(e, attempt):
refreshed_creds = await asyncio.to_thread(
self._try_refresh_subscription_creds
# Check for auth errors and try refreshing subscription credentials
if category == "auth" and self._auth_provider_id and attempt == 0:
try:
logging.info("Authentication error detected - attempting credential refresh")
refreshed_creds = self._refresh_subscription_creds()
if refreshed_creds:
# Update parameters with refreshed credentials (don't clear cache)
kwargs = self._build_completion_params(**kwargs)
# Retry immediately with refreshed credentials
can_retry = True
retry_delay = 0.0
logging.info("Subscription credentials refreshed, retrying...")
except Exception as refresh_error:
logging.warning(f"Failed to refresh subscription credentials: {refresh_error}")
# Handle different failover decision actions
if decision.action == "rotate_profile" and self._failover_manager:
if self._current_profile:
is_rate_limit = (category == "rate_limit")
self._failover_manager.mark_failure(
self._current_profile, error_str, is_rate_limit=is_rate_limit
)
next_profile = self._failover_manager.get_next_profile()
if next_profile and next_profile != self._current_profile:
self._switch_to_profile(next_profile)
self._current_profile = next_profile
# Update the kwargs with new profile values for the next retry
if "api_key" in kwargs:
kwargs["api_key"] = self.api_key
if "base_url" in kwargs:
kwargs["base_url"] = self.base_url
if "model" in kwargs:
kwargs["model"] = self.model
logging.info(f"Failover: switched to profile '{next_profile.name}'")
# Legacy failover for compatibility (when decision is retry but failover is configured)
elif self._failover_manager and self._current_profile and decision.action == "retry":
is_rate_limit = (category == "rate_limit")
self._failover_manager.mark_failure(
self._current_profile, error_str, is_rate_limit=is_rate_limit
)
should_raise, category, retry_delay, error_str, kwargs = (
self._handle_retry_exception(e, attempt, kwargs, refreshed_creds=refreshed_creds)
)
if should_raise:
next_profile = self._failover_manager.get_next_profile()
if next_profile and next_profile != self._current_profile:
self._switch_to_profile(next_profile)
self._current_profile = next_profile
# Update the kwargs with new profile values for the next retry
if "api_key" in kwargs:
kwargs["api_key"] = self.api_key
if "base_url" in kwargs:
kwargs["base_url"] = self.base_url
if "model" in kwargs:
kwargs["model"] = self.model
# Enable retry for profile switch even if originally non-retryable
can_retry = True
retry_delay = 0.0
logging.info(f"Failover: switched to profile '{next_profile.name}'")
if decision.action == "surface_error" or not can_retry:
raise
if attempt < self._max_retries:
@@ -1215,21 +1165,6 @@ Respond with ONLY a valid JSON tool call in this format:
f"waiting {retry_delay:.1f}s before retry..."
)
# P5: Emit retry callback for CLI visibility.
# Use the async dispatcher so a sync callback runs in a
# thread executor (never blocking the event loop) and any
# async-registered 'retry' callback is also awaited.
try:
from ..main import execute_callback
await execute_callback('retry',
attempt=attempt + 1,
max_attempts=self._max_retries + 1,
error=error_str[:200],
retry_in_seconds=retry_delay
)
except ImportError:
pass
if self._rate_limiter is not None:
await self._rate_limiter.wait_for_retry_async(retry_delay)
else:
@@ -1529,42 +1464,6 @@ Result: {tool_result_str}
Now provide your final answer using this result. Summarize the information naturally for the user."""
}
def _try_append_multimodal_tool_result(
self,
messages: List[Dict[str, Any]],
tool_result: Any,
tool_call_id: str,
function_name: Optional[str] = None,
deferred_followups: Optional[List[Dict[str, Any]]] = None,
) -> bool:
"""Append a multimodal tool result if the result carries media.
The ``tool`` reply is appended in-place so all tool replies for the
assistant turn stay consecutive (provider contract). The media-bearing
``user`` follow-up message is collected into ``deferred_followups`` for
the caller to flush *after* the whole tool_calls batch is processed; if
no collector is supplied it is appended directly (single-call paths).
Returns True if the result was multimodal (caller should skip its
default text-only tool message), False otherwise (no regression).
"""
try:
from ..agent.tool_execution import build_tool_result_message_pair
pair = build_tool_result_message_pair(
tool_result, tool_call_id, function_name=function_name
)
if pair:
tool_message, followup_message = pair
messages.append(tool_message)
if deferred_followups is not None:
deferred_followups.append(followup_message)
else:
messages.append(followup_message)
return True
except Exception as e:
logging.debug(f"Multimodal tool result formatting skipped: {e}")
return False
def _get_tool_names_for_prompt(self, formatted_tools: Optional[List]) -> str:
"""Extract tool names from formatted tools for prompts."""
if not formatted_tools:
@@ -1840,90 +1739,6 @@ Now provide your final answer using this result. Summarize the information natur
return False, None, iteration_count
def _finalise_on_limit(self, messages: List[Dict], response_text: str,
temperature: float = 0.2, **kwargs) -> str:
"""
Force a final tools-disabled answer when the tool-calling loop hits max_iter.
Instead of returning a canned placeholder, this performs exactly one extra
LLM call with tools disabled (tool_choice="none") and a short wrap-up nudge,
asking the model to summarise what it accomplished, what remains unfinished,
and any next steps using the accumulated tool results already in context.
Falls back to the prior placeholder text if the extra call fails, so behaviour
never regresses.
Args:
messages: Current message history (already contains tool results).
response_text: Any text emitted on the final turn (used as fallback).
temperature: Sampling temperature for the wrap-up call.
Returns:
The synthesised final answer, or a safe fallback string.
"""
wrap_up = {"role": "user", "content": self.STEP_LIMIT_WRAP_UP_PROMPT}
try:
safe_kwargs = {k: v for k, v in kwargs.items() if k != 'reasoning_steps'}
final_params = self._build_completion_params(
messages=messages + [wrap_up],
temperature=temperature,
stream=False,
tool_choice="none",
**safe_kwargs
)
# Ensure no tools are advertised even if the builder injects
# runtime tools (e.g. web_fetch, Claude memory); tool_choice="none"
# with tools present is invalid/undesirable for the wrap-up call.
# Drop the now-meaningless tool_choice too, as some providers reject
# tool_choice when no tools are supplied.
final_params.pop("tools", None)
final_params.pop("tool_choice", None)
# Route through the retry wrapper so this bounded final call gets the
# same rate-limit/failover resilience as every other completion.
resp = self._completion_with_retry(**final_params)
summary = resp["choices"][0]["message"].get("content")
if summary and summary.strip():
return summary.strip()
except Exception as e:
logging.warning(f"Step-limit finalisation call failed, using fallback: {e}")
if response_text and response_text.strip():
return response_text.strip()
return self.STEP_LIMIT_FALLBACK_MESSAGE
async def _finalise_on_limit_async(self, messages: List[Dict], response_text: str,
temperature: float = 0.2, **kwargs) -> str:
"""Async counterpart of :meth:`_finalise_on_limit`."""
wrap_up = {"role": "user", "content": self.STEP_LIMIT_WRAP_UP_PROMPT}
try:
safe_kwargs = {k: v for k, v in kwargs.items() if k != 'reasoning_steps'}
final_params = self._build_completion_params(
messages=messages + [wrap_up],
temperature=temperature,
stream=False,
tool_choice="none",
**safe_kwargs
)
# Ensure no tools are advertised even if the builder injects
# runtime tools (e.g. web_fetch, Claude memory); tool_choice="none"
# with tools present is invalid/undesirable for the wrap-up call.
# Drop the now-meaningless tool_choice too, as some providers reject
# tool_choice when no tools are supplied.
final_params.pop("tools", None)
final_params.pop("tool_choice", None)
# Route through the retry wrapper so this bounded final call gets the
# same rate-limit/failover resilience as every other completion.
resp = await self._acompletion_with_retry(**final_params)
summary = resp["choices"][0]["message"].get("content")
if summary and summary.strip():
return summary.strip()
except Exception as e:
logging.warning(f"Step-limit finalisation call failed, using fallback: {e}")
if response_text and response_text.strip():
return response_text.strip()
return self.STEP_LIMIT_FALLBACK_MESSAGE
def _needs_system_message_skip(self) -> bool:
"""Check if this model requires skipping system messages"""
if not self.model:
@@ -2495,10 +2310,7 @@ Now provide your final answer using this result. Summarize the information natur
# Safety: break after max_iter iterations
if iteration_count >= self.max_iter:
final_response_text = self._finalise_on_limit(
messages, response_text, temperature=temperature,
**{k: v for k, v in kwargs.items() if k != 'reasoning_steps'}
)
final_response_text = response_text.strip() if response_text else "Task completed."
break
iteration_count += 1
@@ -3188,10 +3000,6 @@ Now provide your final answer using this result. Summarize the information natur
tool_calls = tool_calls[:remaining_calls]
logging.warning(f"Limiting batch to {remaining_calls} tool calls to stay within limit of {max_tool_calls_per_turn}.")
# Collect media-bearing follow-up messages so they are
# flushed only AFTER every tool reply for this assistant
# turn, keeping all tool replies consecutive (provider contract).
_deferred_media_followups: List[Dict[str, Any]] = []
for tool_call in tool_calls:
# Handle both object and dict access patterns
is_ollama = self._is_ollama_provider()
@@ -3256,13 +3064,6 @@ Now provide your final answer using this result. Summarize the information natur
if self._is_ollama_provider():
# For Ollama, use user role and format as natural language
messages.append(self._format_ollama_tool_result_message(function_name, tool_result))
elif self._try_append_multimodal_tool_result(
messages, tool_result, tool_call_id,
function_name=function_name,
deferred_followups=_deferred_media_followups,
):
# Multimodal result (image/file) emitted as model-visible parts
pass
else:
# For other providers, use tool role with tool_call_id
# Format error results more clearly
@@ -3284,12 +3085,7 @@ Now provide your final answer using this result. Summarize the information natur
# This mimics the logic from agent.py lines 1004-1007
if function_name == "sequentialthinking" and arguments.get("nextThoughtNeeded", False):
should_continue = True
# Flush deferred media follow-ups after all tool replies
# for this turn have been appended (provider contract).
if _deferred_media_followups:
messages.extend(_deferred_media_followups)
# If we should continue, increment iteration and continue loop
if should_continue:
iteration_count += 1
@@ -3320,10 +3116,10 @@ Now provide your final answer using this result. Summarize the information natur
# Safety check: prevent infinite loops for any provider
if iteration_count >= self.max_iter:
final_response_text = self._finalise_on_limit(
messages, response_text, temperature=temperature,
**{k: v for k, v in kwargs.items() if k != 'reasoning_steps'}
)
if tool_results:
final_response_text = "Task completed successfully based on tool execution results."
else:
final_response_text = response_text.strip() if response_text else "Task completed."
break
# Otherwise, continue the loop to get final response with tool results
@@ -4260,10 +4056,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
})
if iteration_count >= self.max_iter:
final_response_text = await self._finalise_on_limit_async(
messages, response_text, temperature=temperature,
**{k: v for k, v in kwargs.items() if k != 'reasoning_steps'}
)
final_response_text = response_text.strip() if response_text else "Task completed."
break
iteration_count += 1
@@ -4461,10 +4254,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.warning(f"Limiting batch to {remaining_calls} tool calls to stay within limit of {max_tool_calls_per_turn}.")
tool_results = [] # Store current iteration tool results
# Collect media-bearing follow-up messages so they are
# flushed only AFTER every tool reply for this assistant
# turn, keeping all tool replies consecutive (provider contract).
_deferred_media_followups: List[Dict[str, Any]] = []
for tool_call in tool_calls:
# Handle both object and dict access patterns
is_ollama = self._is_ollama_provider()
@@ -4490,13 +4279,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if self._is_ollama_provider():
# For Ollama, use user role and format as natural language
messages.append(self._format_ollama_tool_result_message(function_name, tool_result))
elif self._try_append_multimodal_tool_result(
messages, tool_result, tool_call_id,
function_name=function_name,
deferred_followups=_deferred_media_followups,
):
# Multimodal result (image/file) emitted as model-visible parts
pass
else:
# For other providers, use tool role with tool_call_id
# Format error results more clearly
@@ -4514,11 +4296,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
"content": content
})
# Flush deferred media follow-ups after all tool replies
# for this turn have been appended (provider contract).
if _deferred_media_followups:
messages.extend(_deferred_media_followups)
# For Ollama, add explicit prompt if we need a final answer
if self._is_ollama_provider() and iteration_count > 0:
# Add an explicit prompt for Ollama to generate the final answer
@@ -4672,10 +4449,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Safety check: prevent infinite loops for any provider
if iteration_count >= self.max_iter:
final_response_text = await self._finalise_on_limit_async(
messages, response_text, temperature=temperature,
**{k: v for k, v in kwargs.items() if k != 'reasoning_steps'}
)
if tool_results:
final_response_text = "Task completed successfully based on tool execution results."
else:
final_response_text = response_text.strip() if response_text else "Task completed."
break
# Continue the loop to check if more tools are needed
@@ -41,38 +41,6 @@ def _get_openai_classes():
openai = _get_openai()
return openai.OpenAI, openai.AsyncOpenAI
def _try_append_multimodal_tool_result(
messages, tool_result, tool_call_id, function_name=None, deferred_followups=None
) -> bool:
"""Append a multimodal (image/file) tool result if present.
The ``tool`` reply is appended in-place so all tool replies for the
assistant turn stay consecutive (provider contract). The media-bearing
``user`` follow-up is collected into ``deferred_followups`` for the caller
to flush after the whole batch; if no collector is supplied it is appended
directly. External tool text parts are fenced via ``function_name``.
Returns True if the result was multimodal (caller should skip its default
text-only tool message); False otherwise for the unchanged path.
"""
try:
from ..agent.tool_execution import build_tool_result_message_pair
pair = build_tool_result_message_pair(
tool_result, tool_call_id, function_name=function_name
)
if pair:
tool_message, followup_message = pair
messages.append(tool_message)
if deferred_followups is not None:
deferred_followups.append(followup_message)
else:
messages.append(followup_message)
return True
except Exception as e:
logging.debug(f"Multimodal tool result formatting skipped: {e}")
return False
def _get_rich_console():
"""Lazy import rich Console."""
global _rich_console
@@ -1689,9 +1657,6 @@ class OpenAIClient:
"tool_calls": serializable_tool_calls
})
# Collect media-bearing follow-ups to flush after all tool
# replies for this turn (keeps tool replies consecutive).
_deferred_media_followups = []
for tool_call in tool_calls:
# Handle both ToolCall dataclass and OpenAI object
if isinstance(tool_call, ToolCall):
@@ -1718,20 +1683,11 @@ class OpenAIClient:
tool_output=results_str[:200] if results_str else None
)
_tc_id = tool_call.id if hasattr(tool_call, 'id') else tool_call['id']
if not _try_append_multimodal_tool_result(
messages, tool_result, _tc_id,
function_name=function_name,
deferred_followups=_deferred_media_followups,
):
messages.append({
"role": "tool",
"tool_call_id": _tc_id,
"content": results_str
})
if _deferred_media_followups:
messages.extend(_deferred_media_followups)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id if hasattr(tool_call, 'id') else tool_call['id'],
"content": results_str
})
# Continue the loop to allow more tool calls
# The model will see tool results and can make additional tool calls
@@ -1903,9 +1859,6 @@ class OpenAIClient:
"tool_calls": serializable_tool_calls
})
# Collect media-bearing follow-ups to flush after all tool
# replies for this turn (keeps tool replies consecutive).
_deferred_media_followups = []
for tool_call in tool_calls:
# Handle both ToolCall dataclass and OpenAI object
if isinstance(tool_call, ToolCall):
@@ -1939,20 +1892,11 @@ class OpenAIClient:
# Trigger callback with result
display_tool_call_fn(f"Function {function_name} returned: {results_str[:200]}{'...' if len(results_str) > 200 else ''}", console=console if verbose else None)
_tc_id = tool_call.id if hasattr(tool_call, 'id') else tool_call['id']
if not _try_append_multimodal_tool_result(
messages, tool_result, _tc_id,
function_name=function_name,
deferred_followups=_deferred_media_followups,
):
messages.append({
"role": "tool",
"tool_call_id": _tc_id,
"content": results_str
})
if _deferred_media_followups:
messages.extend(_deferred_media_followups)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id if hasattr(tool_call, 'id') else tool_call['id'],
"content": results_str
})
# Continue the loop to allow more tool calls
# The model will see tool results and can make additional tool calls
@@ -2102,9 +2046,6 @@ class OpenAIClient:
"tool_calls": serializable_tool_calls
})
# Collect media-bearing follow-ups to flush after all tool
# replies for this turn (keeps tool replies consecutive).
_deferred_media_followups = []
for tool_call in tool_calls:
# Handle both ToolCall dataclass and OpenAI object
try:
@@ -2142,21 +2083,11 @@ class OpenAIClient:
if verbose:
yield f"\n[Function result: {results_str}]"
_tc_id = tool_call.id if hasattr(tool_call, 'id') else tool_call['id']
_tool_result_for_mm = locals().get('tool_result')
if not _try_append_multimodal_tool_result(
messages, _tool_result_for_mm, _tc_id,
function_name=function_name,
deferred_followups=_deferred_media_followups,
):
messages.append({
"role": "tool",
"tool_call_id": _tc_id,
"content": results_str
})
if _deferred_media_followups:
messages.extend(_deferred_media_followups)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id if hasattr(tool_call, 'id') else tool_call['id'],
"content": results_str
})
# Continue the loop to allow more tool calls
iteration_count += 1
@@ -1,351 +0,0 @@
"""
Panel (Mixture-of-Agents) LLM provider.
A ``PanelLLM`` is a *model selection*, not an orchestration primitive. One or
more advisory **reference** models run first (no tools, trimmed conversation
view); their outputs are folded into private context that is appended to the
**tail of the latest user turn** — below any stable cached prefix, so Anthropic
prompt caching on the system message is preserved. A single **aggregator** model
is the real acting model: it inherits the full normal agent loop (tool calls,
hooks, session) from :class:`~praisonaiagents.llm.llm.LLM`.
Selectable exactly like any other model::
# Inline dict descriptor (no registration needed):
Agent(llm={"provider": "panel", "references": [...], "aggregator": "..."})
# Named preset (register it first):
register_panel_preset("deep", {"references": [...], "aggregator": "..."})
Agent(llm="panel:deep")
No presets are bundled by the core SDK; ``panel:<name>`` strings resolve against
presets registered via :func:`register_panel_preset` (e.g. by the wrapper, CLI,
or YAML layer). YAML / CLI / bot ``/model`` paths flow the ``panel:<name>``
string through unchanged because resolution happens in core model resolution.
"""
import hashlib
import logging
from collections import OrderedDict
from typing import Any, Dict, List, Optional, Union
from .llm import LLM
logger = logging.getLogger(__name__)
# =============================================================================
# Panel preset registry
# =============================================================================
# Named presets keyed by name. Resolved when a ``panel:<name>`` descriptor is
# selected. Wrapper/CLI/YAML can register additional presets at import time via
# ``register_panel_preset``.
PANEL_PRESETS: Dict[str, Dict[str, Any]] = {}
def register_panel_preset(name: str, config: Dict[str, Any]) -> None:
"""Register (or override) a named panel preset.
Args:
name: Preset name (used as ``panel:<name>``).
config: Dict with ``references`` (list[str]), ``aggregator`` (str),
and optional ``enabled`` (bool, default True).
"""
PANEL_PRESETS[name] = dict(config)
def is_panel_descriptor(llm: Any) -> bool:
"""Return True if ``llm`` selects a panel provider.
Accepts ``"panel:<name>"`` strings and ``{"provider": "panel", ...}`` dicts.
"""
if isinstance(llm, str):
return llm.startswith("panel:")
if isinstance(llm, dict):
return llm.get("provider") == "panel"
return False
def resolve_panel_config(llm: Union[str, Dict[str, Any]]) -> Dict[str, Any]:
"""Resolve a panel descriptor into a normalized config dict.
Returns a dict with keys ``references`` (list[str]), ``aggregator`` (str),
``enabled`` (bool), and ``options`` (dict) carrying any extra provider/model
settings (e.g. ``base_url``, ``api_key``, ``temperature``) so they survive
both the dict-descriptor and named-preset resolution paths.
Raises:
ValueError: if the preset is unknown, the config is incomplete, or a
recursion guard is violated (a reference/aggregator is itself a
panel descriptor).
"""
if isinstance(llm, str):
name = llm.split(":", 1)[1] if ":" in llm else llm
if name not in PANEL_PRESETS:
raise ValueError(
f"Unknown panel preset 'panel:{name}'. "
f"Known presets: {sorted(PANEL_PRESETS) or '(none registered)'}. "
"Use a dict descriptor or register_panel_preset(...) first."
)
config = dict(PANEL_PRESETS[name])
elif isinstance(llm, dict):
config = {k: v for k, v in llm.items() if k != "provider"}
else:
raise ValueError(f"Invalid panel descriptor: {llm!r}")
references = config.get("references") or []
aggregator = config.get("aggregator")
enabled = config.get("enabled", True)
if not isinstance(aggregator, str) or not aggregator:
raise ValueError("Panel config requires a non-empty 'aggregator' model string.")
if not isinstance(references, (list, tuple)):
raise ValueError("Panel 'references' must be a list of model strings.")
if not all(isinstance(ref, str) and ref for ref in references):
raise ValueError("Panel 'references' must contain only non-empty model strings.")
if not isinstance(enabled, bool):
raise ValueError("Panel 'enabled' must be a boolean.")
# Recursion guard: a panel preset cannot reference another panel preset.
if is_panel_descriptor(aggregator):
raise ValueError("Panel 'aggregator' cannot itself be a panel descriptor.")
for ref in references:
if is_panel_descriptor(ref):
raise ValueError("Panel 'references' cannot contain a panel descriptor.")
# Extra provider/model settings (e.g. base_url, api_key, temperature) carried
# so both dict descriptors and named presets configure the aggregator the same.
options = {
k: v
for k, v in config.items()
if k not in ("references", "aggregator", "enabled", "provider", "model")
}
return {
"references": list(references),
"aggregator": aggregator,
"enabled": bool(enabled),
"options": options,
}
# Guidance wrapper for injected reference context. Appended to the tail of the
# latest user turn so the cached system/history prefix is never modified.
_REFERENCE_HEADER = (
"\n\n[Panel reference perspectives — advisory only, from other models. "
"Use them to inform your answer; you remain the acting model.]\n"
)
class PanelLLM(LLM):
"""Aggregator-acting panel model.
Subclasses :class:`LLM` so the aggregator inherits the full tool loop,
hooks, session handling, and prompt-cache discipline unchanged. Reference
calls are advisory (tool-free, trimmed view) and cached per user turn.
"""
def __init__(
self,
aggregator: str,
references: Optional[List[str]] = None,
enabled: bool = True,
reference_temperature: float = 0.0,
**kwargs: Any,
):
super().__init__(model=aggregator, **kwargs)
self._panel_references = list(references or [])
self._panel_enabled = bool(enabled)
self._panel_reference_temperature = reference_temperature
# Connection/credential settings forwarded to reference LLMs so advisory
# calls hit the same backend and authenticate the same way as the
# aggregator (e.g. a custom Ollama endpoint or subscription ``auth``).
# Only connection-relevant kwargs are propagated; execution options
# (tools, web_search, etc.) are intentionally excluded since references
# are tool-free advisory calls.
self._panel_ref_kwargs = {
k: kwargs[k]
for k in ("base_url", "api_key", "api_version", "auth")
if k in kwargs and kwargs[k] is not None
}
# Per-turn cache: signature(trimmed view) -> joined reference guidance.
# Bounded (FIFO) so long-lived agents with many unique turns do not leak.
self._panel_ref_cache: "OrderedDict[str, str]" = OrderedDict()
self._panel_ref_cache_max = 128
# Lazily-created reference LLM instances, keyed by model string.
self._panel_ref_llms: Dict[str, LLM] = {}
# -- helpers ------------------------------------------------------------
def _panel_view_signature(
self, prompt: Any, system_prompt: Optional[str], chat_history: Optional[List[Dict]]
) -> str:
"""Deterministic signature of the trimmed view used for caching."""
text = repr(self._panel_trimmed_messages(prompt, system_prompt, chat_history))
return hashlib.sha256(text.encode("utf-8", "ignore")).hexdigest()
@staticmethod
def _panel_trimmed_messages(
prompt: Any, system_prompt: Optional[str], chat_history: Optional[List[Dict]]
) -> List[Dict[str, str]]:
"""Build a strict-provider-safe, tool-free view for reference calls.
Drops the system prompt, ``tool``-role messages and any ``tool_calls``
payloads so strict providers do not 400 on orphan tool messages.
Only user/assistant text content is kept.
"""
view: List[Dict[str, str]] = []
for m in chat_history or []:
if not isinstance(m, dict):
continue
role = m.get("role")
if role not in ("user", "assistant"):
continue
if m.get("tool_calls"):
continue
content = m.get("content")
if isinstance(content, str) and content:
view.append({"role": role, "content": content})
if isinstance(prompt, str) and prompt:
view.append({"role": "user", "content": prompt})
elif isinstance(prompt, list):
for part in prompt:
if isinstance(part, dict) and isinstance(part.get("text"), str):
view.append({"role": "user", "content": part["text"]})
return view
def _cache_guidance(self, sig: str, guidance: str) -> None:
"""Store guidance under ``sig`` with a bounded FIFO eviction policy."""
cache = self._panel_ref_cache
cache[sig] = guidance
cache.move_to_end(sig)
while len(cache) > self._panel_ref_cache_max:
cache.popitem(last=False)
def _get_reference_llm(self, model: str) -> LLM:
if model not in self._panel_ref_llms:
self._panel_ref_llms[model] = LLM(model=model, **self._panel_ref_kwargs)
return self._panel_ref_llms[model]
def _format_reference_guidance(self, results: List[tuple]) -> str:
parts = [_REFERENCE_HEADER]
for label, text in results:
parts.append(f"--- {label} ---\n{text}\n")
return "".join(parts)
@staticmethod
def _inject_into_prompt(prompt: Any, guidance: str) -> Any:
"""Append guidance to the tail of the latest user prompt (cache-safe)."""
if isinstance(prompt, str):
return prompt + guidance
if isinstance(prompt, list):
new_prompt = [dict(p) if isinstance(p, dict) else p for p in prompt]
for part in reversed(new_prompt):
if isinstance(part, dict) and isinstance(part.get("text"), str):
part["text"] = part["text"] + guidance
return new_prompt
new_prompt.append({"type": "text", "text": guidance})
return new_prompt
return prompt
# -- reference execution ------------------------------------------------
def _run_references_sync(self, view: List[Dict[str, str]]) -> str:
results: List[tuple] = []
for model in self._panel_references:
label = f"Reference: {model}"
try:
ref_llm = self._get_reference_llm(model)
text = ref_llm.get_response(
prompt=view[-1]["content"] if view else "",
chat_history=view[:-1] if len(view) > 1 else None,
temperature=self._panel_reference_temperature,
tools=None,
verbose=False,
stream=False,
markdown=False,
)
results.append((label, text if isinstance(text, str) else str(text)))
except Exception as e: # partial-failure tolerance
logger.warning(
"Panel reference '%s' failed: %s", model, type(e).__name__
)
results.append((label, "(unavailable: reference call failed)"))
return self._format_reference_guidance(results)
async def _run_references_async(self, view: List[Dict[str, str]]) -> str:
import asyncio
async def _one(model: str):
label = f"Reference: {model}"
try:
ref_llm = self._get_reference_llm(model)
text = await ref_llm.get_response_async(
prompt=view[-1]["content"] if view else "",
chat_history=view[:-1] if len(view) > 1 else None,
temperature=self._panel_reference_temperature,
tools=None,
verbose=False,
stream=False,
markdown=False,
)
return (label, text if isinstance(text, str) else str(text))
except Exception as e: # partial-failure tolerance
logger.warning(
"Panel reference '%s' failed: %s", model, type(e).__name__
)
return (label, "(unavailable: reference call failed)")
results = await asyncio.gather(*[_one(m) for m in self._panel_references])
return self._format_reference_guidance(list(results))
# -- overridden entry points -------------------------------------------
def get_response(self, prompt, system_prompt=None, chat_history=None, **kwargs):
if self._panel_enabled and self._panel_references:
view = self._panel_trimmed_messages(prompt, system_prompt, chat_history)
sig = self._panel_view_signature(prompt, system_prompt, chat_history)
guidance = self._panel_ref_cache.get(sig)
if guidance is None:
guidance = self._run_references_sync(view)
self._cache_guidance(sig, guidance)
prompt = self._inject_into_prompt(prompt, guidance)
return super().get_response(
prompt=prompt, system_prompt=system_prompt, chat_history=chat_history, **kwargs
)
async def get_response_async(self, prompt, system_prompt=None, chat_history=None, **kwargs):
if self._panel_enabled and self._panel_references:
view = self._panel_trimmed_messages(prompt, system_prompt, chat_history)
sig = self._panel_view_signature(prompt, system_prompt, chat_history)
guidance = self._panel_ref_cache.get(sig)
if guidance is None:
guidance = await self._run_references_async(view)
self._cache_guidance(sig, guidance)
prompt = self._inject_into_prompt(prompt, guidance)
return await super().get_response_async(
prompt=prompt, system_prompt=system_prompt, chat_history=chat_history, **kwargs
)
def create_panel_llm(descriptor: Union[str, Dict[str, Any]], **llm_kwargs: Any) -> PanelLLM:
"""Build a :class:`PanelLLM` from a panel descriptor.
Strips panel-only keys before forwarding remaining kwargs to ``LLM``.
"""
config = resolve_panel_config(descriptor)
# Preserve extra provider options (e.g. base_url, api_key, temperature)
# supplied inside a dict descriptor *or* a named preset so they reach the
# aggregator LLM. ``options`` is normalized by ``resolve_panel_config`` for
# both paths; explicit ``llm_kwargs`` (Agent-level) take precedence.
descriptor_kwargs: Dict[str, Any] = dict(config.get("options") or {})
descriptor_kwargs.update(llm_kwargs)
for key in ("references", "aggregator", "enabled", "provider", "model"):
descriptor_kwargs.pop(key, None)
return PanelLLM(
aggregator=config["aggregator"],
references=config["references"],
enabled=config["enabled"],
**descriptor_kwargs,
)
+21 -97
View File
@@ -244,82 +244,6 @@ def _clean_display_content(content: str, max_length: int = 20000) -> str:
return content.strip()
def _sanitize_renderable(renderable, stream):
"""Replace unencodable decorative glyphs in a Rich renderable/str.
On legacy consoles (e.g. Windows cp1252) glyphs such as ``▸`` (U+25B8)
cannot be encoded and Rich raises ``UnicodeEncodeError``. This walks the
common renderable types (plain ``str``, ``Text``, ``Panel``) and rewrites
their text through :func:`safe_text` so printing never crashes.
"""
from .output.encoding import safe_text
try:
from rich.text import Text
from rich.panel import Panel
from rich.markdown import Markdown
except ImportError:
return safe_text(renderable, stream) if isinstance(renderable, str) else renderable
if isinstance(renderable, str):
return safe_text(renderable, stream)
if isinstance(renderable, Text):
renderable.plain = safe_text(renderable.plain, stream)
return renderable
if isinstance(renderable, Markdown):
# Rebuild from sanitized markup so the rendered body drops
# unencodable glyphs (otherwise the LLM response would be lost).
return Markdown(safe_text(renderable.markup, stream))
if isinstance(renderable, Panel):
renderable.renderable = _sanitize_renderable(renderable.renderable, stream)
if isinstance(renderable.title, str):
renderable.title = safe_text(renderable.title, stream)
return renderable
return renderable
def _renderable_to_plain(renderable, stream):
"""Best-effort plain-text extraction from a Rich renderable for the
last-resort ``print()`` fallback, so LLM responses are never dropped.
"""
from .output.encoding import safe_text
if isinstance(renderable, str):
return safe_text(renderable, stream)
try:
from rich.text import Text
from rich.panel import Panel
from rich.markdown import Markdown
except ImportError:
return safe_text(str(getattr(renderable, "plain", renderable)), stream)
if isinstance(renderable, Text):
return safe_text(renderable.plain, stream)
if isinstance(renderable, Markdown):
return safe_text(renderable.markup, stream)
if isinstance(renderable, Panel):
return _renderable_to_plain(renderable.renderable, stream)
return safe_text(str(getattr(renderable, "plain", renderable)), stream)
def _safe_console_print(console, *renderables, **kwargs):
"""Print via Rich, falling back to ASCII-safe glyphs on encoding errors.
Rich's legacy Windows renderer encodes with the terminal encoding
(cp1252 by default) using strict error handling and has no fallback, so
decorative glyphs crash the agent loop. We first try a normal print; on
``UnicodeEncodeError`` we retry with sanitized renderables so ``run`` and
other display paths degrade gracefully instead of failing.
"""
try:
console.print(*renderables, **kwargs)
except UnicodeEncodeError:
stream = getattr(console, "file", None)
sanitized = [_sanitize_renderable(r, stream) for r in renderables]
try:
console.print(*sanitized, **kwargs)
except UnicodeEncodeError:
plain = " ".join(_renderable_to_plain(r, stream) for r in sanitized)
# Write to the console's own stream (which may be a captured or
# redirected file), not the process-wide sys.stdout.
print(plain, file=stream if stream is not None else None)
def display_interaction(message, response, markdown=True, generation_time=None, console=None, agent_name=None, agent_role=None, agent_tools=None, task_name=None, task_description=None, task_id=None, metrics=None):
"""Synchronous version of display_interaction.
@@ -385,11 +309,11 @@ def display_interaction(message, response, markdown=True, generation_time=None,
# Format: 📝 Task message
task_prefix = "[bold #FF9B9B]📝[/]"
if markdown:
_safe_console_print(console, f"{task_prefix} {message}\n")
_safe_console_print(console, Panel.fit(Markdown(response_content), title=response_title, border_style=PRAISON_COLORS["response"]))
console.print(f"{task_prefix} {message}\n")
console.print(Panel.fit(Markdown(response_content), title=response_title, border_style=PRAISON_COLORS["response"]))
else:
_safe_console_print(console, f"{task_prefix} [bold {PRAISON_COLORS['task_text']}]{message}[/]\n")
_safe_console_print(console, Panel.fit(Text(response_content, style=f"bold {PRAISON_COLORS['response_text']}"), title=response_title, border_style=PRAISON_COLORS["response"]))
console.print(f"{task_prefix} [bold {PRAISON_COLORS['task_text']}]{message}[/]\n")
console.print(Panel.fit(Text(response_content, style=f"bold {PRAISON_COLORS['response_text']}"), title=response_title, border_style=PRAISON_COLORS["response"]))
def display_self_reflection(message: str, console=None):
if not message or not message.strip():
@@ -402,7 +326,7 @@ def display_self_reflection(message: str, console=None):
# Execute synchronous callbacks
execute_sync_callback('self_reflection', message=message)
_safe_console_print(console, Panel.fit(Text(message, style="bold yellow"), title="Self Reflection", border_style="magenta"))
console.print(Panel.fit(Text(message, style="bold yellow"), title="Self Reflection", border_style="magenta"))
def display_instruction(message: str, console=None, agent_name: str = None, agent_role: str = None, agent_tools: List[str] = None):
if not message or not message.strip():
@@ -423,11 +347,11 @@ def display_instruction(message: str, console=None, agent_name: str = None, agen
if agent_tools:
tools_str = ", ".join(f"[italic #B4D4FF]{tool}[/]" for tool in agent_tools)
agent_info += f"\n[bold #86A789]Tools:[/] {tools_str}"
_safe_console_print(console, Panel(agent_info, border_style="#D2E3C8", title="[bold]Agent Info[/]", title_align="left", padding=(1, 2)))
console.print(Panel(agent_info, border_style="#D2E3C8", title="[bold]Agent Info[/]", title_align="left", padding=(1, 2)))
# Only print if log level is DEBUG
if get_logger().getEffectiveLevel() == logging.DEBUG:
_safe_console_print(console, Panel.fit(Text(message, style="bold blue"), title="Instruction", border_style="cyan"))
console.print(Panel.fit(Text(message, style="bold blue"), title="Instruction", border_style="cyan"))
def display_tool_call(message: str, console=None, tool_name: str = None, tool_input: dict = None, tool_output: str = None, elapsed_time: float = None, success: bool = True):
"""Display tool call information in PraisonAI's unique timeline format.
@@ -485,10 +409,10 @@ def display_tool_call(message: str, console=None, tool_name: str = None, tool_in
tool_line += f" [dim]→[/] [italic]{output_str}[/]"
tool_line += f" {time_str} {status_icon}"
_safe_console_print(console, tool_line)
console.print(tool_line)
else:
# Legacy format - simple inline
_safe_console_print(console, f"[bold #86A789]▸[/] {message}")
console.print(f"[bold #86A789]▸[/] {message}")
def display_error(message: str, console=None):
if not message or not message.strip():
@@ -502,7 +426,7 @@ def display_error(message: str, console=None):
execute_sync_callback('error', message=message)
# Use semantic error color
_safe_console_print(console, Panel.fit(
console.print(Panel.fit(
Text(message, style=f"bold {PRAISON_COLORS['error_text']}"),
title="⚠ Error",
border_style=PRAISON_COLORS["error"]
@@ -558,7 +482,7 @@ def display_reasoning_steps(steps: List[str], console=None):
reasoning_display = "\n".join(reasoning_lines)
_safe_console_print(console, Panel.fit(
console.print(Panel.fit(
Text(reasoning_display, style=f"italic {PRAISON_COLORS['reasoning_text']}"),
title="Reasoning",
border_style=PRAISON_COLORS["reasoning"]
@@ -626,14 +550,14 @@ async def adisplay_interaction(message, response, markdown=True, generation_time
# Rest of the display logic...
if generation_time:
_safe_console_print(console, Text(f"Response generated in {generation_time:.1f}s", style="dim"))
console.print(Text(f"Response generated in {generation_time:.1f}s", style="dim"))
if markdown:
_safe_console_print(console, Panel.fit(Markdown(message), title="Task", border_style="cyan"))
_safe_console_print(console, Panel.fit(Markdown(response), title="Response", border_style="cyan"))
console.print(Panel.fit(Markdown(message), title="Task", border_style="cyan"))
console.print(Panel.fit(Markdown(response), title="Response", border_style="cyan"))
else:
_safe_console_print(console, Panel.fit(Text(message, style="bold green"), title="Task", border_style="cyan"))
_safe_console_print(console, Panel.fit(Text(response, style="bold blue"), title="Response", border_style="cyan"))
console.print(Panel.fit(Text(message, style="bold green"), title="Task", border_style="cyan"))
console.print(Panel.fit(Text(response, style="bold blue"), title="Response", border_style="cyan"))
async def adisplay_self_reflection(message: str, console=None):
"""Async version of display_self_reflection."""
@@ -647,7 +571,7 @@ async def adisplay_self_reflection(message: str, console=None):
# Execute callbacks
await execute_callback('self_reflection', message=message)
_safe_console_print(console, Panel.fit(Text(message, style="bold yellow"), title="Self Reflection", border_style="magenta"))
console.print(Panel.fit(Text(message, style="bold yellow"), title="Self Reflection", border_style="magenta"))
async def adisplay_instruction(message: str, console=None, agent_name: str = None, agent_role: str = None, agent_tools: List[str] = None):
"""Async version of display_instruction."""
@@ -669,11 +593,11 @@ async def adisplay_instruction(message: str, console=None, agent_name: str = Non
if agent_tools:
tools_str = ", ".join(f"[italic #B4D4FF]{tool}[/]" for tool in agent_tools)
agent_info += f"\n[bold #86A789]Tools:[/] {tools_str}"
_safe_console_print(console, Panel(agent_info, border_style="#D2E3C8", title="[bold]Agent Info[/]", title_align="left", padding=(1, 2)))
console.print(Panel(agent_info, border_style="#D2E3C8", title="[bold]Agent Info[/]", title_align="left", padding=(1, 2)))
# Only print if log level is DEBUG
if get_logger().getEffectiveLevel() == logging.DEBUG:
_safe_console_print(console, Panel.fit(Text(message, style="bold blue"), title="Instruction", border_style="cyan"))
console.print(Panel.fit(Text(message, style="bold blue"), title="Instruction", border_style="cyan"))
async def adisplay_tool_call(message: str, console=None):
"""Async version of display_tool_call."""
@@ -690,7 +614,7 @@ async def adisplay_tool_call(message: str, console=None):
# Execute callbacks
await execute_callback('tool_call', message=message)
_safe_console_print(console, Panel.fit(Text(message, style="bold cyan"), title="Tool Call", border_style="green"))
console.print(Panel.fit(Text(message, style="bold cyan"), title="Tool Call", border_style="green"))
async def adisplay_error(message: str, console=None):
"""Async version of display_error."""
@@ -704,7 +628,7 @@ async def adisplay_error(message: str, console=None):
# Execute callbacks
await execute_callback('error', message=message)
_safe_console_print(console, Panel.fit(Text(message, style="bold red"), title="Error", border_style="red"))
console.print(Panel.fit(Text(message, style="bold red"), title="Error", border_style="red"))
with _error_logs_lock:
error_logs.append(message)
@@ -422,14 +422,7 @@ class Memory(SearchMixin, MemoryCoreMixin):
os.makedirs(rag_path, exist_ok=True)
# Get chromadb lazily
try:
import chromadb
from chromadb.config import Settings as ChromaSettings
except ImportError as exc:
raise ImportError(
"chromadb is required for the rag memory provider. "
"Install with: pip install 'praisonaiagents[memory]'"
) from exc
chromadb, ChromaSettings = _get_chromadb()
# Initialize ChromaDB with persistent storage
self.chroma_client = chromadb.PersistentClient(
@@ -1805,11 +1798,7 @@ class Memory(SearchMixin, MemoryCoreMixin):
"""
try:
import importlib.util
litellm_available = importlib.util.find_spec("litellm") is not None
openai_available = importlib.util.find_spec("openai") is not None
if litellm_available:
if _check_litellm():
# Use LiteLLM for consistency with the rest of the codebase
import litellm
@@ -1825,7 +1814,7 @@ class Memory(SearchMixin, MemoryCoreMixin):
response_format={"type": "json_object"},
temperature=0.3
)
elif openai_available:
elif _check_openai():
# Fallback to OpenAI client
from openai import OpenAI
client = OpenAI()
@@ -0,0 +1,111 @@
"""
Shared utilities for memory modules.
This module contains utility functions that were originally in memory.py
and are needed by the decomposed mixin modules.
"""
import os
import threading
import logging
# Thread-safe import cache
_import_lock = threading.Lock()
_module_cache = {}
def _check_chromadb():
"""Thread-safe lazy check for chromadb availability."""
if "chromadb" in _module_cache:
return _module_cache["chromadb"]["available"]
with _import_lock:
if "chromadb" in _module_cache:
return _module_cache["chromadb"]["available"]
try:
import chromadb
from chromadb.config import Settings as ChromaSettings
_module_cache["chromadb"] = {
"available": True,
"module": chromadb,
"settings": ChromaSettings
}
return True
except ImportError:
_module_cache["chromadb"] = {"available": False}
return False
def _get_chromadb():
"""Thread-safe lazy import of chromadb (only if available)."""
if "chromadb" not in _module_cache or not _module_cache["chromadb"]["available"]:
if not _check_chromadb():
return {}
with _import_lock:
return {
"module": _module_cache["chromadb"]["module"],
"settings": _module_cache["chromadb"]["settings"]
}
def _check_pymongo():
"""Thread-safe lazy check for pymongo availability."""
if "pymongo" in _module_cache:
return _module_cache["pymongo"]["available"]
with _import_lock:
if "pymongo" in _module_cache:
return _module_cache["pymongo"]["available"]
try:
import pymongo
import motor.motor_asyncio
_module_cache["pymongo"] = {
"available": True,
"MongoClient": pymongo.MongoClient,
"AsyncIOMotorClient": motor.motor_asyncio.AsyncIOMotorClient
}
return True
except ImportError:
_module_cache["pymongo"] = {"available": False}
return False
def _get_pymongo():
"""Thread-safe lazy import of pymongo (only if available)."""
if "pymongo" not in _module_cache or not _module_cache["pymongo"]["available"]:
if not _check_pymongo():
return {}
with _import_lock:
return {
"MongoClient": _module_cache["pymongo"]["MongoClient"],
"AsyncIOMotorClient": _module_cache["pymongo"]["AsyncIOMotorClient"]
}
def _check_openai():
"""Thread-safe lazy check for openai availability."""
if "openai" in _module_cache:
return _module_cache["openai"]["available"]
with _import_lock:
if "openai" in _module_cache:
return _module_cache["openai"]["available"]
try:
import openai
_module_cache["openai"] = {
"available": True,
"module": openai
}
return True
except ImportError:
_module_cache["openai"] = {"available": False}
return False
def _get_openai():
"""Thread-safe lazy import of openai (only if available)."""
if "openai" not in _module_cache or not _module_cache["openai"]["available"]:
if not _check_openai():
return None
with _import_lock:
return _module_cache["openai"]["module"]
@@ -1,88 +0,0 @@
"""
Encoding-safe symbol helpers for console output.
On some consoles (e.g. Windows default cp1252) writing decorative Unicode
glyphs such as ``▸`` (U+25B8) raises ``UnicodeEncodeError``. These helpers
detect whether the target stream can encode a symbol and provide an ASCII
fallback so progress/status output never crashes the agent loop.
This mirrors the ``_can_encode_unicode()`` fallback used by the doctor
formatter, exposed here as a shared utility for the output package.
"""
import sys
from typing import Optional, TextIO
# Common decorative glyphs mapped to safe ASCII fallbacks.
_ASCII_FALLBACKS = {
"\u25b8": ">", # ▸ black right-pointing small triangle
"\u25b6": ">", # ▶ black right-pointing triangle
"\u25c0": "<", # ◀ black left-pointing triangle
"\u2192": "->", # → rightwards arrow
"\u2190": "<-", # ← leftwards arrow
"\u2713": "v", # ✓ check mark
"\u2717": "x", # ✗ ballot x
"\u2714": "v", # ✔ heavy check mark
"\u2718": "x", # ✘ heavy ballot x
"\u2500": "-", # ─ box drawings light horizontal
"\u2502": "|", # │ box drawings light vertical
"\u2514": "+", # └ box drawings light up and right
"\u2500\u2500": "--",
"\U0001f4ca": "[metrics]", # 📊 bar chart
}
def can_encode(stream: Optional[TextIO], text: str) -> bool:
"""Return True if ``text`` can be encoded by ``stream``'s encoding."""
if not isinstance(text, str):
return True
encoding = getattr(stream, "encoding", None) or getattr(sys.stdout, "encoding", None) or "ascii"
if encoding.lower() in ("utf-8", "utf8"):
return True
try:
text.encode(encoding, errors="strict")
return True
except (UnicodeEncodeError, LookupError, TypeError):
return False
def safe_symbol(unicode_symbol: str, ascii_fallback: Optional[str] = None,
stream: Optional[TextIO] = None) -> str:
"""
Return ``unicode_symbol`` if the stream can encode it, else an ASCII
fallback. When no explicit fallback is given, a sensible default is used.
"""
target = stream if stream is not None else sys.stdout
if can_encode(target, unicode_symbol):
return unicode_symbol
if ascii_fallback is not None:
return ascii_fallback
return _ASCII_FALLBACKS.get(unicode_symbol, "")
def safe_text(text: str, stream: Optional[TextIO] = None) -> str:
"""
Return ``text`` with any unencodable decorative glyphs replaced by ASCII
fallbacks so it can be printed without raising ``UnicodeEncodeError``.
"""
if not isinstance(text, str):
return text
target = stream if stream is not None else sys.stdout
if can_encode(target, text):
return text
result = text
for glyph, fallback in _ASCII_FALLBACKS.items():
if glyph in result:
result = result.replace(glyph, fallback)
if can_encode(target, result):
return result
# Last resort: drop any remaining unencodable characters so printing
# never raises UnicodeEncodeError on legacy consoles.
encoding = getattr(target, "encoding", None) or getattr(sys.stdout, "encoding", None) or "ascii"
try:
return result.encode(encoding, errors="replace").decode(encoding, errors="replace")
except (LookupError, TypeError):
return result.encode("ascii", errors="replace").decode("ascii")
__all__ = ["can_encode", "safe_symbol", "safe_text"]
@@ -293,8 +293,7 @@ class StatusOutput:
self._emit_text("Final Output:", ts, "bold", show_timestamp=False)
# Print actual content to stdout (not stderr) so users see it
with self._lock: # Thread-safe output
from .encoding import safe_text
print(safe_text(content, sys.stdout))
print(content)
def _emit_text(self, message: str, ts: float, style: str = None, show_timestamp: bool = True) -> None:
"""Emit a text line. Thread-safe for multi-agent execution."""
@@ -310,8 +309,7 @@ class StatusOutput:
else:
console.print(f"[dim]{ts_str}[/dim]{message}")
else:
from .encoding import safe_text
print(safe_text(f"{ts_str}{message}", self._file), file=self._file)
print(f"{ts_str}{message}", file=self._file)
def _emit_jsonl(self, event_type: str, **kwargs) -> None:
"""Emit a JSONL line. Thread-safe for multi-agent execution."""
@@ -90,8 +90,7 @@ class TraceOutput:
else:
console.print(f"[dim]{ts_str}[/dim]{message}")
else:
from .encoding import safe_text
print(safe_text(f"{ts_str}{message}", self._file), file=self._file)
print(f"{ts_str}{message}", file=self._file)
def llm_start(self, model: str = None) -> None:
"""Record LLM call start."""
@@ -193,10 +192,9 @@ class TraceOutput:
else:
console.print(content)
else:
from .encoding import safe_text
print("", file=self._file)
print("Response:", file=self._file)
print(safe_text(content, self._file), file=self._file)
print(content, file=self._file)
def error(self, message: str) -> None:
"""Output error message."""
@@ -36,8 +36,6 @@ __all__ = [
"PermissionResult",
"PersistentApproval",
"DoomLoopDetector",
"derive_pattern",
"command_prefix",
]
@@ -70,13 +68,5 @@ def __getattr__(name: str):
if name == "DoomLoopDetector":
from .doom_loop import DoomLoopDetector
return DoomLoopDetector
if name == "derive_pattern":
from .arity import derive_pattern
return derive_pattern
if name == "command_prefix":
from .arity import prefix
return prefix
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -1,181 +0,0 @@
"""
Command-arity inference for reusable permission-prefix scopes.
When a user approves a shell command with a persistent scope (``session`` or
``always``), recording the *literal* command string means only that exact
command is auto-allowed next time. Approving ``git status`` will not match a
later ``git status -s`` or ``git status .`` — producing approval fatigue.
This module derives a *reusable command prefix* from a small, extensible
command-arity table so that, for example:
* ``git status`` -> ``git status`` (prefix) -> pattern ``bash:git status *``
* ``npm run build`` -> ``npm run`` (prefix) -> pattern ``bash:npm run *``
* ``docker compose up`` -> ``docker compose`` (prefix) -> pattern ``bash:docker compose *``
* ``ls -la`` -> ``ls`` (prefix) -> pattern ``bash:ls *``
Unknown commands fall back to a conservative single-token prefix, so a bare
``git`` never over-generalises to *all* git subcommands.
The table is intentionally small and additive; it can be extended without
touching the matching hot path (``fnmatch`` already handles the ``*`` suffix).
"""
from typing import Dict, List, Optional
# Command -> number of leading tokens to KEEP as the reusable prefix.
#
# The value is how many leading tokens make up the "meaningful" command that
# should be generalised (the remainder becomes ``*``). For example ``git`` -> 2
# keeps ``git status`` from ``git status -s``. Multi-word keys (e.g.
# ``"npm run"``) allow a subcommand to request a deeper prefix than its base
# command; the longest matching key wins. Unknown commands default to keeping a
# single leading token, so a bare ``git`` never over-generalises to all git.
ARITY: Dict[str, int] = {
# Version control
"git": 2, # git status -s -> git status *
"gh": 2, # gh pr create -> gh pr *
"hg": 2,
"svn": 2,
# Package managers / build tools
"npm": 2, # npm run build -> npm run *
"yarn": 2,
"pnpm": 2,
"pip": 2, # pip install x -> pip install *
"cargo": 2, # cargo run --bin x -> cargo run *
"go": 2, # go test ./... -> go test *
"poetry": 2,
"uv": 2,
"make": 2,
# Containers / orchestration
"docker": 2, # docker compose up -> docker compose *
"docker compose": 2, # docker compose up -d -> docker compose *
"kubectl": 2, # kubectl get pods -> kubectl get *
"helm": 2,
# Python tooling
"python": 2, # python -m pytest -> python -m *
"python3": 2,
"pytest": 1, # pytest tests/ -> pytest *
"ruff": 2, # ruff check . -> ruff check *
# System / misc
"apt": 2,
"apt-get": 2,
"brew": 2,
"systemctl": 2,
}
def prefix(tokens: List[str], arity_map: Optional[Dict[str, int]] = None) -> str:
"""Return the longest meaningful command prefix for ``tokens``.
Args:
tokens: The command split into whitespace-delimited tokens
(e.g. ``["git", "status", "-s"]``).
arity_map: Optional override for the arity table (defaults to
:data:`ARITY`).
Returns:
The meaningful command prefix as a single space-joined string. For an
empty ``tokens`` list an empty string is returned.
Examples:
>>> prefix(["git", "status", "-s"])
'git status'
>>> prefix(["npm", "run", "build"])
'npm run'
>>> prefix(["ls", "-la"])
'ls'
>>> prefix(["git"])
'git'
"""
if not tokens:
return ""
table = ARITY if arity_map is None else arity_map
# Try the longest multi-word keys first so "docker compose" wins over
# "docker". The matched value is the number of leading tokens to keep.
key_word_counts = sorted(
{len(k.split()) for k in table}, reverse=True
)
for n in key_word_counts:
if n > len(tokens):
continue
candidate = " ".join(tokens[:n])
if candidate in table:
keep = min(table[candidate], len(tokens))
return " ".join(tokens[:keep])
# Unknown command: keep only the first token (conservative default) so a
# bare command never over-generalises to all of its subcommands.
return tokens[0]
def derive_pattern(
target: str, arity_map: Optional[Dict[str, int]] = None
) -> str:
"""Derive a reusable glob pattern for a shell approval ``target``.
Only ``bash:``/``shell:`` targets are generalised; any other target (or one
that already contains a glob) is returned unchanged so callers can safely
apply this unconditionally.
Args:
target: The approval target, e.g. ``"bash:git status -s"``.
arity_map: Optional override for the arity table.
Returns:
A reusable pattern such as ``"bash:git status *"``. The target is
returned **unchanged** when it is not a shell target, is empty, already
contains ``*``/``?`` glob characters, contains a shell control operator
(``&&``, ``|``, ``;``, ``$(...)`` etc.), or is a bare single-token
command (so a lone ``git`` never becomes ``git *``).
Examples:
>>> derive_pattern("bash:git status")
'bash:git status *'
>>> derive_pattern("bash:npm run build")
'bash:npm run *'
>>> derive_pattern("bash:git") # bare command stays literal
'bash:git'
>>> derive_pattern("bash:cd /tmp && rm x") # operator -> literal
'bash:cd /tmp && rm x'
>>> derive_pattern("read:/etc/hosts")
'read:/etc/hosts'
"""
shell_prefixes = ("bash:", "shell:")
matched = next((p for p in shell_prefixes if target.startswith(p)), None)
if matched is None:
return target
command = target[len(matched):].strip()
if not command:
return target
# If the user already provided a glob, respect it verbatim.
if "*" in command or "?" in command:
return target
# Shell control operators / substitutions. If the command contains any of
# these, a globbed prefix would silently swallow a *second* command into
# the reusable scope (e.g. ``cd /tmp && rm -rf x`` -> ``cd *``). Refuse to
# generalise such commands and keep the literal target instead.
_SHELL_OPERATORS = ("&&", "||", "|", ";", "&", "$(", "`", ">", "<", "\n")
if any(op in command for op in _SHELL_OPERATORS):
return target
tokens = command.split()
cmd_prefix = prefix(tokens, arity_map)
if not cmd_prefix:
return target
# A bare single-token command (e.g. ``git`` with no subcommand) must not be
# generalised to ``git *`` — that would auto-approve every subcommand,
# violating the documented conservative contract. Only generalise when the
# prefix is genuinely a prefix (i.e. there is at least one trailing token to
# replace with ``*``); a lone token that already equals the full command has
# nothing to generalise and is kept literal.
if len(cmd_prefix.split()) == 1 and cmd_prefix == command:
return target
return f"{matched}{cmd_prefix} *"
@@ -28,49 +28,6 @@ _SEPARATORS = ("&&", "||", ";", "|", "&", "\n")
_WRITE_REDIRECTS = (">", ">>", ">|", "&>", "&>>")
def _looks_like_path(tok: str) -> bool:
"""Return ``True`` if *tok* is path-like enough to warrant a boundary check.
Conservative but covers common escape forms: absolute (``/x``),
home-relative (``~``), explicit relative (``./``, ``../``, ``..``),
env-prefixed (``$VAR/…``) and any bare token that embeds a ``/`` (e.g.
``subdir/../../etc/passwd``). Plain flags/values without a path shape are
ignored so non-path args never trigger a spurious prompt.
"""
if not tok:
return False
return (
tok.startswith("/")
or tok.startswith("~")
or tok.startswith("./")
or tok.startswith("../")
or tok == ".."
or tok.startswith("$")
or "/" in tok
)
def _extract_flag_path(tok: str) -> str:
"""Extract a path operand attached to a short flag (``-o/tmp/x``).
Handles the ``getopt`` short-option form where the value is joined to the
flag with no separator (e.g. ``-o/tmp/outside``, ``-I/usr/include``). The
leading dashes and flag letters are stripped and the first embedded
path-shaped segment (``/…``, ``~…``, ``$…``) is returned; otherwise ``""``.
Long flags (``--flag=…``) are handled separately via ``=`` splitting.
"""
if not tok.startswith("-") or tok.startswith("--"):
return ""
body = tok.lstrip("-")
for i, ch in enumerate(body):
if ch in ("/", "~", "$"):
candidate = body[i:]
if _looks_like_path(candidate):
return candidate
break
return ""
@dataclass
class ShellOp:
"""A single simple-command extracted from a shell command line.
@@ -91,38 +48,6 @@ class ShellOp:
parts = [self.executable, *self.args]
return " ".join(p for p in parts if p)
@property
def path_args(self) -> List[str]:
"""Args that look like filesystem paths (for boundary checks).
Returns path-like tokens for boundary evaluation. Covers absolute
(``/x``), home-relative (``~``), parent/relative (``./``, ``../``),
env-prefixed (``$VAR/…``) and bare traversal/relative paths that
embed ``/`` (e.g. ``subdir/../../etc/passwd``). Path operands passed
as joined flags (``--config=/etc/x``) or short-flag-attached
(``-o/tmp/x``) are also unwrapped so the value is boundary-checked.
Plain flags and non-path values are ignored.
"""
paths: List[str] = []
for tok in self.args:
if not tok:
continue
# Flag forms can hide a path operand: ``--flag=<value>`` (split on
# ``=``) or short getopt ``-o<value>`` (value joined to the flag).
if tok.startswith("-"):
if "=" in tok:
value = tok.split("=", 1)[1]
if value and _looks_like_path(value):
paths.append(value)
continue
attached = _extract_flag_path(tok)
if attached:
paths.append(attached)
continue
if _looks_like_path(tok):
paths.append(tok)
return paths
def _extract_substitutions(token: str) -> List[str]:
"""Extract inner commands from ``$(...)`` and backtick substitutions.
@@ -58,7 +58,6 @@ class PermissionManager:
storage_dir: Optional[str] = None,
agent_name: Optional[str] = None,
approval_callback: Optional[Callable[[str, str], bool]] = None,
workspace_root: Optional[str] = None,
):
"""
Initialize the permission manager.
@@ -67,19 +66,10 @@ class PermissionManager:
storage_dir: Directory for persistent storage
agent_name: Optional agent name for filtering rules
approval_callback: Optional callback for user approval
workspace_root: Optional project/workspace root. When set, shell
and file targets that resolve *outside* this root emit a
distinct ``external_dir:<parent>/*`` sub-target (default
``ask``), gating out-of-workspace access with an extra
approval. When ``None`` (default) no boundary check runs and
behaviour is unchanged.
"""
self.storage_dir = storage_dir or DEFAULT_PERMISSIONS_DIR
self.agent_name = agent_name
self.approval_callback = approval_callback
self.workspace_root = (
os.path.realpath(workspace_root) if workspace_root else None
)
self._rules: List[PermissionRule] = []
self._approvals: List[PersistentApproval] = []
@@ -210,22 +200,6 @@ class PermissionManager:
# sub-targets before matching (command-aware permission matching).
_SHELL_PREFIXES = ("bash:", "shell:")
# File-mutating tool prefixes whose ``<prefix>:<path>`` target must also be
# gated by the workspace boundary. A broad ``write_file:*`` approval should
# not silently permit writes outside ``workspace_root``; out-of-workspace
# paths emit an ``external_dir:`` ask (deny rules still win).
_FILE_PREFIXES = (
"write:",
"write_file:",
"edit:",
"edit_file:",
"apply_patch:",
"append:",
"append_file:",
"delete:",
"delete_file:",
)
def check(self, target: str, agent_name: Optional[str] = None) -> PermissionResult:
"""
Check permission for a target.
@@ -250,61 +224,9 @@ class PermissionManager:
structured = self._check_shell_command(prefix, command, agent)
if structured is not None:
return structured
return self._check_flat(target, agent)
# File-mutating tool targets share the workspace-boundary gate so a
# broad ``write_file:*`` approval cannot silently escape the workspace.
if self.workspace_root is not None:
file_prefix = next(
(p for p in self._FILE_PREFIXES if target.startswith(p)), None
)
if file_prefix is not None:
boundary = self._check_file_boundary(
file_prefix, target[len(file_prefix):], target, agent
)
if boundary is not None:
return boundary
return self._check_flat(target, agent)
def _check_file_boundary(
self, prefix: str, path: str, original_target: str, agent: Optional[str]
) -> Optional[PermissionResult]:
"""Apply the workspace boundary to a file-tool ``<prefix>:<path>``.
Returns ``None`` when the path is inside the workspace (defer to normal
flat matching) so in-workspace behaviour is unchanged. When the path
escapes the root, aggregates the flat file-target result with the
``external_dir:`` gate using deny-wins → ask → allow, mirroring the
shell decomposition semantics (a deny still wins over the ask gate).
"""
ext = self._external_dir_target(path)
if ext is None:
return None
file_res = self._check_flat(original_target, agent)
ext_res = self._check_flat(ext, agent)
# Deny wins (either an explicit file-target deny or an external_dir deny).
for sub_target, res in ((original_target, file_res), (ext, ext_res)):
if res.action == PermissionAction.DENY:
res.reason = f"Denied '{sub_target}' ({res.reason})"
res.target = original_target
return res
# Then ask: the external-directory gate requires approval unless it has
# been explicitly pre-authorised (allow rule/approval on external_dir:).
if ext_res.action != PermissionAction.ALLOW:
ext_res.reason = (
f"Out-of-workspace path requires approval "
f"('{ext}': {ext_res.reason})"
)
ext_res.target = original_target
return ext_res
# External path was explicitly pre-authorised; honour the file rule.
return file_res
def _check_flat(self, target: str, agent: Optional[str]) -> PermissionResult:
"""Legacy flat matching against approvals and rules for a target."""
with self._lock:
@@ -364,23 +286,6 @@ class PermissionManager:
sub_targets.append(prefix + cmd_str)
for path in op.write_targets:
sub_targets.append(f"write:{path}")
# Workspace-boundary gate: any write target or path-like arg that
# resolves outside the workspace root gets a distinct
# ``external_dir:`` sub-target (default ``ask``).
if self.workspace_root is not None:
boundary_paths = list(op.write_targets) + op.path_args
# An executable referenced by path (``/tmp/tool``, ``../tool``)
# runs code outside the workspace; a bare name (``rm``) is
# PATH-resolved and must not trigger a boundary prompt.
if op.executable and (
op.executable.startswith(("/", "~", "./", "../", "$"))
or "/" in op.executable
):
boundary_paths.append(op.executable)
for path in boundary_paths:
ext = self._external_dir_target(path)
if ext is not None:
sub_targets.append(ext)
# No structured decomposition possible, or it collapses to exactly the
# original target — defer to the existing flat matcher.
@@ -423,87 +328,12 @@ class PermissionManager:
reason="All shell sub-operations allowed",
)
def _external_dir_target(self, path: str) -> Optional[str]:
"""Return an ``external_dir:<parent>/*`` target if *path* escapes root.
Resolves *path* (``~``, ``$VARS``, ``..``) against ``workspace_root``
using the shared ``path_safety`` containment logic. In-workspace paths
return ``None`` so existing flows are unaffected.
"""
if self.workspace_root is None:
return None
# Fail *closed*: if the boundary check cannot be performed (import or
# resolution failure), gate the raw path behind ``external_dir:`` rather
# than silently allowing it. A deny rule still wins over this ask.
fail_closed = f"external_dir:{path}/*"
try:
from ..tools.path_safety import resolve_within_root, resolve_path
except Exception as e: # noqa: BLE001
logger.error(
"Workspace boundary check unavailable for path '%s' "
"(agent=%s): %s. Requiring approval instead of allowing.",
path, self.agent_name, e,
)
return fail_closed
try:
if resolve_within_root(path, self.workspace_root) is not None:
return None
resolved = resolve_path(path, self.workspace_root)
parent = os.path.dirname(resolved) or resolved
return f"external_dir:{parent}/*"
except Exception as e: # noqa: BLE001
logger.warning(
"Could not resolve path '%s' for workspace boundary check "
"(agent=%s): %s. Requiring approval.",
path, self.agent_name, e,
)
return fail_closed
def check_path_boundary(
self, path: str, agent_name: Optional[str] = None
) -> Optional[PermissionResult]:
"""Check a single file-tool target against the workspace boundary.
Shared boundary policy for file tools (``write_file``/``edit``/
``apply_patch``). Returns ``None`` when no ``workspace_root`` is
configured or the path is inside it (i.e. no extra gate); otherwise
returns the ``external_dir:`` permission decision (default ``ask``).
"""
ext = self._external_dir_target(path)
if ext is None:
return None
return self.check(ext, agent_name)
def suggest_scope_pattern(self, target: str) -> str:
"""Suggest a reusable command-prefix pattern for a shell ``target``.
Derives a generalised glob (e.g. ``bash:git status`` ->
``bash:git status *``) from the command-arity table so a persistent
approval can cover repeated invocations with different trailing args.
Non-shell targets (or targets already containing a glob) are returned
unchanged. Matching is untouched — ``fnmatch`` already handles ``*``.
Args:
target: The approval target, e.g. ``"bash:git status -s"``.
Returns:
The suggested reusable pattern, or ``target`` if no generalisation
applies.
"""
try:
from .arity import derive_pattern
return derive_pattern(target)
except Exception:
return target
def approve(
self,
target: str,
approved: bool,
scope: str = "once",
agent_name: Optional[str] = None,
reusable_scope: bool = False,
pattern: Optional[str] = None,
) -> PersistentApproval:
"""
Record an approval decision.
@@ -513,39 +343,15 @@ class PermissionManager:
approved: Whether it was approved
scope: Scope of approval (once, session, always)
agent_name: Optional agent name
reusable_scope: When True and scope is session/always, derive a
reusable command-prefix pattern from ``target`` (e.g.
``bash:git status`` -> ``bash:git status *``) instead of storing
the literal command. Ignored when an explicit ``pattern`` is
given. Defaults to False for backward compatibility.
pattern: Explicit pattern to record. Overrides both ``target`` and
``reusable_scope`` derivation, letting callers (CLI/UI) present
and adjust the suggested scope before saving.
Returns:
The created PersistentApproval
"""
stored_pattern = pattern if pattern is not None else target
# ``derived`` is True only when we auto-generalise ``target`` into a
# reusable prefix glob here. An explicit ``pattern`` (or a literal
# target) is user-authored and keeps exact fnmatch semantics — the
# bare-prefix fallback in PersistentApproval.matches never applies to it.
derived = False
if (
pattern is None
and reusable_scope
and scope in ("session", "always")
):
suggested = self.suggest_scope_pattern(target)
derived = suggested != target
stored_pattern = suggested
approval = PersistentApproval(
pattern=stored_pattern,
pattern=target,
approved=approved,
scope=scope,
agent_name=agent_name or self.agent_name,
derived=derived,
)
with self._lock:
@@ -210,11 +210,6 @@ class PersistentApproval:
expires_at: Optional[float] = None
agent_name: Optional[str] = None
id: str = field(default_factory=lambda: str(uuid.uuid4()))
# True only when ``pattern`` was auto-generated by the reusable
# command-prefix scope derivation (arity.derive_pattern). Gates the
# bare-prefix fallback in ``matches`` so user-authored globs like
# ``bash:rm *`` keep exact fnmatch semantics.
derived: bool = False
def is_valid(self) -> bool:
"""Check if the approval is still valid."""
@@ -230,27 +225,7 @@ class PersistentApproval:
if self.agent_name and agent_name and self.agent_name != agent_name:
return False
if fnmatch.fnmatch(target, self.pattern):
return True
# A reusable command-prefix scope like "bash:git status *" is meant to
# cover the bare command too (e.g. "bash:git status" with no trailing
# args), which a plain "<prefix> *" glob would miss. Match the prefix
# exactly as well, without altering the general matching hot path.
#
# Gated on ``self.derived`` so this ONLY applies to patterns produced by
# the reusable-scope derivation (arity.derive_pattern). User-authored
# globs — shell or otherwise, e.g. "bash:rm *" or "read:/secret *" —
# keep their exact fnmatch semantics and this never changes their
# allow/deny decisions.
if self.derived and self.pattern.endswith(" *") and self.pattern.startswith(
("bash:", "shell:")
):
bare = self.pattern[:-2]
if target == bare:
return True
return False
return fnmatch.fnmatch(target, self.pattern)
def to_dict(self) -> Dict[str, Any]:
"""Convert approval to dictionary."""
@@ -262,7 +237,6 @@ class PersistentApproval:
"created_at": self.created_at,
"expires_at": self.expires_at,
"agent_name": self.agent_name,
"derived": self.derived,
}
@classmethod
@@ -276,5 +250,4 @@ class PersistentApproval:
created_at=data.get("created_at", time.time()),
expires_at=data.get("expires_at"),
agent_name=data.get("agent_name"),
derived=data.get("derived", False),
)
@@ -105,13 +105,6 @@ class Plugin(ABC):
def after_tool(self, tool_name: str, result: Any) -> Any:
"""Called after tool execution. Can modify result."""
return result
def before_tool_definitions(
self, tool_definitions: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""Called after advertised tool definitions are assembled and before
they reach the LLM. Can inspect, filter, or rewrite them."""
return tool_definitions
def before_message(self, message: Dict[str, Any]) -> Dict[str, Any]:
"""Called before message is processed. Can modify message."""
@@ -14,7 +14,7 @@ import asyncio
from unittest.mock import Mock, patch, MagicMock
from typing import Any, Dict
from praisonaiagents.runtime.resolve import (
from .resolve import (
resolve_runtime,
SessionContext,
RuntimeProtocol,
@@ -10,7 +10,7 @@ import pytest
from typing import List, Dict, Any
from unittest.mock import Mock, patch
from praisonaiagents.runtime.turn_context import (
from .turn_context import (
PreparedTurnContext,
ModelReference,
ToolSchema,
@@ -23,7 +23,7 @@ from praisonaiagents.runtime.turn_context import (
create_default_delivery,
create_session_correlation,
)
from praisonaiagents.runtime.context_builder import DefaultTurnContextBuilder
from .context_builder import DefaultTurnContextBuilder
class MockAgent:
@@ -40,9 +40,6 @@ __all__ = [
# Discovery
"discover_skills",
"get_default_skill_dirs",
# Bundles
"BundleManifest",
"discover_bundles",
# Loader
"SkillLoader",
# Manager
@@ -95,10 +92,6 @@ def __getattr__(name: str):
if name in ("discover_skills", "get_default_skill_dirs"):
from .discovery import discover_skills, get_default_skill_dirs
return locals()[name]
if name in ("BundleManifest", "discover_bundles"):
from .bundles import BundleManifest, discover_bundles
return locals()[name]
if name == "SkillLoader":
from .loader import SkillLoader
@@ -1,197 +0,0 @@
"""Skill bundles: named, reusable sets of skills.
A *bundle* is a small manifest that references existing skills by name and is
itself selectable as one unit. It is composition over the existing skills
layer — not a new execution primitive. A bundle expands to its member skills,
which then flow through the unchanged budget + prompt-injection path.
Bundles are discovered alongside skills:
- a top-level ``BUNDLE.yaml`` / ``BUNDLE.yml`` inside a skill directory, or
- any ``*.yaml`` / ``*.yml`` file inside a ``bundles/`` subdirectory of a
skill root.
Selection uses a ``@`` marker everywhere a skill is selected
(``skills=["@backend-dev"]``), mirroring how skills already behave:
forgiving membership, precedence-with-logging, and budget-awareness.
"""
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
from .discovery import get_default_skill_dirs
logger = logging.getLogger(__name__)
BUNDLE_MARKER = "@"
@dataclass
class BundleManifest:
"""A named set of skills selectable as one unit.
Attributes:
name: Bundle name (kebab-case, like a skill name).
description: What the bundle is for.
skills: Member skill names referenced by the bundle.
instruction: Optional extra guidance loaded above the member skills.
path: Path to the manifest file (set on discovery).
"""
name: str
description: str = ""
skills: List[str] = field(default_factory=list)
instruction: Optional[str] = None
path: Optional[Path] = None
@classmethod
def from_dict(cls, data: dict, path: Optional[Path] = None) -> "BundleManifest":
"""Build a BundleManifest from a parsed mapping.
Tolerant of both ``skills`` (preferred) and ``members`` keys for the
member list, and accepts a string or list for the members.
"""
name = str(data.get("name") or "").strip()
if not name:
raise ValueError("Bundle manifest must define a 'name'")
members = data.get("skills")
if members is None:
members = data.get("members")
skills = _normalize_members(members)
return cls(
name=name,
description=str(data.get("description") or ""),
skills=skills,
instruction=(str(data["instruction"]) if data.get("instruction") else None),
path=path,
)
def _normalize_members(value) -> List[str]:
"""Normalize a member list to a list of clean skill-name strings."""
if value is None:
return []
if isinstance(value, str):
parts = value.replace(",", " ").split()
return [p.strip() for p in parts if p.strip()]
if isinstance(value, (list, tuple)):
return [str(v).strip() for v in value if str(v).strip()]
return []
def strip_bundle_marker(selector: str) -> str:
"""Return the bundle name for a ``@name`` selector (or the name itself)."""
s = selector.strip()
return s[len(BUNDLE_MARKER):] if s.startswith(BUNDLE_MARKER) else s
def is_bundle_selector(selector: str) -> bool:
"""True if ``selector`` is a ``@bundle`` reference."""
return isinstance(selector, str) and selector.strip().startswith(BUNDLE_MARKER)
def _load_manifest_file(file_path: Path) -> Optional[BundleManifest]:
"""Load a single bundle manifest from a YAML file, tolerantly."""
try:
import yaml # type: ignore
except ImportError:
logger.warning(
"PyYAML is required to parse bundle manifests. "
"Install it with: pip install PyYAML"
)
return None
try:
with open(file_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
except (OSError, Exception) as exc: # noqa: BLE001 - tolerant discovery
logger.warning("Skipping invalid bundle %s: %s", file_path, exc)
return None
if not isinstance(data, dict):
logger.warning("Skipping bundle %s: manifest is not a mapping", file_path)
return None
try:
return BundleManifest.from_dict(data, path=file_path)
except Exception as exc: # noqa: BLE001 - tolerant discovery
logger.warning("Skipping invalid bundle %s: %s", file_path, exc)
return None
def _iter_bundle_files(root: Path):
"""Yield candidate bundle-manifest files under a single skill root."""
# bundles/ subdirectory of the root
bundles_dir = root / "bundles"
if bundles_dir.exists() and bundles_dir.is_dir():
try:
for item in sorted(bundles_dir.iterdir()):
if item.is_file() and item.suffix.lower() in (".yaml", ".yml"):
yield item
except PermissionError as exc:
logger.warning("Cannot read bundles directory %s: %s", bundles_dir, exc)
# BUNDLE.yaml inside each skill subdirectory
try:
for item in sorted(root.iterdir()):
if not item.is_dir():
continue
for name in ("BUNDLE.yaml", "BUNDLE.yml", "bundle.yaml", "bundle.yml"):
candidate = item / name
if candidate.exists() and candidate.is_file():
yield candidate
break
except PermissionError as exc:
logger.warning("Cannot read skills directory %s: %s", root, exc)
def discover_bundles(
skill_dirs: Optional[List[str]] = None,
include_defaults: bool = True,
) -> List[BundleManifest]:
"""Discover bundle manifests alongside skills.
Mirrors :func:`discovery.discover_skills`: scans the same roots, is
forgiving of invalid manifests, and reports name collisions (first
wins, later entries shadowed-and-logged).
Args:
skill_dirs: Explicit skill-root directories to scan.
include_defaults: Whether to include default skill directories.
Returns:
List of discovered BundleManifest objects.
"""
all_dirs: List[Path] = []
if skill_dirs:
for d in skill_dirs:
path = Path(d).expanduser().resolve()
if path.exists() and path.is_dir():
all_dirs.append(path)
if include_defaults:
all_dirs.extend(get_default_skill_dirs())
seen_dirs = set()
unique_dirs: List[Path] = []
for d in all_dirs:
if d not in seen_dirs:
seen_dirs.add(d)
unique_dirs.append(d)
bundles: List[BundleManifest] = []
for root in unique_dirs:
for file_path in _iter_bundle_files(root):
manifest = _load_manifest_file(file_path)
if manifest is None:
continue
if any(b.name == manifest.name for b in bundles):
logger.info(
"Bundle '%s' at %s shadowed by earlier entry (precedence).",
manifest.name, file_path,
)
continue
bundles.append(manifest)
return bundles
@@ -1,14 +1,11 @@
"""SkillManager for Agent Skills integration."""
import logging
from typing import List, Optional, Dict, TYPE_CHECKING
from typing import List, Optional, Dict
logger = logging.getLogger(__name__)
from .models import SkillMetadata, SkillState
if TYPE_CHECKING:
from .bundles import BundleManifest
from .discovery import discover_skills, get_default_skill_dirs
from .loader import SkillLoader, LoadedSkill
from .prompt import generate_skills_xml
@@ -50,8 +47,6 @@ class SkillManager:
for safe-by-default behaviour).
"""
self._skills: Dict[str, LoadedSkill] = {}
self._bundles: Dict[str, "BundleManifest"] = {}
self._selected_bundles: List[str] = []
self._loader = SkillLoader()
self._discovered = False
self._validation_cache: Dict[str, ValidationResult] = {}
@@ -107,113 +102,6 @@ class SkillManager:
self._discovered = True
return len(props_list)
# ── Bundles (composition over skills) ─────────────────────────────
@property
def bundles(self) -> List["BundleManifest"]:
"""Get all registered bundle manifests."""
return list(self._bundles.values())
@property
def bundle_names(self) -> List[str]:
"""Get names of all registered bundles."""
return list(self._bundles.keys())
def add_bundle(self, manifest: "BundleManifest") -> None:
"""Register a bundle manifest.
First registration wins; a later bundle of the same name is shadowed
and logged (same precedence stance discovery already uses).
"""
if manifest.name in self._bundles:
logger.info(
"Bundle '%s' already registered; ignoring duplicate (precedence).",
manifest.name,
)
return
self._bundles[manifest.name] = manifest
def get_bundle(self, name: str) -> Optional["BundleManifest"]:
"""Get a bundle manifest by name (``@`` marker optional)."""
from .bundles import strip_bundle_marker
return self._bundles.get(strip_bundle_marker(name))
def discover_bundles(
self,
skill_dirs: Optional[List[str]] = None,
include_defaults: bool = True,
) -> int:
"""Discover bundle manifests from directories.
Returns:
Number of bundles registered.
"""
from .bundles import discover_bundles as _discover_bundles
manifests = _discover_bundles(skill_dirs, include_defaults)
added = 0
for manifest in manifests:
if manifest.name not in self._bundles:
self._bundles[manifest.name] = manifest
added += 1
return added
def resolve(self, selectors: List[str]) -> List[str]:
"""Expand ``@bundle`` selectors into member skill names.
Plain skill names/paths pass through unchanged. A ``@bundle`` selector
expands to its member skill names (forgiving: a missing/unknown bundle
is logged, not fatal). Nested ``@bundle`` members are expanded
recursively, with cycle protection. Duplicates are removed while
preserving order.
"""
from .bundles import is_bundle_selector, strip_bundle_marker
resolved: List[str] = []
seen = set()
def _add(item: str) -> None:
if item and item not in seen:
seen.add(item)
resolved.append(item)
def _expand(selector: str, stack: tuple) -> None:
if not is_bundle_selector(selector):
_add(selector)
return
name = strip_bundle_marker(selector)
if name in stack:
logger.warning(
"Skill bundle cycle detected at '@%s'; skipping.", name,
)
return
manifest = self._bundles.get(name)
if manifest is None:
logger.warning(
"Unknown skill bundle '@%s'; skipping (no such bundle).",
name,
)
return
# Record selected bundles so their bundle-level 'instruction' can
# be surfaced in the prompt, including nested bundles.
if name not in self._selected_bundles:
self._selected_bundles.append(name)
for member in manifest.skills:
_expand(member, stack + (name,))
for selector in selectors or []:
_expand(selector, ())
return resolved
def get_bundle_instructions(self) -> List[str]:
"""Return bundle-level instructions for selected bundles, in order."""
out: List[str] = []
for name in self._selected_bundles:
manifest = self._bundles.get(name)
if manifest is not None and manifest.instruction:
out.append(manifest.instruction)
return out
def add_skill(self, skill_path: str) -> Optional[LoadedSkill]:
"""Add a single skill from a directory path.
@@ -228,26 +116,6 @@ class SkillManager:
self._skills[skill.properties.name] = skill
return skill
def add_skill_by_name(
self,
name: str,
skill_dirs: Optional[List[str]] = None,
include_defaults: bool = True,
) -> Optional[LoadedSkill]:
"""Add a single skill resolved by *name* (not path).
Searches the given/default skill directories for a skill whose name
matches and loads it. Used to materialise bundle members, which are
referenced by name. Forgiving: returns None if no match is found.
"""
if name in self._skills:
return self._skills[name]
props_list = discover_skills(skill_dirs, include_defaults)
for props in props_list:
if props.name == name and props.path is not None:
return self.add_skill(str(props.path))
return None
def get_skill(self, name: str) -> Optional[LoadedSkill]:
"""Get a skill by name.
@@ -290,59 +158,23 @@ class SkillManager:
This is used for system prompt injection. Skills are filtered by:
1. disable-model-invocation: true (excluded)
2. Capability enforcement (if strict mode, exclude UNAVAILABLE skills)
3. Graceful-degradation fallbacks: a skill that declares
``fallback_for_tools`` / ``fallback_for_servers`` is offered only
when the referenced capability is ABSENT.
"""
available = []
# Resolved capability sets are needed to evaluate fallback eligibility.
# Computed lazily and cached by the validator.
available_tools = None
available_servers = None
for skill in self._skills.values():
# Skip if explicitly disabled for model invocation
if getattr(skill.properties, "disable_model_invocation", False):
continue
req = getattr(skill.properties, "requirements", None)
is_fallback_skill = bool(
req is not None and (req.fallback_for_tools or req.fallback_for_servers)
)
# Check capability requirements when enforcement is strict, OR for
# fallback skills regardless of mode: a fallback skill must still
# satisfy its own ``requires_*`` gates (e.g. a web-via-terminal
# fallback that needs the terminal tool stays hidden if terminal is
# also absent), so we never inject an unusable skill.
if self._validator.enforcement_level == EnforcementLevel.STRICT or is_fallback_skill:
# Check capability requirements if enforcement is strict
if self._validator.enforcement_level == EnforcementLevel.STRICT:
try:
validation = self.validate_skill_capabilities(skill.properties.name)
if validation.state == SkillState.UNAVAILABLE:
continue # Skip unavailable skills in strict mode
# A fallback skill must satisfy its own requires_* gates in
# every mode. In non-strict modes missing tools/servers only
# mark the skill DEGRADED (not UNAVAILABLE), so check the
# critical-missing flag directly to avoid offering a fallback
# that itself cannot run.
if is_fallback_skill and validation.has_critical_missing:
continue
except Exception as e:
logger.warning(f"Skipping skill '{skill.properties.name}' due to validation error: {e}")
continue
# Graceful degradation: a fallback skill is only offered when its
# target capability is absent. When the real tool/server is present,
# the fallback stays hidden to keep the skills index lean.
if is_fallback_skill:
if available_tools is None:
available_tools = self._validator._get_available_tools()
if available_servers is None:
available_servers = self._validator._get_available_servers()
if any(t in available_tools for t in req.fallback_for_tools):
continue
if any(s in available_servers for s in req.fallback_for_servers):
continue
available.append(skill.metadata)
return available
@@ -418,26 +250,11 @@ class SkillManager:
def to_prompt(self) -> str:
"""Generate XML prompt for available skills.
When one or more ``@bundle`` selectors carried a bundle-level
``instruction``, those are prepended in a ``<bundle_instructions>``
block so the documented bundle guidance actually reaches the prompt.
Returns:
XML string with <available_skills> block
"""
metadata_list = self.get_available_skills()
skills_xml = generate_skills_xml(metadata_list)
instructions = self.get_bundle_instructions()
if not instructions:
return skills_xml
import html
lines = ["<bundle_instructions>"]
for text in instructions:
lines.append(f" <instruction>{html.escape(text)}</instruction>")
lines.append("</bundle_instructions>")
return "\n".join(lines) + "\n" + skills_xml
return generate_skills_xml(metadata_list)
def get_instructions(self, name: str) -> Optional[str]:
"""Get instructions for a skill, activating if needed.
@@ -1492,8 +1309,6 @@ version: 1.0.0
def clear(self) -> None:
"""Clear all loaded skills."""
self._skills.clear()
self._bundles.clear()
self._selected_bundles.clear()
self._validation_cache.clear()
self._validator.clear_cache()
self._discovered = False
@@ -36,19 +36,11 @@ class SkillRequirements:
tools: Required tool names (from tool registry)
env_vars: Required environment variables (use sparingly)
openclaw_hints: OpenClaw-specific metadata (passthrough)
fallback_for_tools: Tools this skill is a graceful fallback for. The
skill is offered only when *none* of these tools are present,
keeping capable agents free of redundant fallback guidance.
fallback_for_servers: MCP servers this skill is a graceful fallback
for. The skill is offered only when *none* of these servers are
present.
"""
servers: List[str] = field(default_factory=list)
tools: List[str] = field(default_factory=list)
env_vars: List[str] = field(default_factory=list)
openclaw_hints: dict = field(default_factory=dict)
fallback_for_tools: List[str] = field(default_factory=list)
fallback_for_servers: List[str] = field(default_factory=list)
@classmethod
def from_frontmatter(cls, metadata: dict) -> "SkillRequirements":
@@ -69,8 +61,6 @@ class SkillRequirements:
tools = []
env_vars = []
openclaw_hints = {}
fallback_for_tools = []
fallback_for_servers = []
# Parse servers
if "requires_servers" in metadata:
@@ -97,26 +87,12 @@ class SkillRequirements:
# OpenClaw hints (passthrough)
if "openclaw" in metadata:
openclaw_hints = metadata["openclaw"] if isinstance(metadata["openclaw"], dict) else {}
# Parse graceful-degradation fallback declarations. A fallback skill is
# surfaced only when its target capability is ABSENT (inverse of requires).
if "fallback_for_tools" in metadata:
fallback_for_tools = cls._normalize_list(metadata["fallback_for_tools"])
elif "fallback-for-tools" in metadata:
fallback_for_tools = cls._normalize_list(metadata["fallback-for-tools"])
if "fallback_for_servers" in metadata:
fallback_for_servers = cls._normalize_list(metadata["fallback_for_servers"])
elif "fallback-for-servers" in metadata:
fallback_for_servers = cls._normalize_list(metadata["fallback-for-servers"])
return cls(
servers=servers,
tools=tools,
env_vars=env_vars,
openclaw_hints=openclaw_hints,
fallback_for_tools=fallback_for_tools,
fallback_for_servers=fallback_for_servers
openclaw_hints=openclaw_hints
)
@staticmethod
@@ -132,10 +108,7 @@ class SkillRequirements:
def is_empty(self) -> bool:
"""Check if no requirements are specified."""
return not any([
self.servers, self.tools, self.env_vars, self.openclaw_hints,
self.fallback_for_tools, self.fallback_for_servers,
])
return not any([self.servers, self.tools, self.env_vars, self.openclaw_hints])
def __bool__(self) -> bool:
"""Truth value: True if any requirements are specified."""
@@ -25,8 +25,6 @@ __all__ = [
"AsyncStreamCallback",
"create_text_printer_callback",
"create_metrics_callback",
"emit_tool_progress",
"tool_progress_channel",
"StreamLogger",
"create_logging_callback",
]
@@ -39,8 +37,7 @@ def __getattr__(name: str):
if name in (
"StreamEvent", "StreamEventType", "StreamMetrics",
"StreamEventEmitter", "StreamCallback", "AsyncStreamCallback",
"create_text_printer_callback", "create_metrics_callback",
"emit_tool_progress", "tool_progress_channel"
"create_text_printer_callback", "create_metrics_callback"
):
from .events import (
StreamEvent,
@@ -51,8 +48,6 @@ def __getattr__(name: str):
AsyncStreamCallback,
create_text_printer_callback,
create_metrics_callback,
emit_tool_progress,
tool_progress_channel,
)
_events_exports = {
@@ -64,8 +59,6 @@ def __getattr__(name: str):
"AsyncStreamCallback": AsyncStreamCallback,
"create_text_printer_callback": create_text_printer_callback,
"create_metrics_callback": create_metrics_callback,
"emit_tool_progress": emit_tool_progress,
"tool_progress_channel": tool_progress_channel,
}
return _events_exports.get(name)
@@ -30,7 +30,6 @@ class StreamEventType(Enum):
DELTA_TOOL_CALL = "delta_tool_call" # Tool call delta (function name, args chunk)
TOOL_CALL_END = "tool_call_end" # Tool call complete (stream end marker)
TOOL_CALL_START = "tool_call_start" # Tool execution starting (complete parsed args)
TOOL_PROGRESS = "tool_progress" # Incremental progress/output while a tool is running
TOOL_CALL_RESULT = "tool_call_result" # Tool execution completed (with result)
LAST_TOKEN = "last_token" # Final content delta
STREAM_END = "stream_end" # Stream completed successfully
@@ -242,11 +241,8 @@ class StreamEventEmitter:
if self._collect_metrics and self._metrics:
self._metrics.update_from_event(event)
# Call all sync callbacks. Iterate over a snapshot so callbacks
# registered/removed concurrently (e.g. a run-progress probe added
# from the event loop while the agent emits from a worker thread)
# cannot corrupt iteration or raise "list changed size".
for callback in list(self._callbacks):
# Call all sync callbacks
for callback in self._callbacks:
try:
callback(event)
except Exception as e:
@@ -263,8 +259,8 @@ class StreamEventEmitter:
if self._collect_metrics and self._metrics:
self._metrics.update_from_event(event)
# Call sync callbacks (snapshot — see emit() for rationale).
for callback in list(self._callbacks):
# Call sync callbacks
for callback in self._callbacks:
try:
callback(event)
except Exception as e:
@@ -272,7 +268,7 @@ class StreamEventEmitter:
logger.warning("Streaming sync callback failed in async context: %s", e, exc_info=True)
# Call async callbacks
for callback in list(self._async_callbacks):
for callback in self._async_callbacks:
try:
await callback(event)
except Exception as e:
@@ -318,76 +314,3 @@ def create_metrics_callback(metrics: StreamMetrics) -> StreamCallback:
metrics.update_from_event(event)
return updater
# ---------------------------------------------------------------------------
# Tool-progress channel
#
# A thread-local channel that lets a running tool push incremental progress
# (stdout/stderr lines, percentage, status text) while it executes, without
# having to change the tool's signature. The agent's tool-execution loop
# activates a sink around each tool call; the tool calls ``emit_tool_progress``
# to surface partial output. Zero overhead when no sink is active.
# ---------------------------------------------------------------------------
import contextlib as _contextlib
import contextvars as _contextvars
_tool_progress_sink: "_contextvars.ContextVar[Optional[StreamCallback]]" = (
_contextvars.ContextVar("praisonai_tool_progress_sink", default=None)
)
def emit_tool_progress(
output: Optional[str] = None,
*,
progress: Optional[float] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> bool:
"""Emit incremental progress from inside a running tool.
Tools (or wrappers around long-running work) call this to surface partial
output while they execute. If no sink is active (the common case outside an
agent run) this is a cheap no-op that returns ``False``.
Args:
output: Partial text/output chunk (e.g. a line of stdout).
progress: Optional completion fraction in the range 0.0–1.0.
metadata: Optional structured metadata (e.g. ``{"stream": "stderr"}``).
Returns:
``True`` if the progress was forwarded to an active sink, else ``False``.
"""
sink = _tool_progress_sink.get()
if sink is None:
return False
md: Dict[str, Any] = dict(metadata) if metadata else {}
if progress is not None:
md["progress"] = progress
try:
sink(StreamEvent(
type=StreamEventType.TOOL_PROGRESS,
content=output,
metadata=md or None,
))
except Exception as e: # never break tool execution on a progress failure
logger.debug("emit_tool_progress sink failed: %s", e)
return False
return True
@_contextlib.contextmanager
def tool_progress_channel(sink: Optional[StreamCallback]):
"""Activate a progress sink for the duration of a tool call.
The agent's tool-execution loop wraps each tool invocation with this so that
``emit_tool_progress`` calls made by the tool are forwarded to ``sink``.
Passing ``None`` explicitly clears any inherited sink for the duration of the
context, so nested no-callback execution never leaks progress into a parent
channel.
"""
token = _tool_progress_sink.set(sink)
try:
yield
finally:
_tool_progress_sink.reset(token)
@@ -36,12 +36,6 @@ def _get_posthog():
# Cached result to avoid repeated environment variable checks
_TELEMETRY_DISABLED_CACHE = None
# Programmatic override set by enable_telemetry()/disable_telemetry().
# Takes precedence over the env-based default (but NOT over an explicit
# env opt-out), and bypasses the cache so downstream instrumentation gates
# (e.g. instrument_agent/instrument_workflow) see the updated state.
_MONITORING_OVERRIDE = None
def _is_monitoring_disabled() -> bool:
"""
Check if monitoring/telemetry is disabled via environment variables.
@@ -52,21 +46,9 @@ def _is_monitoring_disabled() -> bool:
The legacy disable flags still work for backward compatibility.
This function is cached to avoid repeated environment variable lookups.
A programmatic override via enable_telemetry()/disable_telemetry() takes
precedence over the default, unless telemetry was explicitly disabled
via environment variables.
"""
global _TELEMETRY_DISABLED_CACHE
# Explicit env opt-out always wins, even over a programmatic enable.
if _is_telemetry_explicitly_disabled():
return True
# Programmatic override (set by enable_telemetry/disable_telemetry)
# bypasses the cache so instrumentation gates reflect the new state.
if _MONITORING_OVERRIDE is not None:
return _MONITORING_OVERRIDE
# Return cached result if available
if _TELEMETRY_DISABLED_CACHE is not None:
return _TELEMETRY_DISABLED_CACHE
@@ -93,22 +75,6 @@ def _is_monitoring_disabled() -> bool:
_TELEMETRY_DISABLED_CACHE = not explicitly_enabled
return _TELEMETRY_DISABLED_CACHE
def _is_telemetry_explicitly_disabled() -> bool:
"""
Check if telemetry is explicitly disabled via opt-out environment variables.
Unlike `_is_monitoring_disabled`, this only returns True when the user has
explicitly opted out (e.g. DO_NOT_TRACK). It does NOT treat the
"disabled by default" case as disabled, so programmatic `enable_telemetry()`
can re-enable telemetry unless the user has explicitly opted out.
"""
return any([
os.environ.get('PRAISONAI_PERFORMANCE_DISABLED', '').lower() in ('true', '1', 'yes'),
os.environ.get('PRAISONAI_TELEMETRY_DISABLED', '').lower() in ('true', '1', 'yes'),
os.environ.get('PRAISONAI_DISABLE_TELEMETRY', '').lower() in ('true', '1', 'yes'),
os.environ.get('DO_NOT_TRACK', '').lower() in ('true', '1', 'yes'),
])
class MinimalTelemetry:
"""
Minimal telemetry collector for anonymous usage tracking.
@@ -246,7 +212,7 @@ class MinimalTelemetry:
success: Whether the execution was successful
async_mode: If True, defer PostHog capture to prevent blocking in streaming scenarios
"""
if not self.enabled or self._metrics_lock is None:
if not self.enabled:
return
with self._metrics_lock:
@@ -301,7 +267,7 @@ class MinimalTelemetry:
task_name: Name of the task (not logged, just for counting)
success: Whether the task completed successfully
"""
if not self.enabled or self._metrics_lock is None:
if not self.enabled:
return
with self._metrics_lock:
@@ -330,7 +296,7 @@ class MinimalTelemetry:
success: Whether the tool call was successful
execution_time: Time in seconds the tool took to execute (optional)
"""
if not self.enabled or self._metrics_lock is None:
if not self.enabled:
return
with self._metrics_lock:
@@ -384,7 +350,7 @@ class MinimalTelemetry:
Args:
error_type: Type of error (not the full message)
"""
if not self.enabled or self._metrics_lock is None:
if not self.enabled:
return
with self._metrics_lock:
@@ -437,7 +403,7 @@ class MinimalTelemetry:
Returns:
Dictionary of current metrics
"""
if not self.enabled or self._metrics_lock is None:
if not self.enabled:
return {"enabled": False}
with self._metrics_lock:
@@ -456,7 +422,7 @@ class MinimalTelemetry:
In a real implementation, this would send data to a backend.
"""
if not self.enabled or self._metrics_lock is None:
if not self.enabled:
return
metrics = self.get_metrics()
@@ -504,7 +470,7 @@ class MinimalTelemetry:
Shutdown telemetry and ensure all events are sent.
Forces proper cleanup of background threads to prevent hanging.
"""
if not self.enabled or self._shutdown_lock is None:
if not self.enabled:
return
# Use lock to prevent concurrent shutdown calls
@@ -691,12 +657,8 @@ def get_telemetry() -> MinimalTelemetry:
def disable_telemetry():
"""Programmatically disable telemetry."""
global _telemetry_instance, _MONITORING_OVERRIDE
global _telemetry_instance
with _telemetry_instance_lock:
# Flip the monitoring gate so instrumentation paths that check
# _is_monitoring_disabled() (e.g. instrument_agent/instrument_workflow)
# also stop installing hooks.
_MONITORING_OVERRIDE = True
if _telemetry_instance:
_telemetry_instance.enabled = False
else:
@@ -739,24 +701,13 @@ def force_shutdown_telemetry():
def enable_telemetry():
"""Programmatically enable telemetry (if not disabled by environment)."""
global _telemetry_instance, _MONITORING_OVERRIDE, _TELEMETRY_DISABLED_CACHE
global _telemetry_instance
with _telemetry_instance_lock:
if _is_telemetry_explicitly_disabled():
return
# Flip the monitoring gate so instrumentation paths that check
# _is_monitoring_disabled() (e.g. instrument_agent/instrument_workflow)
# actually install their hooks even in the default (no env var) case.
_MONITORING_OVERRIDE = False
_TELEMETRY_DISABLED_CACHE = False
needs_init = (
_telemetry_instance is None
or getattr(_telemetry_instance, "_metrics_lock", None) is None
)
if needs_init:
_telemetry_instance = MinimalTelemetry(enabled=True)
else:
_telemetry_instance.enabled = True
_telemetry_instance._shutdown_complete = False
if not _is_telemetry_disabled():
if _telemetry_instance:
_telemetry_instance.enabled = True
else:
_telemetry_instance = MinimalTelemetry(enabled=True)
# For backward compatibility with existing code
class TelemetryCollector:
@@ -5,10 +5,7 @@ from typing import Any
# Note: actual lazy loading cache is implemented with _loaded_classes in __getattr__
# Export core tool items for organized imports (lightweight)
from .base import (
BaseTool, ToolResult, ToolValidationError, validate_tool,
multimodal_content, text_part, image_part, file_part,
)
from .base import BaseTool, ToolResult, ToolValidationError, validate_tool
from .decorator import tool, FunctionTool
from .registry import get_registry, register_tool, get_tool, add_tool, has_tool, remove_tool, list_tools, list_available_tools, list_tools_with_allowed_filter, list_tools_with_hermes_filter, ToolRegistry
from .tools import Tools
@@ -189,11 +186,6 @@ TOOL_MAPPINGS = {
'send_message': ('.messaging_tools', None),
'messaging_tools': ('.messaging_tools', None),
# Search Tools (fast, capped content grep + file glob)
'grep': ('.search_tools', None),
'glob': ('.search_tools', None),
'search_tools': ('.search_tools', None),
# AST-Grep Tools (structural code search/rewrite)
'ast_grep_search': ('.ast_grep_tool', None),
'ast_grep_rewrite': ('.ast_grep_tool', None),
@@ -372,7 +364,6 @@ def __getattr__(name: str) -> Any:
'jira_watch_issue', 'jira_watch_project', 'jira_get_issue_info', 'jira_search_issues',
'schedule_add', 'schedule_list', 'schedule_remove',
'send_message',
'grep', 'glob',
'ast_grep_search', 'ast_grep_rewrite', 'ast_grep_scan', 'is_ast_grep_available', 'get_ast_grep_tools',
'store_memory', 'search_memory',
'store_learning', 'search_learning',
@@ -383,7 +374,7 @@ def __getattr__(name: str) -> Any:
'clarify'
]:
return getattr(module, name)
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'github_tools', 'jira_tools', 'schedule_tools', 'messaging_tools', 'search_tools', 'ast_grep_tools', 'email_tools']:
if name in ['file_tools', 'spider_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools', 'github_tools', 'jira_tools', 'schedule_tools', 'messaging_tools', 'ast_grep_tools', 'email_tools']:
return module # Returns the callable module
return getattr(module, name)
else:
@@ -396,7 +387,6 @@ def __getattr__(name: str) -> Any:
__all__ = list(TOOL_MAPPINGS.keys()) + [
'Injected', 'AgentState',
'BaseTool', 'ToolResult', 'ToolValidationError', 'validate_tool',
'multimodal_content', 'text_part', 'image_part', 'file_part',
'tool', 'FunctionTool',
'get_registry', 'register_tool', 'get_tool', 'add_tool', 'has_tool', 'remove_tool',
'list_tools', 'list_available_tools', 'list_tools_with_allowed_filter', 'list_tools_with_hermes_filter', 'ToolRegistry',
@@ -30,120 +30,35 @@ class ToolValidationError(Exception):
class ToolResult:
"""Wrapper for tool execution results.
In addition to the text/JSON ``output`` channel, tools may return a
structured, multimodal ``content`` channel so that images/files produced by
a tool become model-visible message parts on the next turn (e.g. a
screenshot tool, chart renderer, or PDF rasteriser feeding a vision model).
``content`` is an ordered list of content parts. Each part is a dict:
- ``{"type": "text", "text": "..."}``
- ``{"type": "image", "data": <base64-str-or-bytes>, "mime": "image/png",
"name": "shot.png"}``
- ``{"type": "image", "url": "https://... or data:..."}``
- ``{"type": "file", "data": ..., "mime": "application/pdf", "name": ...}``
Plain text/JSON tool returns behave exactly as before; multimodal is purely
additive and opt-in by the tool author.
"""
"""Wrapper for tool execution results."""
def __init__(
self,
output: Any,
success: bool = True,
error: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
content: Optional[List[Dict[str, Any]]] = None
metadata: Optional[Dict[str, Any]] = None
):
self.output = output
self.success = success
self.error = error
self.metadata = metadata or {}
self.content = content
@property
def is_multimodal(self) -> bool:
"""True if this result carries structured (possibly image/file) parts."""
return bool(self.content)
def __str__(self) -> str:
if self.success:
if self.output is not None:
return str(self.output)
# Derive a text summary from structured content if no output set
if self.content:
texts = [p.get("text", "") for p in self.content
if isinstance(p, dict) and p.get("type") == "text"]
if texts:
return "\n".join(t for t in texts if t)
return "[multimodal tool result]"
return str(self.output)
return f"Error: {self.error}"
def __repr__(self) -> str:
return f"ToolResult(success={self.success}, output={self.output!r})"
def to_dict(self) -> Dict[str, Any]:
data = {
return {
"output": self.output,
"success": self.success,
"error": self.error,
"metadata": self.metadata
}
if self.content:
data["content"] = self.content
return data
def multimodal_content(*parts: Dict[str, Any], output: Any = None,
success: bool = True) -> "ToolResult":
"""Convenience factory for building a multimodal ToolResult.
Example::
from praisonaiagents.tools import multimodal_content, image_part, text_part
def screenshot() -> ToolResult:
png_bytes = capture()
return multimodal_content(
text_part("Here is the current screen:"),
image_part(png_bytes, mime="image/png", name="screen.png"),
)
"""
return ToolResult(output=output, success=success, content=list(parts))
def text_part(text: str) -> Dict[str, Any]:
"""Build a text content part."""
return {"type": "text", "text": text}
def image_part(data: Any = None, *, mime: str = "image/png",
name: Optional[str] = None, url: Optional[str] = None) -> Dict[str, Any]:
"""Build an image content part from raw bytes/base64 or a URL/data URI."""
part: Dict[str, Any] = {"type": "image", "mime": mime}
if url is not None:
part["url"] = url
else:
part["data"] = data
if name:
part["name"] = name
return part
def file_part(data: Any = None, *, mime: str = "application/octet-stream",
name: Optional[str] = None, url: Optional[str] = None) -> Dict[str, Any]:
"""Build a generic file content part from raw bytes/base64 or a URL."""
part: Dict[str, Any] = {"type": "file", "mime": mime}
if url is not None:
part["url"] = url
else:
part["data"] = data
if name:
part["name"] = name
return part
class BaseTool(ABC):
@@ -45,8 +45,7 @@ _LSP_LANGUAGE_BY_EXT = {
class EditTools:
"""Tools for file editing and patching operations."""
def __init__(self, workspace=None, post_edit_diagnostics: str = "auto",
post_edit_format: str = "off", formatters: Optional[dict] = None):
def __init__(self, workspace=None, post_edit_diagnostics: str = "auto"):
"""Initialize EditTools with optional workspace containment.
Args:
@@ -63,24 +62,6 @@ class EditTools:
- ``"off"``: never run diagnostics (zero overhead).
Checkers are auto-detected by file type and silently skipped
when unavailable; a missing linter never fails the edit.
post_edit_format: When to run a configured, language-appropriate
formatter on a file after a successful edit/patch, then persist
the formatted result. Mirrors ``post_edit_diagnostics``. One of:
- ``"off"`` (default): never run a formatter (zero overhead;
byte-for-byte identical to the pre-existing behaviour).
- ``"auto"`` / ``"on"``: run a formatter if one is detected for
the file type, otherwise silently skip.
Formatters are auto-detected by file type and silently skipped
when unavailable; a missing or failing formatter never fails the
edit (the edit still succeeds, just unformatted).
formatters: Optional mapping of lowercase file extension (e.g.
``".py"``) to a ``(tool_name, argv_template)`` formatter, where
``argv_template`` is a list whose entries may include the literal
placeholder ``"{path}"`` (replaced with the file path) — or whose
final element is the path when no placeholder is given. Lets a
project pin its own formatter (e.g. ``black`` instead of
``ruff format``). Unspecified extensions fall back to the
built-in defaults.
"""
self._workspace = workspace
self._file_cache = {} # Cache for staleness checking
@@ -90,14 +71,6 @@ class EditTools:
if mode not in ("auto", "on", "off"):
mode = "auto"
self._post_edit_diagnostics = mode
fmt_mode = (post_edit_format or "off")
if isinstance(fmt_mode, str):
fmt_mode = fmt_mode.lower()
if fmt_mode not in ("auto", "on", "off"):
fmt_mode = "off"
self._post_edit_format = fmt_mode
self._formatters = formatters or {}
def _validate_path(self, filepath: str) -> str:
"""Validate and resolve a file path within workspace constraints."""
@@ -450,151 +423,6 @@ class EditTools:
logger.debug("Diagnostics error for %s: %s", display_path, e)
return ""
# ------------------------------------------------------------------
# Post-edit formatting
#
# Symmetric to the post-edit diagnostics hook: after a successful mutation
# we optionally run a configured, language-appropriate formatter on the
# single modified file and persist the formatted result. All imports are
# deferred and any failure is swallowed: a missing or broken formatter must
# never turn a successful edit into a failure (the edit succeeds, just
# unformatted).
# ------------------------------------------------------------------
def _format_command(self, safe_path: str) -> Optional[Tuple[str, List[str]]]:
"""Resolve a (tool_name, argv) formatter for ``safe_path`` by extension.
Honours any caller-supplied ``formatters`` override first, then falls
back to built-in defaults. Returns ``None`` when no formatter is
configured for the file type or the executable is not installed, so the
caller can silently skip. Lazy-imports ``shutil``.
"""
import shutil
ext = os.path.splitext(safe_path)[1].lower()
def _materialise(argv_template: List[str]) -> List[str]:
# Substitute the literal ``{path}`` placeholder where present; if no
# entry references it, append the path so a bare formatter command
# still receives the file to format.
if any("{path}" in part for part in argv_template):
return [part.replace("{path}", safe_path) for part in argv_template]
return list(argv_template) + [safe_path]
def _resolvable(exe: str) -> bool:
# Accept an absolute path, a binary on PATH, or a project-local
# relative command (e.g. ``./node_modules/.bin/prettier``) that
# exists relative to the edited file's directory.
if os.path.isabs(exe) or shutil.which(exe):
return True
if os.sep in exe or (os.altsep and os.altsep in exe):
candidate = os.path.join(os.path.dirname(safe_path), exe)
return os.path.isfile(candidate) and os.access(candidate, os.X_OK)
return False
# Caller override takes precedence and pins the project's formatter.
override = self._formatters.get(ext)
if override:
tool_name, argv_template = override
argv = _materialise(list(argv_template))
if _resolvable(argv[0]):
return tool_name, argv
return None
candidates: List[Tuple[str, List[str]]] = []
if ext in (".py", ".pyi"):
if shutil.which("ruff"):
candidates.append((
"ruff", ["ruff", "format", "--quiet", "--no-cache", safe_path],
))
if shutil.which("black"):
candidates.append(("black", ["black", "--quiet", safe_path]))
elif ext in (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json",
".css", ".scss", ".less", ".html", ".md", ".yaml", ".yml"):
if shutil.which("prettier"):
# Run prettier without discovering workspace config or
# EditorConfig files so we never execute repository-controlled
# JS/TS config code from the edited project.
candidates.append((
"prettier",
["prettier", "--write", "--no-config", "--no-editorconfig",
"--log-level", "warn", safe_path],
))
elif ext == ".go":
if shutil.which("gofmt"):
candidates.append(("gofmt", ["gofmt", "-w", safe_path]))
elif ext == ".rs":
if shutil.which("rustfmt"):
candidates.append(("rustfmt", ["rustfmt", "--quiet", safe_path]))
for tool_name, argv in candidates:
if _resolvable(argv[0]):
return tool_name, argv
return None
def _apply_post_edit_format(self, safe_path: str) -> bool:
"""Run a configured formatter on ``safe_path`` in place.
Does nothing when formatting is disabled or no formatter is available.
Any failure (missing binary, timeout, non-zero exit) is logged at debug
level and swallowed: a formatter must never fail an edit.
Returns ``True`` only when a formatter ran to completion with a zero
exit code (so the on-disk content is trustworthy and the cached hash
should be refreshed). Returns ``False`` when no formatter ran or it
failed, so the caller keeps the known-good post-edit hash and does not
adopt partial/corrupt formatter output as the new baseline.
"""
if self._post_edit_format == "off":
return False
try:
resolved = self._format_command(safe_path)
if resolved is None:
return False
_tool_name, argv = resolved
import subprocess
proc = subprocess.run(
argv,
capture_output=True,
text=True,
timeout=_DIAGNOSTICS_TIMEOUT,
cwd=os.path.dirname(safe_path) or None,
)
if proc.returncode != 0:
output = ((proc.stdout or "") + (proc.stderr or "")).strip()
suffix = f": {self._bound_output(output)}" if output else ""
logger.debug(
"Formatter %s failed for %s with exit code %s%s",
_tool_name, safe_path, proc.returncode, suffix,
)
return False
return True
except Exception as e: # never let formatting break a successful edit
logger.debug("Formatter skipped/failed for %s: %s", safe_path, e)
return False
def _refresh_hash_after_format(self, safe_path: str) -> None:
"""Re-record the on-disk hash after an in-place format.
A formatter may rewrite the file, so the hash recorded right after the
edit can become stale. Re-hash the current on-disk content (BOM
stripped, exactly as ``read_file`` would) so a follow-up edit in the
same session is not falsely flagged as stale. Best-effort: any failure
is swallowed so it cannot break a successful edit.
"""
try:
with open(safe_path, 'rb') as f:
raw_bytes = f.read()
if raw_bytes.startswith(b'\xef\xbb\xbf'):
raw_bytes = raw_bytes[3:]
content = raw_bytes.decode('utf-8')
new_hash = self._compute_content_hash(content)
self._file_cache[safe_path] = new_hash
_file_locks.record_read_hash(safe_path, new_hash)
except Exception as e:
logger.debug("Hash refresh skipped after format for %s: %s", safe_path, e)
# ------------------------------------------------------------------
# Fuzzy matching ladder
#
@@ -880,16 +708,7 @@ class EditTools:
new_hash = self._compute_content_hash(new_content)
self._file_cache[safe_path] = new_hash
_file_locks.record_read_hash(safe_path, new_hash)
# Optionally format the touched file in place, then refresh the
# recorded hash so a follow-up edit is not flagged as stale.
# Only adopt the on-disk content as the new baseline when the
# formatter succeeded; on failure we keep the known-good
# post-edit hash rather than trusting partial output.
if self._post_edit_format != "off":
if self._apply_post_edit_format(safe_path):
self._refresh_hash_after_format(safe_path)
result = f"Success: Made {replacements} replacement(s) in {filepath}\n\nDiff:\n{diff}"
return result + self._run_diagnostics(safe_path, filepath)
@@ -1149,13 +968,6 @@ class EditTools:
applied.append(("write", safe_path, backup))
self._file_cache[safe_path] = content_hash
_file_locks.record_read_hash(safe_path, content_hash)
# Optionally format the touched file in place before
# diagnostics run, refreshing the recorded hash only
# when the formatter succeeded so partial/corrupt output
# is never adopted as the new baseline.
if self._post_edit_format != "off":
if self._apply_post_edit_format(safe_path):
self._refresh_hash_after_format(safe_path)
verb = "Added" if kind == "add" else "Updated"
diagnostics = self._run_diagnostics(safe_path, display)
messages.append(f"{verb} {display}\n{diff}{diagnostics}")
@@ -1367,23 +1179,15 @@ def search_files(directory: str, pattern: str,
return _edit_tools.search_files(directory, pattern, file_pattern)
def create_edit_tools(workspace=None, post_edit_diagnostics: str = "auto",
post_edit_format: str = "off",
formatters: Optional[dict] = None) -> EditTools:
def create_edit_tools(workspace=None, post_edit_diagnostics: str = "auto") -> EditTools:
"""Create EditTools instance with optional workspace containment.
Args:
workspace: Optional Workspace instance for path containment
post_edit_diagnostics: Diagnostics mode ("auto"|"on"|"off"); see
``EditTools.__init__`` for details.
post_edit_format: Formatter mode ("off"|"auto"|"on"); see
``EditTools.__init__`` for details. Defaults to ``"off"`` for
backward compatibility.
formatters: Optional ext -> ``(tool_name, argv_template)`` override map;
see ``EditTools.__init__`` for details.
Returns:
EditTools instance configured with workspace
"""
return EditTools(workspace=workspace, post_edit_diagnostics=post_edit_diagnostics,
post_edit_format=post_edit_format, formatters=formatters)
return EditTools(workspace=workspace, post_edit_diagnostics=post_edit_diagnostics)
@@ -20,39 +20,6 @@ import logging
from ..approval import require_approval
from . import _file_locks
# Default cap on the number of lines returned by a single ``read_file`` call
# when no explicit ``limit`` is given. Keeps an unbounded whole-file read from
# silently blowing the context budget; the model is told how to page for more.
DEFAULT_MAX_LINES = 2000
# Default cap on characters rendered per line so one pathologically long line
# cannot dominate the window.
DEFAULT_MAX_LINE_CHARS = 2000
def _render_with_line_numbers(lines, start_index, max_line_chars):
"""Render ``lines`` with a right-aligned 1-based line-number gutter.
Args:
lines: The window of lines to render.
start_index: 0-based index of the first line within the full file.
max_line_chars: Truncate any single line longer than this.
Returns:
str: Line-numbered content (e.g. `` 42\\tconst x = ...``).
"""
if not lines:
return ""
last_number = start_index + len(lines)
width = max(len(str(last_number)), 6)
rendered = []
for line_index, line in enumerate(lines):
if max_line_chars and max_line_chars > 0 and len(line) > max_line_chars:
line = line[:max_line_chars] + "... (line truncated)"
rendered.append(f"{str(start_index + line_index + 1).rjust(width)}\t{line}")
return "\n".join(rendered)
class FileTools:
"""Tools for file operations including read, write, list, and information."""
@@ -128,34 +95,16 @@ class FileTools:
content = raw_bytes.decode(encoding)
return hashlib.sha256(content.encode('utf-8')).hexdigest()
def read_file(self, filepath: str, encoding: str = 'utf-8',
offset: Optional[int] = None, limit: Optional[int] = None,
line_numbers: bool = True,
max_line_chars: int = DEFAULT_MAX_LINE_CHARS) -> str:
def read_file(self, filepath: str, encoding: str = 'utf-8') -> str:
"""
Read content from a file (coding-grade, windowed + line-numbered).
By default the content is returned with a right-aligned line-number
gutter so a coding agent can reference exact lines for subsequent
edits, and a sensible line cap keeps a whole-file read from blowing the
context budget. Pass ``line_numbers=False`` (with no ``offset``/
``limit``) to get the exact plain-string content as before.
Read content from a file.
Args:
filepath: Path to the file
encoding: File encoding (default: utf-8)
offset: 1-based first line to read (default: start of file)
limit: Maximum number of lines to read (default: up to
``DEFAULT_MAX_LINES`` lines from ``offset``). A negative value
is treated as an invalid bound and yields an empty window.
line_numbers: Prefix each line with its 1-based number (default:
True). Set False to return raw content.
max_line_chars: Truncate any single line longer than this many
characters (default: ``DEFAULT_MAX_LINE_CHARS``)
Returns:
str: Content of the requested window. When truncated, a trailing
hint tells the model how to page for the rest via ``offset``.
str: Content of the file
"""
try:
# Validate path to prevent traversal attacks
@@ -175,48 +124,7 @@ class FileTools:
safe_path, self._content_hash(safe_path, encoding))
except Exception:
pass
# Fast path: preserve the exact legacy shape when the caller asks
# for raw content of the whole file (no window requested).
if not line_numbers and offset is None and limit is None:
return data
lines = data.splitlines()
total = len(lines)
# Clamp the 1-based offset into range; treat <=0 / None as start.
start = (offset - 1) if offset and offset > 0 else 0
if start > total:
start = total
# A negative ``limit`` is an invalid bound; return an empty window
# rather than silently expanding to ``DEFAULT_MAX_LINES``.
if limit is not None and limit < 0:
end = start
elif limit is not None:
end = start + limit
else:
end = start + DEFAULT_MAX_LINES
end = min(end, total)
if line_numbers:
body = _render_with_line_numbers(
lines[start:end], start, max_line_chars)
else:
# Keep each line's trailing newline (keepends=True) so a raw
# windowed read preserves the window's structure for the
# edit/patch tools rather than collapsing separators.
raw_lines = data.splitlines(keepends=True)
body = "".join(raw_lines[start:end])
# Tell the model how to page when the window does not reach EOF.
if end < total:
if body and not body.endswith("\n"):
body += "\n"
body += (
f"... (showing lines {start + 1}-{end} of {total}; "
f"call again with offset={end + 1} for more)"
)
return body
return data
except Exception as e:
error_msg = f"Error reading file {filepath}: {str(e)}"
logging.error(error_msg)
@@ -6,39 +6,19 @@ import os
from typing import Optional
def resolve_path(path: str, root: Optional[str] = None) -> str:
"""Fully resolve *path* against *root* (real, absolute, ``..``-collapsed).
Expands ``~`` and environment variables, joins relative paths onto the
real path of *root* (defaulting to the current working directory) and
resolves symlinks/``..``. This is the single shared resolver used by all
workspace-containment checks so boundary logic can never silently diverge.
"""
base = os.path.realpath(root or os.getcwd())
expanded = os.path.expanduser(os.path.expandvars(path))
candidate = (
expanded if os.path.isabs(expanded) else os.path.join(base, expanded)
)
return os.path.realpath(os.path.normpath(candidate))
def resolve_within_root(path: str, root: Optional[str] = None) -> Optional[str]:
"""Resolve *path* under *root*, returning ``None`` if it escapes the root."""
"""Resolve *path* under *root* and reject traversal outside the root."""
base = os.path.realpath(root or os.getcwd())
resolved = resolve_path(path, base)
expanded = os.path.expanduser(path)
candidate = (
os.path.join(base, expanded)
if not os.path.isabs(expanded)
else expanded
)
resolved = os.path.realpath(os.path.normpath(candidate))
try:
if os.path.commonpath([resolved, base]) != base:
return None
except ValueError:
return None
return resolved
def is_within_root(path: str, root: Optional[str] = None) -> bool:
"""Return ``True`` if *path* resolves to a location inside *root*.
Expands ``~`` and environment variables, resolves symlinks/``..`` and
compares against the real path of *root* (defaulting to the current
working directory). Paths equal to the root itself are considered within.
"""
return resolve_within_root(path, root) is not None
@@ -0,0 +1,259 @@
"""
Rules Tools for PraisonAI Agents.
Provides tools for agents to create, read, and manage rules dynamically.
This enables Agent-Requested rules similar to Cursor's agent-requested rules feature.
Usage:
from praisonaiagents.tools.rules_tools import (
create_rule_tool,
list_rules_tool,
get_rule_tool,
delete_rule_tool
)
agent = Agent(
name="Assistant",
tools=[create_rule_tool, list_rules_tool, get_rule_tool]
)
"""
import logging
from praisonaiagents._logging import get_logger
from typing import Optional
logger = get_logger(__name__)
# Singleton RulesManager instance
_rules_manager = None
def _get_rules_manager():
"""Get or create the RulesManager singleton."""
global _rules_manager
if _rules_manager is None:
from praisonaiagents.memory.rules_manager import RulesManager
_rules_manager = RulesManager()
return _rules_manager
def create_rule_tool(
name: str,
content: str,
description: str = "",
globs: Optional[str] = None,
activation: str = "always",
priority: int = 0,
scope: str = "workspace"
) -> str:
"""
Create a new rule for the AI agent to follow.
This tool allows the agent to create persistent rules that will be
applied to future conversations and tasks.
Args:
name: Rule name (used as filename, no spaces or special chars)
content: The rule content in markdown format
description: Short description of what the rule does
globs: Comma-separated glob patterns for file matching (e.g., "**/*.py,**/*.js")
activation: When to apply the rule:
- "always": Always apply this rule
- "glob": Apply when working with files matching globs
- "manual": Only apply when explicitly mentioned
- "ai_decision": Let AI decide when to apply
priority: Priority (higher = applied first, default 0)
scope: Where to save ("workspace" or "global")
Returns:
Success message with rule details
Example:
create_rule_tool(
name="python-style",
content="- Use type hints for all functions\\n- Follow PEP 8",
description="Python coding standards",
globs="**/*.py",
activation="glob"
)
"""
try:
manager = _get_rules_manager()
# Parse globs
glob_list = None
if globs:
glob_list = [g.strip() for g in globs.split(",") if g.strip()]
# Validate activation
valid_activations = ["always", "glob", "manual", "ai_decision"]
if activation not in valid_activations:
return f"Error: activation must be one of {valid_activations}"
# Validate scope
if scope not in ["workspace", "global"]:
return "Error: scope must be 'workspace' or 'global'"
# Create the rule
rule = manager.create_rule(
name=name,
content=content,
description=description,
globs=glob_list,
activation=activation,
priority=priority,
scope=scope
)
return f"""Successfully created rule '{name}'
Location: {rule.file_path}
Activation: {activation}
Priority: {priority}
Description: {description or 'None'}
Globs: {glob_list or 'None'}
The rule will be applied according to its activation mode."""
except Exception as e:
logger.error(f"Error creating rule: {e}")
return f"Error creating rule: {str(e)}"
def list_rules_tool() -> str:
"""
List all available rules.
Returns a formatted list of all rules with their metadata.
Returns:
Formatted string with rule information
"""
try:
manager = _get_rules_manager()
rules = manager.get_all_rules()
if not rules:
return "No rules found. Use create_rule_tool to create new rules."
lines = ["# Available Rules\n"]
for rule in rules:
scope = "global" if "global:" in (rule.file_path or "") else "workspace"
lines.append(f"## {rule.name}")
lines.append(f"- **Activation**: {rule.activation}")
lines.append(f"- **Priority**: {rule.priority}")
if rule.description:
lines.append(f"- **Description**: {rule.description}")
if rule.globs:
lines.append(f"- **Globs**: {', '.join(rule.globs)}")
lines.append(f"- **Scope**: {scope}")
lines.append("")
return "\n".join(lines)
except Exception as e:
logger.error(f"Error listing rules: {e}")
return f"Error listing rules: {str(e)}"
def get_rule_tool(name: str) -> str:
"""
Get the content of a specific rule.
Args:
name: The name of the rule to retrieve
Returns:
The rule content and metadata
"""
try:
manager = _get_rules_manager()
rule = manager.get_rule(name)
if not rule:
return f"Rule '{name}' not found. Use list_rules_tool to see available rules."
return f"""# Rule: {rule.name}
**Description**: {rule.description or 'None'}
**Activation**: {rule.activation}
**Priority**: {rule.priority}
**Globs**: {', '.join(rule.globs) if rule.globs else 'None'}
**File**: {rule.file_path}
## Content:
{rule.content}
"""
except Exception as e:
logger.error(f"Error getting rule: {e}")
return f"Error getting rule: {str(e)}"
def delete_rule_tool(name: str, scope: Optional[str] = None) -> str:
"""
Delete a rule.
Args:
name: The name of the rule to delete
scope: Optional scope to delete from ("workspace" or "global")
Returns:
Success or error message
"""
try:
manager = _get_rules_manager()
if scope and scope not in ["workspace", "global"]:
return "Error: scope must be 'workspace' or 'global'"
success = manager.delete_rule(name, scope)
if success:
return f"Successfully deleted rule '{name}'"
else:
return f"Rule '{name}' not found or could not be deleted"
except Exception as e:
logger.error(f"Error deleting rule: {e}")
return f"Error deleting rule: {str(e)}"
def get_active_rules_tool(file_path: Optional[str] = None) -> str:
"""
Get all rules that are currently active for a given file or context.
Args:
file_path: Optional file path to check which rules apply
Returns:
Formatted string with active rules
"""
try:
manager = _get_rules_manager()
if file_path:
rules = manager.get_rules_for_file(file_path)
else:
rules = manager.get_active_rules()
if not rules:
return "No active rules for this context."
lines = ["# Active Rules\n"]
for rule in rules:
lines.append(f"## {rule.name}")
if rule.description:
lines.append(f"*{rule.description}*\n")
lines.append(rule.content)
lines.append("")
return "\n".join(lines)
except Exception as e:
logger.error(f"Error getting active rules: {e}")
return f"Error getting active rules: {str(e)}"
# Export all tools
__all__ = [
"create_rule_tool",
"list_rules_tool",
"get_rule_tool",
"delete_rule_tool",
"get_active_rules_tool"
]
@@ -1,383 +0,0 @@
"""Fast, capped code-search built-ins: ``grep`` and ``glob``.
These are the two most common moves a coding/repo-exploration agent makes:
"where is this string/symbol used?" (``grep``) and "which files match this
pattern?" (``glob``). Both are bounded (hard result cap with a "narrow your
query" hint), path-safe (contained to the workspace, no traversal), and
zero-config.
``grep`` prefers ``ripgrep`` (``rg``) when present for speed and falls back to a
pure-Python tree walk so it always works with zero external dependencies.
``glob`` uses :mod:`pathlib`/:mod:`fnmatch` and honours ``.gitignore`` when
available.
Usage:
from praisonaiagents import Agent
from praisonaiagents.tools import grep, glob
agent = Agent(name="coder", tools=[grep, glob])
or directly::
from praisonaiagents.tools import grep, glob
print(grep("deprecated_fn", path="src", glob="*.py"))
print(glob("**/*.py", path="src"))
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import fnmatch
import logging
from pathlib import Path
from typing import List, Optional
from .path_safety import resolve_within_root
logger = logging.getLogger(__name__)
# Default hard cap on returned results to keep output bounded and agent-friendly.
_DEFAULT_MAX_RESULTS = 100
# Directories that are almost never useful to search and are expensive to walk.
_SKIP_DIRS = frozenset({
".git", ".hg", ".svn", "__pycache__", "node_modules", ".venv", "venv",
".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox", "dist", "build",
".eggs", ".idea", ".vscode",
})
def _validate_within_workspace(path: str) -> Optional[str]:
"""Resolve *path* under the current workspace, rejecting traversal.
Returns the resolved absolute path, or ``None`` when *path* escapes the
workspace root (mirrors the containment used by ``file_tools``).
"""
return resolve_within_root(path or ".")
def _truncation_hint(count: int, max_results: int, what: str) -> str:
return (
f"... {count}+ {what} (truncated at {max_results}); "
f"refine the pattern or narrow `path`/`glob`."
)
def _load_gitignore_spec(root: str):
"""Best-effort load of ``.gitignore`` patterns from *root*.
Returns a callable ``matcher(relpath, is_dir) -> bool`` or ``None`` when no
ignore file is present. Uses a small self-contained matcher so no external
dependency is required.
"""
gitignore = os.path.join(root, ".gitignore")
if not os.path.isfile(gitignore):
return None
patterns: List[str] = []
try:
with open(gitignore, "r", encoding="utf-8", errors="ignore") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#"):
continue
patterns.append(line)
except Exception:
return None
if not patterns:
return None
def _matches(relpath: str, is_dir: bool) -> bool:
name = os.path.basename(relpath)
posix = relpath.replace(os.sep, "/")
# Last matching pattern wins so re-includes (``!important.log``) can
# override an earlier broad exclude, mirroring Git's own semantics.
ignored = False
for pat in patterns:
neg = pat.startswith("!")
p = pat[1:] if neg else pat
dir_only = p.endswith("/")
p = p.rstrip("/")
if dir_only and not is_dir:
continue
hit = False
if "/" in p:
candidate = p[1:] if p.startswith("/") else p
if fnmatch.fnmatch(posix, candidate) or fnmatch.fnmatch(posix, candidate + "/*"):
hit = True
else:
# A bare name matches the entry itself or any path segment,
# so ``build/`` also ignores ``build/foo.py``.
if fnmatch.fnmatch(name, p) or any(
fnmatch.fnmatch(seg, p) for seg in posix.split("/")
):
hit = True
if hit:
ignored = not neg
return ignored
return _matches
def _matches_glob(fname: str, rel: str, glob: Optional[str]) -> bool:
"""True when *glob* is empty or matches the filename or workspace-relative path."""
if not glob:
return True
posix = rel.replace(os.sep, "/")
return (
fnmatch.fnmatch(fname, glob)
or fnmatch.fnmatch(rel, glob)
or fnmatch.fnmatch(posix, glob)
)
def _iter_files(root: str, glob: Optional[str], ignore_matcher):
"""Yield files under *root*, skipping noise dirs and ``.gitignore`` matches.
When *root* is a single file it is yielded directly (subject to *glob*), so
callers work identically whether given a directory or a file path.
"""
if os.path.isfile(root):
rel = os.path.basename(root)
if _matches_glob(rel, rel, glob):
yield root
return
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [
d for d in dirnames
if d not in _SKIP_DIRS
and not (ignore_matcher and ignore_matcher(
os.path.relpath(os.path.join(dirpath, d), root), True))
]
for fname in filenames:
full = os.path.join(dirpath, fname)
rel = os.path.relpath(full, root)
if ignore_matcher and ignore_matcher(rel, False):
continue
if not _matches_glob(fname, rel, glob):
continue
yield full
_MAX_PATTERN_LEN = 1000
def _python_grep(pattern, root, glob, case_insensitive, max_results):
"""Pure-Python fallback content search (regex/literal)."""
# Python's backtracking ``re`` engine has no execution-time bound, so cap
# the pattern length to reduce catastrophic-backtracking (ReDoS) exposure
# on this fallback path (ripgrep's RE2-style engine is linear-time).
if len(pattern) > _MAX_PATTERN_LEN:
return f"Error: pattern too long (>{_MAX_PATTERN_LEN} chars); please narrow it."
flags = re.IGNORECASE if case_insensitive else 0
try:
regex = re.compile(pattern, flags)
except re.error as exc:
return f"Error: invalid regex pattern {pattern!r}: {exc}"
ignore_matcher = _load_gitignore_spec(root)
lines: List[str] = []
count = 0
truncated = False
for full in _iter_files(root, glob, ignore_matcher):
try:
with open(full, "r", encoding="utf-8", errors="ignore") as fh:
for lineno, line in enumerate(fh, start=1):
if regex.search(line):
count += 1
if count > max_results:
truncated = True
break
rel = os.path.relpath(full)
lines.append(f"{rel}:{lineno}: {line.rstrip()}")
except (OSError, UnicodeError):
continue
if truncated:
break
if not lines:
return "No matches found."
if truncated:
lines.append(_truncation_hint(max_results, max_results, "matches"))
return "\n".join(lines)
def _relativize(line: str) -> str:
"""Rewrite a ``file:line:match`` entry to use a workspace-relative path."""
head, sep, rest = line.partition(":")
if not sep:
return line
try:
return os.path.relpath(head) + sep + rest
except ValueError:
return line
def _cap_rg_output(stdout: str, max_results: int) -> str:
lines = [_relativize(ln) for ln in stdout.splitlines() if ln]
if not lines:
return "No matches found."
if len(lines) > max_results:
lines = lines[:max_results]
lines.append(_truncation_hint(max_results, max_results, "matches"))
return "\n".join(lines)
def grep(
pattern: str,
path: str = ".",
glob: Optional[str] = None,
case_insensitive: bool = False,
max_results: int = _DEFAULT_MAX_RESULTS,
) -> str:
"""Search file contents for *pattern* (regex/literal), capped and bounded.
Prefers ``ripgrep`` (``rg``) when available for speed; otherwise falls back
to a pure-Python walker so it always works with zero external dependencies.
Args:
pattern: Regex/literal to search for.
path: Directory (or file) to search under. Contained to the workspace.
glob: Optional filename glob to restrict the search (e.g. ``"*.py"``).
case_insensitive: Case-insensitive matching when True.
max_results: Hard cap on the number of matching lines returned.
Returns:
``path:line: matched line`` entries (newline-joined), hard-capped with a
truncation hint when there are more matches. Returns a short message on
no matches or on error.
"""
if not pattern:
return "Error: `pattern` must be a non-empty string."
try:
max_results = int(max_results)
except (TypeError, ValueError):
max_results = _DEFAULT_MAX_RESULTS
if max_results <= 0:
max_results = _DEFAULT_MAX_RESULTS
safe = _validate_within_workspace(path)
if safe is None:
return f"Error: path {path!r} escapes the workspace."
if not os.path.exists(safe):
return f"Error: path not found: {path}"
rg = shutil.which("rg")
if rg:
# NOTE: no ``--max-count`` — that caps *per file*, which diverges from
# the pure-Python fallback's total cap. We cap the total afterwards so
# both paths return identical truncated results.
argv = [rg, "--line-number", "--with-filename", "--color=never"]
if case_insensitive:
argv.append("-i")
if glob:
argv += ["--glob", glob]
argv += ["--", pattern, safe]
try:
proc = subprocess.run(
argv, capture_output=True, text=True, timeout=30, check=False)
# rg exit code 1 == no matches (not an error); >1 == real error.
if proc.returncode not in (0, 1):
logger.debug("ripgrep failed (%s); falling back to python grep: %s",
proc.returncode, proc.stderr.strip())
return _python_grep(pattern, safe, glob, case_insensitive, max_results)
return _cap_rg_output(proc.stdout, max_results)
except (subprocess.SubprocessError, OSError) as exc:
logger.debug("ripgrep invocation error; falling back: %s", exc)
return _python_grep(pattern, safe, glob, case_insensitive, max_results)
def glob(
pattern: str,
path: str = ".",
max_results: int = _DEFAULT_MAX_RESULTS,
) -> str:
"""Return files matching a glob *pattern* under *path*, capped and sorted.
Supports recursive globs (``**/*.py``, ``src/**/test_*.py``). Honours
``.gitignore`` when available and skips common noise directories. Results
are sorted by modification time (newest first) and hard-capped.
Args:
pattern: Glob pattern (e.g. ``"**/*.ts"``).
path: Directory to search under. Contained to the workspace.
max_results: Hard cap on the number of paths returned.
Returns:
Newline-joined relative file paths, hard-capped with a truncation hint
when there are more matches. Returns a short message on no matches or
on error.
"""
if not pattern:
return "Error: `pattern` must be a non-empty string."
try:
max_results = int(max_results)
except (TypeError, ValueError):
max_results = _DEFAULT_MAX_RESULTS
if max_results <= 0:
max_results = _DEFAULT_MAX_RESULTS
safe = _validate_within_workspace(path)
if safe is None:
return f"Error: path {path!r} escapes the workspace."
if not os.path.isdir(safe):
return f"Error: directory not found: {path}"
ignore_matcher = _load_gitignore_spec(safe)
root = Path(safe)
def _ignored(rel: str, parts: List[str]) -> bool:
# Skip noise dirs and any file whose own path OR an ancestor directory
# matches ``.gitignore`` (so directory-only rules like ``build/`` also
# exclude ``build/foo.py``).
if any(part in _SKIP_DIRS for part in parts):
return True
if not ignore_matcher:
return False
if ignore_matcher(rel, False):
return True
ancestor = ""
for part in parts[:-1]:
ancestor = os.path.join(ancestor, part) if ancestor else part
if ignore_matcher(ancestor, True):
return True
return False
try:
matches = []
for p in root.glob(pattern):
if not p.is_file():
continue
rel = os.path.relpath(p, safe)
if _ignored(rel, rel.split(os.sep)):
continue
matches.append(p)
except (OSError, ValueError) as exc:
return f"Error: invalid glob pattern {pattern!r}: {exc}"
if not matches:
return "No files found."
def _mtime(p: Path) -> float:
try:
return p.stat().st_mtime
except OSError:
return 0.0
matches.sort(key=_mtime, reverse=True)
truncated = len(matches) > max_results
shown = matches[:max_results]
lines = [os.path.relpath(p) for p in shown]
if truncated:
lines.append(_truncation_hint(max_results, max_results, "files"))
return "\n".join(lines)
if __name__ == "__main__":
print("grep('def ', path='.', glob='*.py'):")
print(grep("def ", path=".", glob="*.py", max_results=10))
print("\nglob('**/*.py'):")
print(glob("**/*.py", max_results=10))
@@ -18,21 +18,6 @@ import platform
from typing import Dict, List, Optional, Union
from ..approval import require_approval
# Minimum grace period (seconds) always granted for stdout/stderr reader threads
# to flush already-written output after the process exits, even when the command
# consumed nearly all of its timeout budget.
_DRAIN_GRACE_SECONDS = 1.0
class _StreamDrainTimeout(Exception):
"""Raised when the direct child has already exited but a reader thread is
still blocked draining an inherited pipe past the timeout budget.
Distinct from ``subprocess.TimeoutExpired`` because the direct child is gone:
the caller must report a timeout WITHOUT trying to kill an already-exited
process.
"""
class ShellTools:
"""Tools for executing shell commands safely."""
@@ -115,11 +100,9 @@ class ShellTools:
)
try:
# Read stdout/stderr incrementally so a progress channel (when
# active) can surface output live while the command runs. Falls
# back to fully-buffered behaviour when no sink is listening.
stdout, stderr = self._communicate_streaming(process, timeout)
# Wait for process with timeout
stdout, stderr = process.communicate(timeout=timeout)
# Truncate output if too large (use smart format)
if len(stdout) > max_output_size:
tail_size = min(max_output_size // 5, 500)
@@ -136,19 +119,6 @@ class ShellTools:
'execution_time': time.time() - start_time
}
except _StreamDrainTimeout:
# The direct child already exited; only an inherited pipe kept a
# reader blocked. Report a timeout but do NOT kill — the direct
# child's PID is gone (and may have been recycled), so killing it
# would be unsafe and misleading.
return {
'stdout': '',
'stderr': f'Command output streaming timed out after {timeout} seconds',
'exit_code': -1,
'success': False,
'execution_time': timeout
}
except subprocess.TimeoutExpired:
# Kill process on timeout
try:
@@ -161,9 +131,6 @@ class ShellTools:
except ImportError:
# Fallback: kill without psutil
process.kill()
except psutil.NoSuchProcess:
# Process already gone — nothing to kill.
pass
return {
'stdout': '',
@@ -184,117 +151,6 @@ class ShellTools:
'execution_time': 0
}
def _communicate_streaming(self, process, timeout):
"""Read stdout/stderr line-buffered, emitting live progress.
Reads both streams concurrently in background threads so a line on
either stream is surfaced via ``emit_tool_progress`` as soon as it
arrives. Preserves ``subprocess.communicate`` semantics: returns the
full ``(stdout, stderr)`` strings and raises ``TimeoutExpired`` on
timeout. When no progress sink is active, this still buffers the full
output identically to the previous behaviour (the emit call is a cheap
no-op).
"""
import threading
# Capture the active progress sink (set by the agent's tool-execution
# loop via a contextvar) in THIS thread, then emit to it directly from
# the reader threads. Capturing the sink up-front avoids relying on
# contextvar propagation into the spawned threads.
_sink = None
try:
from ..streaming import events as _stream_events
_sink = _stream_events._tool_progress_sink.get()
except Exception: # streaming module unavailable — no streaming
_sink = None
# Stop forwarding progress once the tool has returned (e.g. on timeout),
# so a still-draining reader thread can never emit after the result.
stop_emitting = threading.Event()
def _emit(line, stream_name):
if _sink is None or stop_emitting.is_set():
return
try:
_sink(_stream_events.StreamEvent(
type=_stream_events.StreamEventType.TOOL_PROGRESS,
content=line,
metadata={"stream": stream_name},
))
except Exception as exc: # never break command execution on a progress failure
logging.debug("Tool progress emission failed: %s", exc, exc_info=True)
stdout_chunks: List[str] = []
stderr_chunks: List[str] = []
read_errors: List[BaseException] = []
def _pump(stream, sink_list, stream_name):
if stream is None:
return
try:
for line in iter(stream.readline, ''):
if line == '':
break
sink_list.append(line)
_emit(line, stream_name)
except Exception as exc:
# Surface a failed read (e.g. UnicodeDecodeError) so the caller's
# error path reports it instead of silently returning a partial
# prefix as if the command had succeeded.
read_errors.append(exc)
logging.debug("Error reading %s stream: %s", stream_name, exc, exc_info=True)
finally:
try:
stream.close()
except Exception as exc:
logging.debug("Failed to close %s stream: %s", stream_name, exc, exc_info=True)
t_out = threading.Thread(target=_pump, args=(process.stdout, stdout_chunks, "stdout"), daemon=True)
t_err = threading.Thread(target=_pump, args=(process.stderr, stderr_chunks, "stderr"), daemon=True)
t_out.start()
t_err.start()
deadline = None if timeout is None else time.monotonic() + timeout
try:
process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
# The direct child is still running. Disable any further emission and
# re-raise TimeoutExpired so the caller's timeout path KILLS the
# process before returning — a recorded read error must not short-
# circuit that cleanup (otherwise the still-running child leaks with
# its pipes open).
stop_emitting.set()
raise
# Wait for the readers to drain the pipe buffers before returning,
# preserving subprocess.communicate()'s "all captured output" semantics.
# A bounded join keeps the overall call within the original timeout
# budget: if a background child inherited stdout/stderr and keeps the
# pipe open after the direct child exits, a reader can stay blocked in
# readline(). Rather than hang indefinitely, fall back to the configured
# timeout result once the remaining budget is exhausted. A small minimum
# grace is always allowed so a command that used most of its timeout can
# still flush its already-written output.
for reader in (t_out, t_err):
if deadline is None:
remaining = None
else:
remaining = max(_DRAIN_GRACE_SECONDS, deadline - time.monotonic())
reader.join(timeout=remaining)
if t_out.is_alive() or t_err.is_alive():
# Readers still blocked on a leaked (inherited) pipe past the budget,
# but the DIRECT child has already exited (process.wait() returned).
# Stop emitting and signal a drain-timeout so the caller reports a
# timeout WITHOUT trying to kill an already-exited process.
stop_emitting.set()
raise _StreamDrainTimeout()
if read_errors:
raise read_errors[0]
return "".join(stdout_chunks), "".join(stderr_chunks)
def list_processes(self) -> List[Dict[str, Union[int, str, float]]]:
"""List running processes with their details.
@@ -6,7 +6,6 @@ enabling hierarchical task delegation and multi-agent coordination.
"""
import logging
import threading
from praisonaiagents._logging import get_logger
from typing import Any, Callable, Dict, List, Optional
@@ -18,7 +17,6 @@ def create_subagent_tool(
max_depth: int = 3,
default_llm: Optional[str] = None,
default_permission_mode: Optional[str] = None,
on_job_complete: Optional[Callable[[Any], None]] = None,
) -> Dict[str, Any]:
"""
Create a subagent tool for task delegation.
@@ -32,49 +30,61 @@ def create_subagent_tool(
max_depth: Maximum nesting depth for subagents
default_llm: Default LLM model for subagents (Claude Code parity)
default_permission_mode: Default permission mode for subagents (Claude Code parity)
on_job_complete: Optional callback invoked with the terminal
``JobInfo`` when a ``background=True`` subagent that carries a
``deliver`` target finishes. This is the observable completion
signal a gateway (e.g. ``BotOS``) subscribes to in order to
route the result back to the originating chat without an active
turn. When ``None`` (the default) background jobs remain strictly
pull-only via ``subagent_result`` — byte-for-byte the prior
behaviour.
Returns:
Tool definition dictionary
"""
# Depth is tracked per-thread so that concurrent background subagents do
# not interfere with each other's depth budget. The parent depth is
# captured at call time and passed into ``_run_subagent`` so a background
# worker (running on a different thread) starts from the correct depth.
_depth_state = threading.local()
def _get_depth() -> int:
return getattr(_depth_state, "current_depth", 0)
def _run_subagent(
_current_depth = 0
def spawn_subagent(
task: str,
agent_name: Optional[str],
context: Optional[str],
tools: Optional[List[str]],
effective_llm: Optional[str],
effective_permission_mode: Optional[str],
parent_depth: int = 0,
agent_name: Optional[str] = None,
context: Optional[str] = None,
tools: Optional[List[str]] = None,
llm: Optional[str] = None,
permission_mode: Optional[str] = None,
) -> Dict[str, Any]:
"""Execute the subagent synchronously and return the result dict.
Depth tracking is handled here so that background jobs honour the
same ``max_depth`` scoping as synchronous calls. ``parent_depth`` is
the depth captured by the caller; it is restored on this thread for
the duration of the execution so nested subagents see the correct
value even when running off the main thread.
"""
previous_depth = _get_depth()
Spawn a subagent to handle a specific task.
Args:
task: The task description for the subagent
agent_name: Optional specific agent to use
context: Optional additional context
tools: Optional list of tools to give the subagent
llm: Optional LLM model override (Claude Code parity)
permission_mode: Optional permission mode (Claude Code parity)
Returns:
Result from the subagent execution
"""
nonlocal _current_depth
# Check depth limit
if _current_depth >= max_depth:
return {
"success": False,
"error": f"Maximum subagent depth ({max_depth}) exceeded",
"output": None,
}
# Check allowed agents
if allowed_agents and agent_name and agent_name not in allowed_agents:
return {
"success": False,
"error": f"Agent '{agent_name}' not in allowed list",
"output": None,
}
try:
_depth_state.current_depth = parent_depth + 1
_current_depth += 1
# Resolve LLM (parameter > default > None)
effective_llm = llm or default_llm
# Resolve permission mode (parameter > default > None)
effective_permission_mode = permission_mode or default_permission_mode
# If we have an agent factory, use it
if agent_factory:
subagent = agent_factory(
@@ -82,15 +92,15 @@ def create_subagent_tool(
tools=tools,
llm=effective_llm,
)
# Build prompt with context
prompt = task
if context:
prompt = f"Context: {context}\n\nTask: {task}"
# Execute the subagent
result = subagent.chat(prompt)
return {
"success": True,
"output": result,
@@ -110,6 +120,7 @@ def create_subagent_tool(
"permission_mode": effective_permission_mode,
"note": "No agent factory provided - simulation mode",
}
except Exception as e:
logger.error(f"Subagent execution failed: {e}")
return {
@@ -118,219 +129,8 @@ def create_subagent_tool(
"output": None,
}
finally:
_depth_state.current_depth = previous_depth
def spawn_subagent(
task: str,
agent_name: Optional[str] = None,
context: Optional[str] = None,
tools: Optional[List[str]] = None,
llm: Optional[str] = None,
permission_mode: Optional[str] = None,
background: bool = False,
deliver: str = "",
platform: str = "",
chat_id: str = "",
thread_id: str = "",
session_id: str = "",
) -> Dict[str, Any]:
"""
Spawn a subagent to handle a specific task.
Args:
task: The task description for the subagent
agent_name: Optional specific agent to use
context: Optional additional context
tools: Optional list of tools to give the subagent
llm: Optional LLM model override (Claude Code parity)
permission_mode: Optional permission mode (Claude Code parity)
background: When True, run the subagent on the background job
runner and return immediately with a ``job_id`` instead of
blocking until completion. Defaults to False (synchronous).
deliver: Optional delivery token mirroring ``schedule_add`` —
e.g. ``"origin"``, ``"all"`` or ``"platform:chat_id[:thread_id]"``.
Only meaningful with ``background=True``: it captures where the
result should be routed when the job finishes, so a gateway can
proactively deliver it back to chat with no active turn. Empty
(the default) keeps background jobs pull-only via
``subagent_result`` — byte-for-byte the prior behaviour.
platform: Origin platform (e.g. "telegram") captured for
``deliver="origin"``. Usually supplied by the gateway context.
chat_id: Origin chat/channel id captured for ``deliver="origin"``.
thread_id: Optional origin thread id.
session_id: Optional origin session id to preserve context.
Returns:
Result from the subagent execution. When ``background=True`` a
``{"success": True, "job_id": ..., "status": "running"}`` handle
is returned instead; use ``subagent_result(job_id)`` to collect.
When ``deliver`` is set the handle also echoes ``"deliver"``.
"""
# Check depth limit (per-thread; captured for background workers below)
parent_depth = _get_depth()
if parent_depth >= max_depth:
return {
"success": False,
"error": f"Maximum subagent depth ({max_depth}) exceeded",
"output": None,
}
# Check allowed agents
if allowed_agents and agent_name and agent_name not in allowed_agents:
return {
"success": False,
"error": f"Agent '{agent_name}' not in allowed list",
"output": None,
}
# Resolve LLM (parameter > default > None)
effective_llm = llm or default_llm
# Resolve permission mode (parameter > default > None)
effective_permission_mode = permission_mode or default_permission_mode
if background:
# Enqueue on the existing background job runner. Depth and
# permission/tool scoping are captured in the closure below so
# the backgrounded subagent runs under exactly the same
# constraints as a synchronous one.
from praisonaiagents.background.job_manager import get_job_manager
job_manager = get_job_manager()
def _job() -> Dict[str, Any]:
return _run_subagent(
task=task,
agent_name=agent_name,
context=context,
tools=tools,
effective_llm=effective_llm,
effective_permission_mode=effective_permission_mode,
parent_depth=parent_depth,
)
# Capture origin/delivery context so a gateway can route the
# result back to the originating conversation when the job
# finishes. Only populated when a ``deliver`` target is set;
# otherwise the job stays pull-only (unchanged behaviour).
origin: Dict[str, Any] = {}
job_on_complete = None
if deliver:
origin = {
"deliver": deliver,
"platform": platform,
"chat_id": chat_id,
"thread_id": thread_id,
"session_id": session_id,
"task": task,
}
if on_job_complete is not None:
job_on_complete = on_job_complete
job_id = job_manager.start_job(
_job,
on_complete=job_on_complete,
origin=origin,
)
handle = {
"success": True,
"job_id": job_id,
"status": "running",
"agent_name": agent_name or "subagent",
"task": task,
"llm": effective_llm,
"permission_mode": effective_permission_mode,
}
if deliver:
handle["deliver"] = deliver
return handle
return _run_subagent(
task=task,
agent_name=agent_name,
context=context,
tools=tools,
effective_llm=effective_llm,
effective_permission_mode=effective_permission_mode,
parent_depth=parent_depth,
)
def subagent_result(job_id: str, wait: bool = False) -> Dict[str, Any]:
"""
Fetch the status/result of a backgrounded subagent.
Args:
job_id: The job ID returned by ``spawn_subagent(background=True)``.
wait: When True, block until the job completes before returning.
Returns:
A dict describing the job. While running:
``{"success": True, "job_id": ..., "status": "running"}``.
On completion the subagent's result dict is returned under
``"result"`` along with ``"status": "completed"``.
"""
from praisonaiagents.background.job_manager import (
get_job_manager,
JobStatus,
)
job_manager = get_job_manager()
try:
if wait:
try:
result = job_manager.get_result(job_id)
except KeyError:
return {
"success": False,
"job_id": job_id,
"error": f"Job '{job_id}' not found",
}
except Exception as e:
# The worker failed or was cancelled. Surface the same
# clean failure shape the non-blocking path returns
# instead of re-raising to the caller.
return {
"success": False,
"job_id": job_id,
"status": JobStatus.FAILED.value,
"error": str(e),
}
return {
"success": True,
"job_id": job_id,
"status": JobStatus.COMPLETED.value,
"result": result,
}
info = job_manager.get_job_info(job_id)
except KeyError:
return {
"success": False,
"job_id": job_id,
"error": f"Job '{job_id}' not found",
}
if info.status == JobStatus.COMPLETED:
return {
"success": True,
"job_id": job_id,
"status": info.status.value,
"result": info.result,
}
if info.status == JobStatus.FAILED:
return {
"success": False,
"job_id": job_id,
"status": info.status.value,
"error": info.error,
}
return {
"success": True,
"job_id": job_id,
"status": info.status.value,
}
spawn_subagent._subagent_result = subagent_result # type: ignore[attr-defined]
_current_depth -= 1
return {
"name": "spawn_subagent",
"description": (
@@ -369,43 +169,9 @@ def create_subagent_tool(
"enum": ["default", "accept_edits", "dont_ask", "bypass_permissions", "plan"],
"description": "Optional permission mode for the subagent (default, accept_edits, dont_ask, bypass_permissions, plan)",
},
"background": {
"type": "boolean",
"description": "When true, run the subagent in the background (fire-and-forget) and return a job_id immediately instead of blocking. Use subagent_result to collect the result.",
"default": False,
},
"deliver": {
"type": "string",
"description": "Optional delivery target for a background job's result when it finishes (e.g. 'origin', 'all', 'telegram:12345'). When set, the completed result is proactively delivered back to that chat with no active turn required. Empty means pull-only via subagent_result.",
},
},
"required": ["task"],
},
"result_tool": {
"name": "subagent_result",
"description": (
"Fetch the status or result of a backgrounded subagent "
"launched via spawn_subagent(background=true). Pass the "
"job_id returned by spawn_subagent. Set wait=true to block "
"until the subagent completes."
),
"function": subagent_result,
"parameters": {
"type": "object",
"properties": {
"job_id": {
"type": "string",
"description": "The job ID returned by spawn_subagent(background=true)",
},
"wait": {
"type": "boolean",
"description": "When true, block until the subagent completes before returning",
"default": False,
},
},
"required": ["job_id"],
},
},
}
def create_batch_tool() -> Dict[str, Any]:
@@ -995,40 +995,6 @@ class AgentFlow:
# Non-agent step, reset chain
self._handoff_chain.clear()
def _validate_framework(self) -> None:
"""Validate the workflow's ``framework`` before execution.
The YAML parser may set a ``framework`` attribute on the workflow
(e.g. from ``framework: crewai`` in a workflow YAML). The native
execution engine only supports ``praisonai``, so any other value
must fail fast with a clear, actionable error rather than being
silently ignored.
Backward-compatible: when ``framework`` is unset, empty, or
``"praisonai"`` (case-insensitive), this is a no-op.
"""
framework = getattr(self, 'framework', None)
if framework is None:
return
if str(framework).strip().lower() in ("", "praisonai"):
return
# Include workflow identity (name/file) so included-recipe failures
# can be traced back to the exact YAML that triggered them.
identity_parts = []
name = getattr(self, 'name', None)
if name:
identity_parts.append(f"workflow '{name}'")
file_path = getattr(self, 'file_path', None)
if file_path:
identity_parts.append(f"file '{file_path}'")
identity = f" ({', '.join(identity_parts)})" if identity_parts else ""
raise ValueError(
f"Unsupported workflow framework: '{framework}'{identity}. "
"Workflow YAML execution only supports the native PraisonAI "
"engine (framework: praisonai). Remove the 'framework' field or "
"set it to 'praisonai'."
)
def run(
self,
input: str = "",
@@ -1066,13 +1032,6 @@ class AgentFlow:
# Gap 3c: Clear handoff chain at start of new workflow run
self._handoff_chain.clear()
# Fail fast if a non-praisonai framework is requested via YAML.
# The YAML parser stores `framework` on the workflow, but execution
# always uses the native PraisonAI engine. Silently ignoring a
# non-praisonai value (e.g. framework: crewai) is a correctness bug,
# so raise a clear error instead of producing misleading results.
self._validate_framework()
# Use default LLM if not specified
model = llm or self.llm or "gpt-4o-mini"
logger.debug(f"Workflow using model: {model} (llm={llm}, default_llm={self.llm})")
@@ -3803,11 +3762,6 @@ class WorkflowManager:
"results": []
}
# Fail fast on unsupported frameworks. This path iterates steps
# directly instead of calling Workflow.run(), so re-apply the same
# guard here to avoid silently ignoring framework: <non-praisonai>.
workflow._validate_framework()
# Merge variables
all_variables = {**workflow.variables}
if variables:
@@ -4187,11 +4141,6 @@ class WorkflowManager:
"results": []
}
# Fail fast on unsupported frameworks. This path iterates steps
# directly instead of calling Workflow.run(), so re-apply the same
# guard here to avoid silently ignoring framework: <non-praisonai>.
workflow._validate_framework()
# Merge variables
all_variables = {**workflow.variables}
if variables:
Loaded 100 of 825 files, more files were not shown because too many files have changed in this diff. Show more