mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 17:31:00 -10:00
Compare commits
271
Commits
No files matched your search
@@ -227,9 +227,10 @@ runs:
|
||||
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
|
||||
3. Tools (PraisonAI-Tools): Specific integrations, external tools (e.g. SurrealDB, Slack), and community extensions MUST go to the `MervinPraison/PraisonAI-Tools` repository. If the feature is a tool, clone `https://github.com/MervinPraison/PraisonAI-Tools`, implement it there, and create the PR using `gh pr create -R MervinPraison/PraisonAI-Tools`.
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
|
||||
- Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
|
||||
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead. PRs with >100 lines added to agent.py require manual review (merge gate will not auto-merge).
|
||||
- Is there a name collision with existing exports in __init__.py?
|
||||
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
|
||||
|
||||
@@ -260,16 +261,24 @@ runs:
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
|
||||
GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
INPUT_MODEL: ${{ inputs.model }}
|
||||
INPUT_MAX_TURNS: ${{ inputs.max_turns }}
|
||||
INPUT_ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
|
||||
INPUT_DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
|
||||
INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: inherit
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
|
||||
run: |
|
||||
echo "🚀 Running Claude Code with GitHub context..."
|
||||
|
||||
# Build command arguments
|
||||
CMD_ARGS=("-p" "--verbose" "--output-format" "stream-json")
|
||||
|
||||
if [[ -n "$INPUT_MODEL" ]]; then
|
||||
CMD_ARGS+=("--model" "$INPUT_MODEL")
|
||||
export ANTHROPIC_MODEL="$INPUT_MODEL"
|
||||
fi
|
||||
|
||||
# Add max turns if specified
|
||||
if [[ -n "$INPUT_MAX_TURNS" ]]; then
|
||||
CMD_ARGS+=("--max-turns" "$INPUT_MAX_TURNS")
|
||||
@@ -280,11 +289,36 @@ runs:
|
||||
if [[ -n "$INPUT_ALLOWED_TOOLS" ]]; then
|
||||
TOOLS="$TOOLS,$INPUT_ALLOWED_TOOLS"
|
||||
fi
|
||||
|
||||
# Remove any explicitly disallowed tools from the effective allow-list so
|
||||
# the read-only contract is enforced deterministically (no reliance on CLI
|
||||
# precedence between --allowedTools and --disallowedTools).
|
||||
if [[ -n "$INPUT_DISALLOWED_TOOLS" ]]; then
|
||||
DISALLOWED_CSV=$(echo "$INPUT_DISALLOWED_TOOLS" | tr ',\n' ' ' | xargs)
|
||||
FILTERED=""
|
||||
IFS=',' read -ra TOOL_ARR <<< "$TOOLS"
|
||||
for tool in "${TOOL_ARR[@]}"; do
|
||||
tool=$(echo "$tool" | xargs)
|
||||
[[ -z "$tool" ]] && continue
|
||||
skip="false"
|
||||
for bad in $DISALLOWED_CSV; do
|
||||
if [[ "$tool" == "$bad" ]]; then
|
||||
skip="true"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$skip" == "false" ]]; then
|
||||
FILTERED="${FILTERED:+$FILTERED,}$tool"
|
||||
fi
|
||||
done
|
||||
TOOLS="$FILTERED"
|
||||
fi
|
||||
CMD_ARGS+=("--allowedTools" "$TOOLS")
|
||||
|
||||
# Add disallowed tools
|
||||
if [[ -n "$INPUT_DISALLOWED_TOOLS" ]]; then
|
||||
CMD_ARGS+=("--disallowedTools" "$INPUT_DISALLOWED_TOOLS")
|
||||
DISALLOWED_FLAG=$(echo "$INPUT_DISALLOWED_TOOLS" | tr '\n' ',' | sed 's/,*$//')
|
||||
CMD_ARGS+=("--disallowedTools" "$DISALLOWED_FLAG")
|
||||
fi
|
||||
|
||||
echo "📝 Executing Claude Code with prompt from file..."
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
const chain = require('./bot-pr-review-chain.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
assert('chainKickPosted requires both markers', chain.chainKickPosted([
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
{ user: { login: 'MervinPraison' }, body: '/review' },
|
||||
]));
|
||||
assert('chainKickPosted false with only coderabbit', !chain.chainKickPosted([
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
]));
|
||||
assert('coderabbitKickPosted detects coderabbit', chain.coderabbitKickPosted([
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
]));
|
||||
assert('qodoKickPosted detects qodo', chain.qodoKickPosted([
|
||||
{ user: { login: 'github-actions[bot]' }, body: '/review' },
|
||||
]));
|
||||
assert('ignores coderabbit bot comment', !chain.coderabbitKickPosted([
|
||||
{ user: { login: 'coderabbitai[bot]' }, body: '@coderabbitai review' },
|
||||
]));
|
||||
assert('bot PR author match', chain.isBotOpenedPr({ user: { login: 'praisonai-triage-agent[bot]' } }));
|
||||
assert('generic bot type match', chain.isBotOpenedPr({ user: { login: 'my-bot', type: 'Bot' } }));
|
||||
assert('human author excluded', !chain.isBotOpenedPr({ user: { login: 'MervinPraison', type: 'User' } }));
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Idempotent CodeRabbit/Qodo kick for bot-opened PRs.
|
||||
* @see .github/workflows/auto-pr-comment.yml, claude.yml, bot-pr-recovery.yml
|
||||
*/
|
||||
|
||||
const KICK_AUTHORS = new Set(['MervinPraison', 'github-actions[bot]']);
|
||||
const BOT_PR_AUTHORS = new Set(['praisonai-triage-agent[bot]', 'github-actions[bot]']);
|
||||
|
||||
async function listAllComments(github, owner, repo, issueNumber) {
|
||||
if (typeof github.paginate === 'function') {
|
||||
return github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
const { data } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
function kickAuthored(comment, marker) {
|
||||
return (
|
||||
KICK_AUTHORS.has(comment.user?.login) &&
|
||||
(comment.body || '').includes(marker)
|
||||
);
|
||||
}
|
||||
|
||||
function coderabbitKickPosted(comments) {
|
||||
return comments.some((c) => kickAuthored(c, '@coderabbitai review'));
|
||||
}
|
||||
|
||||
function qodoKickPosted(comments) {
|
||||
return comments.some((c) => kickAuthored(c, '/review'));
|
||||
}
|
||||
|
||||
function chainKickPosted(comments) {
|
||||
return coderabbitKickPosted(comments) && qodoKickPosted(comments);
|
||||
}
|
||||
|
||||
function isBotOpenedPr(pr) {
|
||||
if (BOT_PR_AUTHORS.has(pr.user?.login)) return true;
|
||||
return pr.user?.type === 'Bot';
|
||||
}
|
||||
|
||||
async function kickReviewChain(github, owner, repo, prNumber, core, preFetchedComments = null) {
|
||||
const comments = preFetchedComments || await listAllComments(github, owner, repo, prNumber);
|
||||
const needCoderabbit = !coderabbitKickPosted(comments);
|
||||
const needQodo = !qodoKickPosted(comments);
|
||||
|
||||
if (!needCoderabbit && !needQodo) {
|
||||
core?.info?.(`Review chain already kicked on PR #${prNumber}`);
|
||||
return { kicked: false, reason: 'already_kicked' };
|
||||
}
|
||||
|
||||
if (needCoderabbit) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: '@coderabbitai review',
|
||||
});
|
||||
}
|
||||
if (needQodo) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: '/review',
|
||||
});
|
||||
}
|
||||
core?.info?.(`Kicked review chain for PR #${prNumber} (coderabbit=${needCoderabbit}, qodo=${needQodo})`);
|
||||
return { kicked: true, coderabbit: needCoderabbit, qodo: needQodo };
|
||||
}
|
||||
|
||||
async function findOpenPrForIssue(github, owner, repo, issueNumber) {
|
||||
const prefix = `claude/issue-${issueNumber}-`;
|
||||
const { data: prs } = await github.rest.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
sort: 'created',
|
||||
direction: 'desc',
|
||||
per_page: 30,
|
||||
});
|
||||
return (
|
||||
prs.find(
|
||||
(p) => (p.head?.ref || '').startsWith(prefix) && isBotOpenedPr(p)
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
async function kickReviewChainForIssue(github, owner, repo, issueNumber, core) {
|
||||
const pr = await findOpenPrForIssue(github, owner, repo, issueNumber);
|
||||
if (!pr) {
|
||||
core?.info?.(`No open PR for issue #${issueNumber}, skipping review kick`);
|
||||
return { kicked: false, reason: 'no_pr' };
|
||||
}
|
||||
const result = await kickReviewChain(github, owner, repo, pr.number, core);
|
||||
return { ...result, prNumber: pr.number };
|
||||
}
|
||||
|
||||
async function recoverStalledBotPrs(github, owner, repo, options, core) {
|
||||
const { prNumber = null, minAgeMs = 10 * 60 * 1000, maxRecover = 10 } = options || {};
|
||||
let prs;
|
||||
if (prNumber) {
|
||||
const { data } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
prs = [data];
|
||||
} else if (typeof github.paginate === 'function') {
|
||||
prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
} else {
|
||||
const { data } = await github.rest.pulls.list({ owner, repo, state: 'open', per_page: 100 });
|
||||
prs = data;
|
||||
}
|
||||
|
||||
const cutoff = Date.now() - minAgeMs;
|
||||
let recovered = 0;
|
||||
for (const pr of prs) {
|
||||
if (recovered >= maxRecover) break;
|
||||
if (!isBotOpenedPr(pr)) continue;
|
||||
if (!prNumber && new Date(pr.created_at).getTime() > cutoff) continue;
|
||||
const comments = await listAllComments(github, owner, repo, pr.number);
|
||||
if (chainKickPosted(comments)) continue;
|
||||
await kickReviewChain(github, owner, repo, pr.number, core, comments);
|
||||
recovered += 1;
|
||||
}
|
||||
core?.info?.(`Recovery complete (${recovered} PR(s) kicked)`);
|
||||
return recovered;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KICK_AUTHORS,
|
||||
BOT_PR_AUTHORS,
|
||||
listAllComments,
|
||||
chainKickPosted,
|
||||
coderabbitKickPosted,
|
||||
qodoKickPosted,
|
||||
isBotOpenedPr,
|
||||
kickReviewChain,
|
||||
findOpenPrForIssue,
|
||||
kickReviewChainForIssue,
|
||||
recoverStalledBotPrs,
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Local self-test for merge-gate.js heuristics (no GitHub API).
|
||||
* Run: node .github/scripts/merge-gate-selftest.js
|
||||
*/
|
||||
const mg = require('./merge-gate.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
// Stale FINAL: push after FINAL, no @claude since head
|
||||
const finals = [
|
||||
{ user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T08:00:00Z' },
|
||||
];
|
||||
assert('stale when head after final', mg.isStaleFinalAfterPush(finals, '2026-06-12T09:00:00Z'));
|
||||
|
||||
const withRecovery = [
|
||||
...finals,
|
||||
{ user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T09:30:00Z' },
|
||||
];
|
||||
assert('not stale when @claude after head', !mg.isStaleFinalAfterPush(withRecovery, '2026-06-12T09:00:00Z'));
|
||||
|
||||
// Bot CHANGES_REQUESTED then APPROVE
|
||||
const reviews = [
|
||||
{ user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T08:00:00Z' },
|
||||
{ user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'APPROVED', submitted_at: '2026-06-12T09:00:00Z' },
|
||||
];
|
||||
assert('bot approve clears CR', !mg.hasAnyChangesRequested(reviews));
|
||||
assert('human CR blocks', mg.hasAnyChangesRequested([
|
||||
{ user: { login: 'MervinPraison', type: 'User' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T09:00:00Z' },
|
||||
]));
|
||||
|
||||
// Verdict after HEAD
|
||||
const verdictComments = [
|
||||
{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T08:00:00Z' },
|
||||
];
|
||||
assert('verdict before head rejected', mg.findMergeGateVerdict(verdictComments, null, '2026-06-12T09:00:00Z') === null);
|
||||
assert('verdict after head accepted', mg.findMergeGateVerdict(
|
||||
[{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T10:00:00Z' }],
|
||||
null,
|
||||
'2026-06-12T09:00:00Z'
|
||||
) === 'APPROVE');
|
||||
|
||||
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));
|
||||
|
||||
// Sensitive + secrets
|
||||
assert('workflow path sensitive', mg.sensitivePathReasons([{ filename: '.github/workflows/foo.yml' }]).length === 1);
|
||||
assert('ci-only label exempts workflows', mg.sensitivePathReasons(
|
||||
[{ filename: '.github/workflows/foo.yml' }],
|
||||
[mg.WORKFLOW_ONLY_LABEL]
|
||||
).length === 0);
|
||||
assert('ci-only label not exempt mixed changes', mg.sensitivePathReasons(
|
||||
[{ filename: '.github/workflows/foo.yml' }, { filename: 'src/foo.py' }],
|
||||
[mg.WORKFLOW_ONLY_LABEL]
|
||||
).length === 1);
|
||||
assert('secret in patch', mg.secretScanReasons([{ filename: 'x.py', patch: '+key = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"' }]).length === 1);
|
||||
|
||||
// Claude run scoping — other PR branches must not block
|
||||
assert('other branch claude does not block', !mg.hasBlockingClaudeRunForPr(
|
||||
[{ event: 'issue_comment', head_branch: 'other-branch' }],
|
||||
'my-branch'
|
||||
));
|
||||
assert('same branch claude blocks', mg.hasBlockingClaudeRunForPr(
|
||||
[{ event: 'issue_comment', head_branch: 'my-branch' }],
|
||||
'my-branch'
|
||||
));
|
||||
assert('issues event never blocks', !mg.hasBlockingClaudeRunForPr(
|
||||
[{ event: 'issues', head_branch: 'my-branch' }],
|
||||
'my-branch'
|
||||
));
|
||||
|
||||
// Conflict rebase clears after bot completion + FINAL on HEAD
|
||||
const conflictTrigger = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude this PR has merge conflicts with main. Please rebase',
|
||||
created_at: '2026-06-26T10:30:38Z',
|
||||
};
|
||||
const rebaseDone = {
|
||||
user: { login: 'praisonai-triage-agent[bot]' },
|
||||
body: 'Rebase complete — PR #2308 onto latest main',
|
||||
created_at: '2026-06-26T10:30:55Z',
|
||||
};
|
||||
const finalAfterRebase = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude You are the FINAL architecture reviewer.',
|
||||
created_at: '2026-06-26T10:39:43Z',
|
||||
};
|
||||
const headAfterRebase = '2026-06-26T10:39:25Z';
|
||||
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase];
|
||||
assert('conflict blocks before rebase done', mg.hasRecentConflictComment([conflictTrigger], headAfterRebase));
|
||||
assert('conflict clears after rebase + FINAL on HEAD', !mg.hasRecentConflictComment(rebaseComments, headAfterRebase));
|
||||
assert('conflict still blocks without FINAL on HEAD', mg.hasRecentConflictComment(
|
||||
[conflictTrigger, rebaseDone],
|
||||
headAfterRebase
|
||||
));
|
||||
|
||||
// Tests heuristic
|
||||
assert('sdk without tests', mg.missingTestsReason([{ filename: 'src/praisonai-agents/a/b.py', additions: 3 }]) !== null);
|
||||
assert('sdk with tests ok', mg.missingTestsReason([
|
||||
{ filename: 'src/praisonai-agents/a/b.py', additions: 3 },
|
||||
{ filename: 'src/praisonai-agents/tests/test_x.py', additions: 10 },
|
||||
]) === null);
|
||||
|
||||
// PR size
|
||||
assert('large PR blocked', mg.prSizeReasons([{ additions: 900 }]).length > 0);
|
||||
|
||||
assert('internal PR link accepted', mg.isInternalPullRequestLink(
|
||||
{ base: { repo: { full_name: 'MervinPraison/PraisonAI' } } },
|
||||
'MervinPraison',
|
||||
'PraisonAI'
|
||||
));
|
||||
assert('fork sync PR link rejected', !mg.isInternalPullRequestLink(
|
||||
{ number: 21, base: { repo: { full_name: 'Milkmange/PraisonAI' } } },
|
||||
'MervinPraison',
|
||||
'PraisonAI'
|
||||
));
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,747 @@
|
||||
/**
|
||||
* Shared merge-gate helpers for claude-merge-gate.yml
|
||||
* @see .github/workflows/claude-merge-gate.yml
|
||||
*/
|
||||
|
||||
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 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;
|
||||
const PR_MAX_AUTO_FILES = 30;
|
||||
const MANUAL_ONLY_LABELS = new Set(['security', 'breaking-change', 'needs-manual-review', 'release']);
|
||||
const WORKFLOW_ONLY_LABEL = 'merge-gate-ci-only';
|
||||
const CI_ONLY_PATH_PREFIXES = ['.github/workflows/', '.github/actions/', '.github/scripts/merge-gate'];
|
||||
const SDK_PATH_PREFIXES = ['src/praisonai-agents/', 'src/praisonai/'];
|
||||
const SENSITIVE_PATH_PATTERNS = [
|
||||
/^\.github\/workflows\//,
|
||||
/praisonaiagents\/(auth|approval|policy|sandbox)\//,
|
||||
/^src\/praisonai-agents\/pyproject\.toml$/,
|
||||
/^src\/praisonai\/pyproject\.toml$/,
|
||||
/\.env(\.|$)/,
|
||||
/credentials\.json$/i,
|
||||
];
|
||||
const REQUIRED_SDK_CHECK_PATTERNS = [/test/i, /smoke/i, /core/i, /python package/i, /comprehensive/i, /optimized/i];
|
||||
const SECRET_PATTERNS = [
|
||||
/sk-[a-zA-Z0-9]{20,}/,
|
||||
/AKIA[0-9A-Z]{16}/,
|
||||
/ghp_[a-zA-Z0-9]{36,}/,
|
||||
/github_pat_/,
|
||||
/Bearer eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/,
|
||||
/-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/,
|
||||
];
|
||||
const BLOCK_LABELS = new Set([
|
||||
'claude-conflict-pending',
|
||||
'claude-merge-gate-active',
|
||||
'no-auto-merge',
|
||||
'auto-merged-by-gate',
|
||||
]);
|
||||
const BOT_REVIEWER_PATTERNS = [
|
||||
'coderabbit',
|
||||
'qodo',
|
||||
'gemini',
|
||||
'copilot',
|
||||
'greptile',
|
||||
];
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function isFinalClaudeTriggerComment(c) {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!AUTO_ACTORS.includes(c.user.login)) return false;
|
||||
if (!body.includes('@claude')) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
}
|
||||
|
||||
function isClaudeTriggerNoise(c) {
|
||||
const body = c.body || '';
|
||||
if (body.includes('Merge gate scan')) return true;
|
||||
if (body.includes('MERGE_GATE_VERDICT')) return true;
|
||||
if (body.includes('Merged by **Claude PR merge gate**')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasRecentClaudeTrigger(comments, minutes = 35) {
|
||||
const cutoff = Date.now() - minutes * 60 * 1000;
|
||||
return comments.some((c) => {
|
||||
if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false;
|
||||
if (isClaudeTriggerNoise(c)) return false;
|
||||
if (!(c.body || '').includes('@claude')) return false;
|
||||
return new Date(c.created_at).getTime() > cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
function isConflictRebaseTriggerComment(c) {
|
||||
if (!AUTO_ACTORS.includes(c.user.login)) return false;
|
||||
const body = (c.body || '').toLowerCase();
|
||||
return body.includes('@claude') && body.includes('merge conflict');
|
||||
}
|
||||
|
||||
function isConflictRebaseCompletionComment(c) {
|
||||
const login = (c.user?.login || '').toLowerCase();
|
||||
if (!login.includes('praisonai-triage') && !login.includes('github-actions')) return false;
|
||||
const body = (c.body || '').toLowerCase();
|
||||
return (
|
||||
body.includes('rebase complete') ||
|
||||
body.includes('rebase onto') ||
|
||||
(body.includes('conflict') && body.includes('resolved'))
|
||||
);
|
||||
}
|
||||
|
||||
function conflictRebaseQuiescent(comments, headPushedAt) {
|
||||
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
|
||||
if (conflictTriggers.length === 0) return true;
|
||||
|
||||
const latestConflict = conflictTriggers.reduce((a, b) =>
|
||||
new Date(a.created_at) > new Date(b.created_at) ? a : b
|
||||
);
|
||||
const conflictTime = new Date(latestConflict.created_at).getTime();
|
||||
|
||||
const rebaseDone = comments.some(
|
||||
(c) =>
|
||||
new Date(c.created_at).getTime() > conflictTime && isConflictRebaseCompletionComment(c)
|
||||
);
|
||||
if (!rebaseDone) return false;
|
||||
|
||||
return finalClaudeCompletedOnSha(comments, headPushedAt);
|
||||
}
|
||||
|
||||
function hasRecentConflictComment(comments, headPushedAt = null) {
|
||||
const cutoff = Date.now() - CONFLICT_COOLDOWN_MS;
|
||||
const hasRecentTrigger = comments.some((c) => {
|
||||
if (!isConflictRebaseTriggerComment(c)) return false;
|
||||
return new Date(c.created_at).getTime() > cutoff;
|
||||
});
|
||||
if (!hasRecentTrigger) return false;
|
||||
|
||||
if (headPushedAt && conflictRebaseQuiescent(comments, headPushedAt)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isBotReviewer(login, userType) {
|
||||
const lower = (login || '').toLowerCase();
|
||||
if (userType === 'Bot') return true;
|
||||
if (lower.endsWith('[bot]')) return true;
|
||||
return ['coderabbit', 'qodo', 'gemini', 'copilot', 'greptile'].some((p) => lower.includes(p));
|
||||
}
|
||||
|
||||
function latestReviewsByUser(reviews) {
|
||||
const latestByUser = new Map();
|
||||
for (const r of reviews) {
|
||||
const login = r.user?.login;
|
||||
if (!login) continue;
|
||||
const prev = latestByUser.get(login);
|
||||
if (!prev || new Date(r.submitted_at) > new Date(prev.submitted_at)) {
|
||||
latestByUser.set(login, r);
|
||||
}
|
||||
}
|
||||
return latestByUser;
|
||||
}
|
||||
|
||||
function hasHumanChangesRequested(reviews) {
|
||||
for (const [login, review] of latestReviewsByUser(reviews)) {
|
||||
if (review.state !== 'CHANGES_REQUESTED') continue;
|
||||
if (!isBotReviewer(login, review.user?.type)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasAnyChangesRequested(reviews) {
|
||||
for (const [, review] of latestReviewsByUser(reviews)) {
|
||||
if (review.state === 'CHANGES_REQUESTED') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasFinalClaudeReviewTrigger(comments) {
|
||||
return comments.some(isFinalClaudeTriggerComment);
|
||||
}
|
||||
|
||||
function isStaleFinalAfterPush(comments, headPushedAt) {
|
||||
if (!headPushedAt) return false;
|
||||
const headTime = new Date(headPushedAt).getTime();
|
||||
const finals = comments.filter(isFinalClaudeTriggerComment);
|
||||
if (finals.length === 0) return true;
|
||||
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();
|
||||
if (headTime <= finalTime + 60000) return false;
|
||||
const claudeSinceHead = comments.some((c) => {
|
||||
if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false;
|
||||
if (isClaudeTriggerNoise(c)) return false;
|
||||
if (!(c.body || '').includes('@claude')) return false;
|
||||
return new Date(c.created_at).getTime() >= headTime - 60000;
|
||||
});
|
||||
return !claudeSinceHead;
|
||||
}
|
||||
|
||||
function needsStaleFinalRecovery(comments, headPushedAt) {
|
||||
return (
|
||||
hasFinalClaudeReviewTrigger(comments) &&
|
||||
isStaleFinalAfterPush(comments, headPushedAt)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldSkipFinalRecovery(comments, headPushedAt) {
|
||||
const isStale = isStaleFinalAfterPush(comments, headPushedAt);
|
||||
if (isStale) return false;
|
||||
return hasRecentClaudeTrigger(comments, 35);
|
||||
}
|
||||
|
||||
function finalClaudeCompletedOnSha(comments, headPushedAt) {
|
||||
if (!hasFinalClaudeReviewTrigger(comments)) return false;
|
||||
if (isStaleFinalAfterPush(comments, headPushedAt)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const FINAL_CLAUDE_REVIEW_BODY =
|
||||
'@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items';
|
||||
|
||||
async function getMergeState(github, owner, repo, prNumber) {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
mergeStateStatus
|
||||
maintainerCanModify
|
||||
isDraft
|
||||
headRefOid
|
||||
headRef { repository { nameWithOwner } }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const result = await github.graphql(query, { owner, repo, number: prNumber });
|
||||
const prGql = result.repository.pullRequest;
|
||||
const status = (prGql?.mergeStateStatus || '').toUpperCase();
|
||||
if (status && status !== 'UNKNOWN') {
|
||||
return {
|
||||
status,
|
||||
isDraft: prGql.isDraft,
|
||||
headRepo: prGql.headRef?.repository?.nameWithOwner,
|
||||
headSha: prGql.headRefOid,
|
||||
maintainerCanModify: prGql.maintainerCanModify === true,
|
||||
};
|
||||
}
|
||||
if (attempt < 2) await sleep(10000);
|
||||
}
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
return {
|
||||
status: (pr.mergeable_state || '').toUpperCase(),
|
||||
isDraft: pr.draft,
|
||||
headRepo: pr.head.repo?.full_name,
|
||||
headSha: pr.head.sha,
|
||||
maintainerCanModify: pr.maintainer_can_modify === true,
|
||||
};
|
||||
}
|
||||
|
||||
async function allChecksGreenOnSha(github, owner, repo, sha, core) {
|
||||
const { data } = await github.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
const runs = (data.check_runs || []).filter((r) => r.head_sha === sha);
|
||||
if (runs.length === 0) {
|
||||
core?.info?.(`No check runs on ${sha.slice(0, 7)} — allowing (e.g. docs-only PR)`);
|
||||
return true;
|
||||
}
|
||||
for (const run of runs) {
|
||||
if (run.status !== 'completed') {
|
||||
core?.info?.(`Check pending: ${run.name} (${run.status})`);
|
||||
return false;
|
||||
}
|
||||
const ok = ['success', 'neutral', 'skipped'].includes(run.conclusion);
|
||||
if (!ok) {
|
||||
core?.info?.(`Check failed: ${run.name} (${run.conclusion})`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function claudeRunBlocksPr(run, headRef) {
|
||||
if (!run || run.event === 'issues') return false;
|
||||
if (!headRef) return true;
|
||||
return run.head_branch === headRef;
|
||||
}
|
||||
|
||||
function hasBlockingClaudeRunForPr(runs, headRef) {
|
||||
return (runs || []).some((r) => claudeRunBlocksPr(r, headRef));
|
||||
}
|
||||
|
||||
async function hasInProgressClaudeAssistant(github, owner, repo, prNumber = null) {
|
||||
try {
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: 'claude.yml',
|
||||
status: 'in_progress',
|
||||
per_page: 20,
|
||||
});
|
||||
const runs = (data.workflow_runs || []).filter((r) => r.event !== 'issues');
|
||||
if (runs.length === 0) return false;
|
||||
if (prNumber == null) return runs.length > 0;
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
const headRef = pr.head.ref;
|
||||
return hasBlockingClaudeRunForPr(runs, headRef);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isCiOnlyChange(files) {
|
||||
if (!files.length) return false;
|
||||
return files.every((f) => CI_ONLY_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
|
||||
}
|
||||
|
||||
function isInternalPullRequestLink(link, owner, repo) {
|
||||
if (link?.base?.repo?.full_name === `${owner}/${repo}`) return true;
|
||||
const baseUrl = link?.base?.repo?.url || '';
|
||||
return baseUrl.endsWith(`/repos/${owner}/${repo}`);
|
||||
}
|
||||
|
||||
async function resolvePrNumberFromLinkedPullRequests(github, owner, repo, linked) {
|
||||
const repoFull = `${owner}/${repo}`;
|
||||
const internal = (linked || []).filter((l) => isInternalPullRequestLink(l, owner, repo));
|
||||
for (const link of internal) {
|
||||
if (!link.number) continue;
|
||||
try {
|
||||
const { data } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: link.number,
|
||||
});
|
||||
if (data.state === 'open' && data.base?.repo?.full_name === repoFull) {
|
||||
return data.number;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.status !== 404) throw err;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolvePrNumberFromHeadBranch(github, owner, repo, branch) {
|
||||
if (!branch || branch === 'main' || branch === 'master') return null;
|
||||
const { data } = await github.rest.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
head: `${owner}:${branch}`,
|
||||
per_page: 1,
|
||||
});
|
||||
return data[0]?.number || null;
|
||||
}
|
||||
|
||||
async function resolvePrNumberFromWorkflowRun(github, owner, repo, workflowRun) {
|
||||
const fromLinked = await resolvePrNumberFromLinkedPullRequests(
|
||||
github, owner, repo, workflowRun.pull_requests
|
||||
);
|
||||
if (fromLinked) return fromLinked;
|
||||
|
||||
const fromBranch = await resolvePrNumberFromHeadBranch(
|
||||
github, owner, repo, workflowRun.head_branch
|
||||
);
|
||||
if (fromBranch) return fromBranch;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listPullFiles(github, owner, repo, prNumber) {
|
||||
if (typeof github.paginate === 'function') {
|
||||
return github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
const { data } = await github.rest.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
function countNewAgentParams(patch) {
|
||||
if (!patch) return 0;
|
||||
return patch
|
||||
.split('\n')
|
||||
.filter((l) => l.startsWith('+') && !l.startsWith('+++'))
|
||||
.filter((l) => {
|
||||
const t = l.slice(1).trim();
|
||||
if (!t || t.startsWith('#') || t.startsWith('"""') || t.startsWith("'''")) return false;
|
||||
if (/^(def|class|@|return\b|if\b|elif\b|else\b|for\b|while\b)/.test(t)) return false;
|
||||
if (/^\w+\s*[:=]/.test(t) && !t.startsWith('self.')) return true;
|
||||
return false;
|
||||
}).length;
|
||||
}
|
||||
|
||||
function getAgentPyChangeFromFiles(files) {
|
||||
const agentFile = files.find((f) => f.filename.endsWith('praisonaiagents/agent/agent.py'));
|
||||
if (!agentFile) {
|
||||
return { touched: false, additions: 0, newParams: 0 };
|
||||
}
|
||||
return {
|
||||
touched: true,
|
||||
additions: agentFile.additions || 0,
|
||||
newParams: countNewAgentParams(agentFile.patch || ''),
|
||||
};
|
||||
}
|
||||
|
||||
async function getAgentPyChange(github, owner, repo, prNumber) {
|
||||
const files = await listPullFiles(github, owner, repo, prNumber);
|
||||
return getAgentPyChangeFromFiles(files);
|
||||
}
|
||||
|
||||
function touchesSdk(files) {
|
||||
return files.some((f) => SDK_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
|
||||
}
|
||||
|
||||
function hasManualOnlyLabel(labels) {
|
||||
return labels.some((l) => MANUAL_ONLY_LABELS.has(l));
|
||||
}
|
||||
|
||||
function sensitivePathReasons(files, labels = []) {
|
||||
if (labels.includes(WORKFLOW_ONLY_LABEL) && isCiOnlyChange(files)) {
|
||||
return [];
|
||||
}
|
||||
const reasons = [];
|
||||
for (const f of files) {
|
||||
if (SENSITIVE_PATH_PATTERNS.some((p) => p.test(f.filename))) {
|
||||
reasons.push(`sensitive path: ${f.filename}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function prSizeReasons(files) {
|
||||
const reasons = [];
|
||||
const totalAdditions = files.reduce((sum, f) => sum + (f.additions || 0), 0);
|
||||
if (totalAdditions > PR_MAX_AUTO_ADDITIONS) {
|
||||
reasons.push(`PR +${totalAdditions} lines (>${PR_MAX_AUTO_ADDITIONS}) requires manual review`);
|
||||
}
|
||||
if (files.length > PR_MAX_AUTO_FILES) {
|
||||
reasons.push(`${files.length} files changed (>${PR_MAX_AUTO_FILES}) requires manual review`);
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function missingTestsReason(files) {
|
||||
const sdkAdds = files.filter(
|
||||
(f) =>
|
||||
f.filename.startsWith('src/praisonai-agents/') &&
|
||||
f.filename.endsWith('.py') &&
|
||||
!f.filename.endsWith('__init__.py') &&
|
||||
(f.additions || 0) > 0
|
||||
);
|
||||
if (sdkAdds.length === 0) return null;
|
||||
const hasTestChange = files.some(
|
||||
(f) => /\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename) || /_test\.py$/.test(f.filename)
|
||||
);
|
||||
if (!hasTestChange) return 'SDK code added without test file changes — requires manual review';
|
||||
return null;
|
||||
}
|
||||
|
||||
function secretScanReasons(files) {
|
||||
for (const f of files) {
|
||||
if (/\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename)) continue;
|
||||
const patch = f.patch || '';
|
||||
if (!patch) continue;
|
||||
for (const pattern of SECRET_PATTERNS) {
|
||||
if (pattern.test(patch)) {
|
||||
return [`possible secret in diff (${f.filename}) — requires manual review`];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function sdkTestChecksReason(github, owner, repo, sha, files, core) {
|
||||
if (!touchesSdk(files)) return null;
|
||||
const { data } = await github.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
const runs = (data.check_runs || []).filter((r) => r.head_sha === sha);
|
||||
if (runs.length === 0) {
|
||||
return 'SDK code changed but no CI checks on HEAD';
|
||||
}
|
||||
const testRuns = runs.filter((r) => REQUIRED_SDK_CHECK_PATTERNS.some((p) => p.test(r.name || '')));
|
||||
if (testRuns.length === 0) {
|
||||
return 'SDK code changed but no test check runs on HEAD';
|
||||
}
|
||||
core?.info?.(`SDK test checks on HEAD: ${testRuns.map((r) => r.name).join(', ')}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
function manualReviewReasonForAgentPy(agentChange) {
|
||||
if (!agentChange.touched) return null;
|
||||
if (agentChange.additions > AGENT_PY_MAX_AUTO_LINES) {
|
||||
return `agent.py +${agentChange.additions} lines (>${AGENT_PY_MAX_AUTO_LINES}) requires manual review`;
|
||||
}
|
||||
if (agentChange.newParams > 0) {
|
||||
return `agent.py adds ${agentChange.newParams} new Agent param(s) — requires manual review`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listAllComments(github, owner, repo, issueNumber) {
|
||||
if (typeof github.paginate === 'function') {
|
||||
return github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
const { data } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getHeadCommitDate(github, owner, repo, prNumber) {
|
||||
try {
|
||||
let commits;
|
||||
if (typeof github.paginate === 'function') {
|
||||
commits = await github.paginate(github.rest.pulls.listCommits, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
} else {
|
||||
const { data } = await github.rest.pulls.listCommits({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
commits = data;
|
||||
}
|
||||
const last = commits[commits.length - 1];
|
||||
return last?.commit?.committer?.date || last?.commit?.author?.date || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrContext(github, owner, repo, prNumber) {
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: prNumber });
|
||||
const comments = await listAllComments(github, owner, repo, prNumber);
|
||||
const { data: reviews } = await github.rest.pulls.listReviews({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const mergeState = await getMergeState(github, owner, repo, prNumber);
|
||||
const headSha = mergeState.headSha || pr.head.sha;
|
||||
const headCommitDate = await getHeadCommitDate(github, owner, repo, prNumber);
|
||||
const headPushedAt = headCommitDate || pr.updated_at;
|
||||
|
||||
return {
|
||||
pr,
|
||||
issue,
|
||||
comments,
|
||||
reviews,
|
||||
mergeState,
|
||||
headSha,
|
||||
headPushedAt,
|
||||
labels: issue.labels.map((l) => l.name),
|
||||
baseRepo: `${owner}/${repo}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, options = {}) {
|
||||
const {
|
||||
forMergeStep = false,
|
||||
skipGlobalClaudeRunCheck = false,
|
||||
skipRecentClaudeCooldown = false,
|
||||
} = options;
|
||||
const ctx = await loadPrContext(github, owner, repo, prNumber);
|
||||
const reasons = [];
|
||||
|
||||
if (ctx.pr.draft) reasons.push('draft');
|
||||
if (ctx.pr.state !== 'open') reasons.push('not open');
|
||||
if (ctx.labels.includes('auto-merged-by-gate')) reasons.push('already merged by gate');
|
||||
if (ctx.labels.includes('no-auto-merge')) reasons.push('no-auto-merge label');
|
||||
if (ctx.labels.includes('claude-conflict-pending')) reasons.push('claude-conflict-pending');
|
||||
if (!forMergeStep && ctx.labels.includes('claude-merge-gate-active')) {
|
||||
reasons.push('claude-merge-gate-active');
|
||||
}
|
||||
|
||||
const { status, headRepo, maintainerCanModify } = ctx.mergeState;
|
||||
if (!ALLOWED_MERGE_STATES.has(status)) reasons.push(`mergeState=${status}`);
|
||||
|
||||
if (headRepo && headRepo !== ctx.baseRepo) {
|
||||
reasons.push('fork PR');
|
||||
}
|
||||
|
||||
if (hasRecentConflictComment(ctx.comments, ctx.headPushedAt)) {
|
||||
reasons.push('recent merge-conflict @claude');
|
||||
}
|
||||
if (!skipRecentClaudeCooldown && hasRecentClaudeTrigger(ctx.comments, 35)) {
|
||||
reasons.push('recent @claude within 35min');
|
||||
}
|
||||
|
||||
if (!skipGlobalClaudeRunCheck && (await hasInProgressClaudeAssistant(github, owner, repo, prNumber))) {
|
||||
reasons.push('claude.yml in progress');
|
||||
}
|
||||
|
||||
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)) {
|
||||
reasons.push('no FINAL Claude review trigger');
|
||||
} else if (isStaleFinalAfterPush(ctx.comments, ctx.headPushedAt)) {
|
||||
reasons.push('stale FINAL after new commits (needs @claude re-review)');
|
||||
} else {
|
||||
reasons.push('FINAL Claude not complete on HEAD');
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAnyChangesRequested(ctx.reviews)) reasons.push('CHANGES_REQUESTED review');
|
||||
|
||||
if (hasManualOnlyLabel(ctx.labels)) {
|
||||
const manualLabel = ctx.labels.find((l) => MANUAL_ONLY_LABELS.has(l));
|
||||
reasons.push(`manual-only label: ${manualLabel}`);
|
||||
}
|
||||
|
||||
const pullFiles = await listPullFiles(github, owner, repo, prNumber);
|
||||
|
||||
const agentChange = getAgentPyChangeFromFiles(pullFiles);
|
||||
const agentManual = manualReviewReasonForAgentPy(agentChange);
|
||||
if (agentManual) reasons.push(agentManual);
|
||||
|
||||
reasons.push(...sensitivePathReasons(pullFiles, ctx.labels));
|
||||
reasons.push(...prSizeReasons(pullFiles));
|
||||
reasons.push(...secretScanReasons(pullFiles));
|
||||
|
||||
const testsReason = missingTestsReason(pullFiles);
|
||||
if (testsReason) reasons.push(testsReason);
|
||||
|
||||
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;
|
||||
return {
|
||||
ready,
|
||||
reasons,
|
||||
headSha: ctx.headSha,
|
||||
prNumber,
|
||||
};
|
||||
}
|
||||
|
||||
async function selectMergeGateCandidates(github, owner, repo, prNumbers, maxCandidates, core) {
|
||||
const readyList = [];
|
||||
const skipped = [];
|
||||
for (const num of prNumbers) {
|
||||
const result = await evaluatePipelineQuiescent(github, owner, repo, num, core);
|
||||
if (result.ready) {
|
||||
readyList.push({ pr_number: num, head_sha: result.headSha });
|
||||
} else {
|
||||
skipped.push({ pr: num, reasons: result.reasons });
|
||||
}
|
||||
}
|
||||
readyList.sort((a, b) => a.pr_number - b.pr_number);
|
||||
return {
|
||||
candidates: readyList.slice(0, maxCandidates),
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
function findMergeGateVerdict(comments, minCreatedAt = null, headPushedAt = null) {
|
||||
const minTime = minCreatedAt ? new Date(minCreatedAt).getTime() : 0;
|
||||
const headTime = headPushedAt ? new Date(headPushedAt).getTime() - 60000 : 0;
|
||||
const gateComments = comments
|
||||
.filter((c) => {
|
||||
if (!(c.body || '').includes('MERGE_GATE_VERDICT:')) return false;
|
||||
const created = new Date(c.created_at).getTime();
|
||||
if (minTime && created < minTime) return false;
|
||||
if (headTime && created < headTime) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
if (gateComments.length === 0) return null;
|
||||
const body = gateComments[0].body || '';
|
||||
if (body.includes('MERGE_GATE_VERDICT: APPROVE')) return 'APPROVE';
|
||||
if (body.includes('MERGE_GATE_VERDICT: BLOCK')) return 'BLOCK';
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CLAUDE_TRIGGER_LOGINS,
|
||||
AUTO_ACTORS,
|
||||
isFinalClaudeTriggerComment,
|
||||
isClaudeTriggerNoise,
|
||||
hasRecentClaudeTrigger,
|
||||
hasRecentConflictComment,
|
||||
isConflictRebaseTriggerComment,
|
||||
isConflictRebaseCompletionComment,
|
||||
conflictRebaseQuiescent,
|
||||
hasHumanChangesRequested,
|
||||
hasAnyChangesRequested,
|
||||
hasFinalClaudeReviewTrigger,
|
||||
finalClaudeCompletedOnSha,
|
||||
getMergeState,
|
||||
allChecksGreenOnSha,
|
||||
hasInProgressClaudeAssistant,
|
||||
claudeRunBlocksPr,
|
||||
hasBlockingClaudeRunForPr,
|
||||
isCiOnlyChange,
|
||||
isInternalPullRequestLink,
|
||||
resolvePrNumberFromLinkedPullRequests,
|
||||
resolvePrNumberFromHeadBranch,
|
||||
resolvePrNumberFromWorkflowRun,
|
||||
WORKFLOW_ONLY_LABEL,
|
||||
getAgentPyChange,
|
||||
getAgentPyChangeFromFiles,
|
||||
manualReviewReasonForAgentPy,
|
||||
countNewAgentParams,
|
||||
listPullFiles,
|
||||
touchesSdk,
|
||||
hasManualOnlyLabel,
|
||||
sensitivePathReasons,
|
||||
prSizeReasons,
|
||||
missingTestsReason,
|
||||
secretScanReasons,
|
||||
sdkTestChecksReason,
|
||||
listAllComments,
|
||||
getHeadCommitDate,
|
||||
isStaleFinalAfterPush,
|
||||
needsStaleFinalRecovery,
|
||||
shouldSkipFinalRecovery,
|
||||
FINAL_CLAUDE_REVIEW_BODY,
|
||||
loadPrContext,
|
||||
evaluatePipelineQuiescent,
|
||||
selectMergeGateCandidates,
|
||||
findMergeGateVerdict,
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run: node .github/scripts/pipeline-status-selftest.js
|
||||
*/
|
||||
const ps = require('./pipeline-status.js');
|
||||
const mg = require('./merge-gate.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
const kickComments = [
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
{ user: { login: 'MervinPraison' }, body: '/review' },
|
||||
];
|
||||
|
||||
const finalComment = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude You are the FINAL architecture reviewer.',
|
||||
created_at: '2026-06-26T10:00:00Z',
|
||||
};
|
||||
|
||||
assert(
|
||||
'reviews pending when not kicked',
|
||||
ps.deriveStage([], { ready: false, reasons: ['no FINAL Claude review trigger'] })
|
||||
=== 'pipeline/reviews-pending'
|
||||
);
|
||||
assert(
|
||||
'final pending after kick',
|
||||
ps.deriveStage(kickComments, { ready: false, reasons: ['no FINAL Claude review trigger'] })
|
||||
=== 'pipeline/final-claude-pending'
|
||||
);
|
||||
assert(
|
||||
'awaiting merge gate after final',
|
||||
ps.deriveStage(
|
||||
[...kickComments, finalComment],
|
||||
{ ready: false, reasons: ['recent @claude within 35min'] }
|
||||
) === 'pipeline/awaiting-merge-gate'
|
||||
);
|
||||
assert(
|
||||
'merge ready',
|
||||
ps.deriveStage([...kickComments, finalComment], { ready: true, reasons: [] })
|
||||
=== 'pipeline/merge-ready'
|
||||
);
|
||||
|
||||
const blockers = ps.deriveBlockerLabels({
|
||||
ready: false,
|
||||
reasons: ['CI not green on HEAD', 'SDK code added without test file changes — requires manual review'],
|
||||
});
|
||||
assert('maps ci blocker', blockers.includes('pipeline/blocked:ci'));
|
||||
assert('maps manual blocker', blockers.includes('pipeline/blocked:manual-review'));
|
||||
|
||||
assert(
|
||||
'internal link matches upstream base',
|
||||
mg.isInternalPullRequestLink(
|
||||
{ base: { repo: { full_name: 'MervinPraison/PraisonAI' } } },
|
||||
'MervinPraison',
|
||||
'PraisonAI'
|
||||
)
|
||||
);
|
||||
assert(
|
||||
'fork sync link rejected',
|
||||
!mg.isInternalPullRequestLink(
|
||||
{ number: 21, base: { repo: { full_name: 'Milkmange/PraisonAI' } } },
|
||||
'MervinPraison',
|
||||
'PraisonAI'
|
||||
)
|
||||
);
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Sync pipeline stage/blocker labels on open PRs.
|
||||
* @see .github/workflows/pipeline-status-sync.yml
|
||||
*/
|
||||
|
||||
const mergeGate = require('./merge-gate.js');
|
||||
const chain = require('./bot-pr-review-chain.js');
|
||||
|
||||
const PIPELINE_PREFIX = 'pipeline/';
|
||||
const STAGE_LABELS = [
|
||||
'pipeline/reviews-pending',
|
||||
'pipeline/final-claude-pending',
|
||||
'pipeline/awaiting-merge-gate',
|
||||
'pipeline/merge-ready',
|
||||
'pipeline/merged',
|
||||
];
|
||||
const BLOCKER_LABELS = [
|
||||
'pipeline/blocked:ci',
|
||||
'pipeline/blocked:conflict',
|
||||
'pipeline/blocked:manual-review',
|
||||
'pipeline/blocked:cooldown',
|
||||
'pipeline/blocked:stale-final',
|
||||
'pipeline/blocked:no-final',
|
||||
];
|
||||
const ALL_PIPELINE_LABELS = [...STAGE_LABELS, ...BLOCKER_LABELS];
|
||||
|
||||
const LABEL_SPECS = [
|
||||
{ name: 'pipeline/reviews-pending', color: 'fbca04', description: 'Waiting for CodeRabbit/Qodo/Copilot reviews' },
|
||||
{ name: 'pipeline/final-claude-pending', color: 'd4c5f9', description: 'Reviews done; waiting for FINAL @claude' },
|
||||
{ name: 'pipeline/awaiting-merge-gate', color: 'c5def5', description: 'FINAL done; waiting for merge gate / CI' },
|
||||
{ name: 'pipeline/merge-ready', color: '0e8a16', description: 'Eligible for merge gate auto-merge' },
|
||||
{ name: 'pipeline/merged', color: '6f42c1', description: 'Merged via pipeline' },
|
||||
{ name: 'pipeline/blocked:ci', color: 'd93f0b', description: 'Blocked: CI not green on HEAD' },
|
||||
{ name: 'pipeline/blocked:conflict', color: 'b60205', description: 'Blocked: merge conflict or rebase pending' },
|
||||
{ name: 'pipeline/blocked:manual-review', color: 'e99695', description: 'Blocked: requires manual review' },
|
||||
{ name: 'pipeline/blocked:cooldown', color: 'fef2c0', description: 'Blocked: post-push or @claude cooldown' },
|
||||
{ name: 'pipeline/blocked:stale-final', color: 'f9d0c4', description: 'Blocked: FINAL stale after new commits' },
|
||||
{ name: 'pipeline/blocked:no-final', color: 'ededed', description: 'Blocked: no FINAL @claude trigger yet' },
|
||||
];
|
||||
|
||||
function deriveStage(comments, evalResult) {
|
||||
if (evalResult.ready) return 'pipeline/merge-ready';
|
||||
if (!chain.chainKickPosted(comments)) return 'pipeline/reviews-pending';
|
||||
if (!mergeGate.hasFinalClaudeReviewTrigger(comments)) return 'pipeline/final-claude-pending';
|
||||
return 'pipeline/awaiting-merge-gate';
|
||||
}
|
||||
|
||||
function reasonToBlockerLabel(reason) {
|
||||
const r = (reason || '').toLowerCase();
|
||||
if (r.includes('ci not green') || r.includes('sdk code changed but no ci')) return 'pipeline/blocked:ci';
|
||||
if (
|
||||
r.includes('conflict') ||
|
||||
r.includes('mergestate=dirty') ||
|
||||
r.includes('not mergeable')
|
||||
) return 'pipeline/blocked:conflict';
|
||||
if (
|
||||
r.includes('manual') ||
|
||||
r.includes('requires manual') ||
|
||||
r.includes('sensitive path') ||
|
||||
r.includes('no-auto-merge') ||
|
||||
r.includes('manual-only label') ||
|
||||
r.includes('without test') ||
|
||||
r.includes('files changed') ||
|
||||
r.includes('possible secret') ||
|
||||
r.includes('agent.py')
|
||||
) return 'pipeline/blocked:manual-review';
|
||||
if (r.includes('recent @claude') || r.includes('post-push buffer')) return 'pipeline/blocked:cooldown';
|
||||
if (r.includes('stale final')) return 'pipeline/blocked:stale-final';
|
||||
if (r.includes('no final claude')) return 'pipeline/blocked:no-final';
|
||||
if (r.includes('final claude not complete')) return 'pipeline/blocked:stale-final';
|
||||
if (r.includes('claude.yml in progress') || r.includes('claude-merge-gate-active')) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveBlockerLabels(evalResult) {
|
||||
if (evalResult.ready) return [];
|
||||
const labels = new Set();
|
||||
for (const reason of evalResult.reasons || []) {
|
||||
const mapped = reasonToBlockerLabel(reason);
|
||||
if (mapped && mapped.startsWith('pipeline/blocked:')) labels.add(mapped);
|
||||
}
|
||||
return [...labels];
|
||||
}
|
||||
|
||||
function computePipelineLabels(comments, evalResult) {
|
||||
const stage = deriveStage(comments, evalResult);
|
||||
const blockers = stage === 'pipeline/merge-ready' ? [] : deriveBlockerLabels(evalResult);
|
||||
return { stage, blockers, all: [stage, ...blockers] };
|
||||
}
|
||||
|
||||
async function ensurePipelineLabels(github, owner, repo, core) {
|
||||
let existing;
|
||||
if (typeof github.paginate === 'function') {
|
||||
existing = await github.paginate(github.rest.issues.listLabelsForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
} else {
|
||||
({ data: existing } = await github.rest.issues.listLabelsForRepo({
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
}));
|
||||
}
|
||||
const names = new Set(existing.map((l) => l.name));
|
||||
for (const spec of LABEL_SPECS) {
|
||||
if (names.has(spec.name)) continue;
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: spec.name,
|
||||
color: spec.color,
|
||||
description: spec.description,
|
||||
});
|
||||
core?.info?.(`Created label ${spec.name}`);
|
||||
} catch (err) {
|
||||
if (err.status !== 422) throw err;
|
||||
core?.info?.(`Label ${spec.name} already exists`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncPipelineLabels(github, owner, repo, prNumber, core) {
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
if (ctx.pr.state !== 'open') return { synced: false, reason: 'not_open' };
|
||||
|
||||
const evalResult = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core
|
||||
);
|
||||
const { stage, blockers, all } = computePipelineLabels(ctx.comments, evalResult);
|
||||
|
||||
const current = ctx.labels.filter((l) => l.startsWith(PIPELINE_PREFIX));
|
||||
const desired = new Set(all);
|
||||
const toRemove = current.filter((l) => !desired.has(l) && l !== 'pipeline/merged');
|
||||
const toAdd = all.filter((l) => !current.includes(l));
|
||||
|
||||
for (const name of toRemove) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name });
|
||||
} catch (err) {
|
||||
if (err.status !== 404) throw err;
|
||||
}
|
||||
}
|
||||
if (toAdd.length) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: toAdd,
|
||||
});
|
||||
}
|
||||
|
||||
core?.info?.(`PR #${prNumber}: stage=${stage} blockers=[${blockers.join(', ')}]`);
|
||||
return { synced: true, stage, blockers, ready: evalResult.ready, reasons: evalResult.reasons };
|
||||
}
|
||||
|
||||
async function syncOpenPullRequests(github, owner, repo, options, core) {
|
||||
const { maxPrs = 20 } = options || {};
|
||||
await ensurePipelineLabels(github, owner, repo, core);
|
||||
let prs;
|
||||
if (maxPrs <= 100) {
|
||||
({ data: prs } = await github.rest.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
}));
|
||||
} else {
|
||||
prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
let synced = 0;
|
||||
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;
|
||||
await syncPipelineLabels(github, owner, repo, pr.number, core);
|
||||
synced += 1;
|
||||
}
|
||||
core?.info?.(`Pipeline label sync complete (${synced} PR(s))`);
|
||||
return synced;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STAGE_LABELS,
|
||||
BLOCKER_LABELS,
|
||||
ALL_PIPELINE_LABELS,
|
||||
deriveStage,
|
||||
deriveBlockerLabels,
|
||||
computePipelineLabels,
|
||||
ensurePipelineLabels,
|
||||
syncPipelineLabels,
|
||||
syncOpenPullRequests,
|
||||
};
|
||||
@@ -15,7 +15,10 @@ on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
pull_request:
|
||||
types: [opened]
|
||||
types: [opened, synchronize]
|
||||
schedule:
|
||||
- cron: '30 3,9,15,21 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
MAX_POLL_ATTEMPTS: 20
|
||||
@@ -143,9 +146,12 @@ jobs:
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
const alreadyPosted = comments.data.some(c => {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
});
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
core.setOutput('comments_count', comments.data.length.toString());
|
||||
|
||||
@@ -186,7 +192,8 @@ jobs:
|
||||
const claudeBody = '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. ' + reviewSummary + '.\n\n' +
|
||||
'Review Context: ' + comments.data.length + ' total comments, ' +
|
||||
(reviews.coderabbit.length + reviews.qodo.length + reviews.gemini.length + reviews.copilot.length + reviews.greptile.length) + ' bot reviews.\n\n' +
|
||||
'Phase 1: Review per AGENTS.md principles (protocol-driven, backward compatible, no perf impact).\n' +
|
||||
'Phase 1: Review per AGENTS.md principles (protocol-driven, backward compatible, no perf impact, SDK value gate — no feature bloat).\n' +
|
||||
'Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n' +
|
||||
'Phase 2: FIX valid issues found by prior reviewers.\n' +
|
||||
'Phase 3: Provide final verdict (approve or request changes).';
|
||||
await github.rest.issues.createComment({issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: claudeBody});
|
||||
@@ -209,6 +216,10 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
@@ -295,9 +306,12 @@ jobs:
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
const alreadyPosted = comments.data.some(c => {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
});
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
@@ -313,10 +327,23 @@ jobs:
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Qodo fallback path (same polling approach)
|
||||
# ================================================================
|
||||
@@ -332,6 +359,10 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
@@ -399,9 +430,12 @@ jobs:
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
const alreadyPosted = comments.data.some(c => {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
});
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
@@ -417,10 +451,23 @@ jobs:
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Gemini — restores the trigger lost from the deleted
|
||||
# chain-claude-after-copilot.yml workflow.
|
||||
@@ -453,9 +500,12 @@ jobs:
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
const alreadyPosted = comments.data.some(c => {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
});
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Post Claude analysis request (Lead Engineer)
|
||||
@@ -468,9 +518,127 @@ jobs:
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the Lead Engineer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
|
||||
body: '@claude You are the Lead Engineer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
|
||||
# ================================================================
|
||||
# Recovery: post FINAL @claude if merge-conflict comment blocked the chain
|
||||
# (e.g. conflict fired before claude-after-copilot could post review prompt)
|
||||
# ================================================================
|
||||
claude-review-recovery:
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.action == 'synchronize'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Re-post FINAL Claude review when stale or missing
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
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 hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
|
||||
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
|
||||
|
||||
if (mergeGate.shouldSkipFinalRecovery(comments, headPushedAt)) {
|
||||
core.info('Recent @claude within 35min and FINAL not stale — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalBody = mergeGate.FINAL_CLAUDE_REVIEW_BODY;
|
||||
|
||||
if (hasFinal && isStale) {
|
||||
await github.rest.issues.createComment({ issue_number: pr, owner, repo, body: finalBody });
|
||||
core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasFinal) {
|
||||
core.info('FINAL Claude review current on HEAD — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasReviewerActivity = comments.some(c => {
|
||||
const login = c.user.login.toLowerCase();
|
||||
return login.includes('coderabbit') || login.includes('copilot') ||
|
||||
login.includes('greptile') || login.includes('qodo') || login.includes('gemini');
|
||||
});
|
||||
if (!hasReviewerActivity) {
|
||||
core.info('No bot reviewer activity yet, skipping recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({ issue_number: pr, owner, repo, body: finalBody });
|
||||
core.info(`Posted missing FINAL Claude review on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# Scheduled: re-post @claude when FINAL is stale (Claude pushed after last FINAL trigger)
|
||||
stale-final-recovery-scheduled:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Re-post stale FINAL @claude on open internal PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: 'open', per_page: 100,
|
||||
});
|
||||
let posted = 0;
|
||||
for (const pr of prs) {
|
||||
if (pr.draft) continue;
|
||||
if (pr.head?.repo?.full_name && pr.head.repo.full_name !== `${owner}/${repo}`) continue;
|
||||
const comments = await mergeGate.listAllComments(github, owner, repo, pr.number);
|
||||
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr.number)) || pr.updated_at;
|
||||
if (!mergeGate.needsStaleFinalRecovery(comments, headPushedAt)) continue;
|
||||
if (mergeGate.hasRecentClaudeTrigger(comments, 10)) {
|
||||
core.info(`PR #${pr.number}: stale FINAL but @claude within 10min — skip`);
|
||||
continue;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr.number, owner, repo,
|
||||
body: mergeGate.FINAL_CLAUDE_REVIEW_BODY,
|
||||
});
|
||||
posted++;
|
||||
core.info(`Posted scheduled stale-FINAL recovery on PR #${pr.number}`);
|
||||
if (posted >= 5) break;
|
||||
}
|
||||
core.info(`Scheduled stale-FINAL recovery complete (${posted} posted)`);
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: CodeRabbit/Qodo skip bot-authored PRs by default.
|
||||
# Trigger them explicitly. Do NOT trigger Copilot here — it will
|
||||
@@ -488,38 +656,29 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Trigger CodeRabbit and Qodo reviews
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
await chain.kickReviewChain(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
// 1. Trigger CodeRabbit review
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '@coderabbitai review'
|
||||
});
|
||||
console.log(`Triggered CodeRabbit for bot PR #${pr}`);
|
||||
|
||||
// 2. Trigger Qodo review
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '/review'
|
||||
});
|
||||
console.log(`Triggered Qodo for bot PR #${pr}`);
|
||||
|
||||
// 3. Trigger Gemini review (commented out)
|
||||
// await github.rest.issues.createComment({
|
||||
// issue_number: pr, owner, repo,
|
||||
// body: '@gemini review this PR'
|
||||
// });
|
||||
// console.log(`Triggered Gemini for bot PR #${pr}`);
|
||||
|
||||
// NOTE: @copilot is NOT triggered here.
|
||||
// It will be triggered by copilot-after-coderabbit or copilot-after-qodo
|
||||
// once those bots finish, ensuring Copilot always reviews LAST.
|
||||
console.log('Copilot will be triggered after CodeRabbit/Qodo complete.');
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Bot PR Recovery
|
||||
|
||||
# Belt-and-braces: kick CodeRabbit/Qodo when pull_request:opened never fired for bot PRs.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Specific PR number to kick (optional)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Find and kick stalled bot PRs
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER_INPUT: ${{ github.event.inputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const manual = (process.env.PR_NUMBER_INPUT || '').trim();
|
||||
const prNumber = manual ? parseInt(manual, 10) : null;
|
||||
if (manual && Number.isNaN(prNumber)) {
|
||||
throw new Error(`Invalid pr_number: ${manual}`);
|
||||
}
|
||||
await chain.recoverStalledBotPrs(github, owner, repo, { prNumber }, core);
|
||||
@@ -0,0 +1,474 @@
|
||||
name: Claude PR Merge Gate
|
||||
|
||||
# Read-only Claude assesses merge readiness; GH_TOKEN merges (no @claude, no gh pr review).
|
||||
# Does not overlap with merge-conflict-claude.yml or auto-pr-comment @claude chains.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2,8,14,20 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Optional PR number (scan all open internal PRs if empty)'
|
||||
required: false
|
||||
type: string
|
||||
merge_method:
|
||||
description: 'Merge method'
|
||||
required: false
|
||||
type: choice
|
||||
default: merge
|
||||
options:
|
||||
- merge
|
||||
- squash
|
||||
- rebase
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Claude Assistant
|
||||
- Core Tests
|
||||
types:
|
||||
- completed
|
||||
repository_dispatch:
|
||||
types:
|
||||
- claude-merge-gate
|
||||
|
||||
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: true
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
MAX_CANDIDATES: 5
|
||||
DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
auto-dispatch:
|
||||
if: github.event_name == 'workflow_run'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Dispatch merge gate when PR pipeline is ready
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PAT: ${{ secrets.GH_TOKEN }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const wr = context.payload.workflow_run;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
if (!process.env.PAT) {
|
||||
core.setFailed('GH_TOKEN secret is required for merge gate auto-dispatch (needs repo + workflow scope).');
|
||||
return;
|
||||
}
|
||||
|
||||
if (wr.conclusion !== 'success') {
|
||||
core.info(`Skip dispatch: ${wr.name} conclusion=${wr.conclusion}`);
|
||||
return;
|
||||
}
|
||||
const allowed = ['Claude Assistant', 'Core Tests'];
|
||||
if (!allowed.includes(wr.name)) {
|
||||
core.info(`Skip dispatch: workflow ${wr.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun(github, owner, repo, wr);
|
||||
if (!prNumber) {
|
||||
core.info('Skip dispatch: could not resolve PR from workflow run');
|
||||
return;
|
||||
}
|
||||
|
||||
const fromClaude = wr.name === 'Claude Assistant';
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, {
|
||||
skipGlobalClaudeRunCheck: fromClaude,
|
||||
skipRecentClaudeCooldown: fromClaude,
|
||||
}
|
||||
);
|
||||
if (!check.ready) {
|
||||
core.info(`PR #${prNumber} not ready: ${check.reasons.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
event_type: 'claude-merge-gate',
|
||||
client_payload: { pr_number: prNumber },
|
||||
});
|
||||
core.info(`Dispatched merge gate for PR #${prNumber} after ${wr.name}`);
|
||||
|
||||
scan-candidates:
|
||||
if: github.event_name != 'workflow_run'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
actions: read
|
||||
outputs:
|
||||
matrix: ${{ steps.scan.outputs.matrix }}
|
||||
has_candidates: ${{ steps.scan.outputs.has_candidates }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Scan open PRs for merge gate eligibility
|
||||
id: scan
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const maxCandidates = parseInt(process.env.MAX_CANDIDATES || '5', 10);
|
||||
const inputPr = String(process.env.DISPATCH_PR_NUMBER || '').trim();
|
||||
|
||||
const candidates = [];
|
||||
const skipped = [];
|
||||
|
||||
if (inputPr) {
|
||||
const n = parseInt(inputPr, 10);
|
||||
if (Number.isNaN(n)) {
|
||||
throw new Error(`Invalid pr_number input: ${inputPr}`);
|
||||
}
|
||||
const result = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, n, core);
|
||||
if (result.ready) {
|
||||
candidates.push({ pr_number: n, head_sha: result.headSha });
|
||||
} else {
|
||||
skipped.push({ pr: n, reasons: result.reasons });
|
||||
}
|
||||
} else {
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: 'open', per_page: 100, sort: 'created', direction: 'asc',
|
||||
});
|
||||
const prNumbers = prs.map((p) => p.number);
|
||||
const selected = await mergeGate.selectMergeGateCandidates(
|
||||
github, owner, repo, prNumbers, maxCandidates, core
|
||||
);
|
||||
candidates.push(...selected.candidates);
|
||||
skipped.push(...selected.skipped);
|
||||
}
|
||||
|
||||
core.info('Skipped: ' + JSON.stringify(skipped));
|
||||
core.setOutput('matrix', JSON.stringify({ include: candidates }));
|
||||
core.setOutput('has_candidates', candidates.length > 0 ? 'true' : 'false');
|
||||
|
||||
core.summary.addRaw('## Merge gate scan\n');
|
||||
core.summary.addRaw(`Candidates: ${candidates.length}\n`);
|
||||
core.summary.addRaw('```json\n' + JSON.stringify({ candidates, skipped }, null, 2) + '\n```');
|
||||
await core.summary.write();
|
||||
|
||||
if (inputPr && skipped.length === 1 && skipped[0].pr === parseInt(inputPr, 10)) {
|
||||
const reasons = skipped[0].reasons;
|
||||
const body = [
|
||||
'**Merge gate scan** — not eligible for auto-merge.',
|
||||
'',
|
||||
...reasons.map((r) => `- \`${r}\``),
|
||||
'',
|
||||
'**Actions:** wait for CI and the Claude review chain, or add label `needs-manual-review` and merge manually.',
|
||||
'**Opt out:** label `no-auto-merge`.',
|
||||
].join('\n');
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: skipped[0].pr, body,
|
||||
});
|
||||
} else if (inputPr && candidates.length === 1) {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: candidates[0].pr_number,
|
||||
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
|
||||
});
|
||||
}
|
||||
|
||||
await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 20 }, core);
|
||||
|
||||
claude-assess:
|
||||
needs: scan-candidates
|
||||
if: needs.scan-candidates.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Mark merge gate active
|
||||
id: mark_active
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
core.setOutput('started_at', new Date().toISOString());
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: ${{ matrix.pr_number }},
|
||||
labels: ['claude-merge-gate-active'],
|
||||
});
|
||||
|
||||
- name: Stash merge gate helpers
|
||||
run: cp .github/scripts/merge-gate.js /tmp/merge-gate.js
|
||||
|
||||
- name: Fetch PR branch
|
||||
env:
|
||||
PR_NUMBER: ${{ matrix.pr_number }}
|
||||
run: |
|
||||
git fetch origin "pull/${PR_NUMBER}/head:pr-${PR_NUMBER}"
|
||||
git checkout "pr-${PR_NUMBER}"
|
||||
|
||||
- name: Claude merge gate assessment (read-only)
|
||||
id: assess
|
||||
continue-on-error: true
|
||||
uses: ./.github/actions/claude-code-action
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: inherit
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
model: claude-opus-4-8
|
||||
trigger_phrase: '@claude-merge-gate-internal'
|
||||
direct_prompt: |
|
||||
You are the MERGE GATE reviewer for PraisonAI PR #${{ matrix.pr_number }}.
|
||||
READ-ONLY: do not modify code, commit, push, rebase, or create branches.
|
||||
|
||||
CRITICAL OUTPUT RULE:
|
||||
Post exactly one PR comment via GitHub MCP containing either:
|
||||
MERGE_GATE_VERDICT: APPROVE
|
||||
or
|
||||
MERGE_GATE_VERDICT: BLOCK
|
||||
followed by a short rationale on the next lines.
|
||||
|
||||
NEVER include @claude in any comment. NEVER mention merge conflict resolution.
|
||||
|
||||
Review checklist:
|
||||
1. Read AGENTS.md and the PR diff on the current branch.
|
||||
2. Read all PR comments and reviews (FINAL architecture reviewer / Lead Engineer must have run).
|
||||
3. Confirm SDK value gate: change strengthens praisonaiagents (no scope creep).
|
||||
4. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
5. If agent.py adds >100 lines or new Agent __init__ params, respond MERGE_GATE_VERDICT: BLOCK (manual review required).
|
||||
6. BLOCK if labels security/breaking-change/needs-manual-review/release, sensitive paths (.github/workflows, auth, pyproject.toml), secrets in diff, PR >800 lines or >30 files, or SDK changes without tests. Label `merge-gate-ci-only` exempts CI-only changes under `.github/workflows/`, `.github/actions/`, or merge-gate scripts.
|
||||
7. Confirm no CHANGES_REQUESTED reviews and CI green on HEAD ${{ matrix.head_sha }}.
|
||||
8. If ready to merge: MERGE_GATE_VERDICT: APPROVE. Otherwise: MERGE_GATE_VERDICT: BLOCK with reasons.
|
||||
|
||||
Do not approve or merge via GitHub UI/API — only post the verdict comment.
|
||||
disallowed_tools: |
|
||||
Bash
|
||||
Edit
|
||||
Write
|
||||
Replace
|
||||
allowed_tools: |
|
||||
View
|
||||
GlobTool
|
||||
GrepTool
|
||||
Read
|
||||
timeout_minutes: 15
|
||||
|
||||
- name: Post fallback verdict if Claude did not comment
|
||||
id: fallback_verdict
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate.js');
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const existing = mergeGate.findMergeGateVerdict(ctx.comments, minCreatedAt, ctx.headPushedAt);
|
||||
if (existing) {
|
||||
core.info(`Verdict already posted: ${existing}`);
|
||||
core.setOutput('verdict', existing);
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
const verdict = check.ready ? 'APPROVE' : 'BLOCK';
|
||||
const lines = [
|
||||
`MERGE_GATE_VERDICT: ${verdict}`,
|
||||
'',
|
||||
'Automated fallback — Claude assess did not post a verdict comment (e.g. GitHub MCP unavailable).',
|
||||
];
|
||||
if (check.ready) {
|
||||
lines.push('All deterministic merge gates passed.');
|
||||
} else {
|
||||
lines.push('Blockers:', ...check.reasons.map((r) => `- ${r}`));
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber,
|
||||
body: lines.join('\n'),
|
||||
});
|
||||
core.info(`Posted fallback verdict: ${verdict}`);
|
||||
core.setOutput('verdict', verdict);
|
||||
|
||||
- name: Verify verdict posted
|
||||
id: verdict
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate.js');
|
||||
const comments = await mergeGate.listAllComments(
|
||||
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
|
||||
);
|
||||
const ctx = await mergeGate.loadPrContext(
|
||||
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
|
||||
);
|
||||
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const verdict = mergeGate.findMergeGateVerdict(comments, minCreatedAt, ctx.headPushedAt);
|
||||
core.setOutput('verdict', verdict || 'NONE');
|
||||
if (!verdict) core.warning('No recent MERGE_GATE_VERDICT comment found after assessment');
|
||||
|
||||
- name: Remove merge gate active label
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
name: 'claude-merge-gate-active',
|
||||
});
|
||||
core.info(`Removed claude-merge-gate-active from PR #${prNumber}`);
|
||||
} catch (e) {
|
||||
core.info(`Label claude-merge-gate-active already removed or missing on PR #${prNumber}`);
|
||||
}
|
||||
|
||||
- name: Fail job if assessment failed
|
||||
if: steps.assess.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
merge-only:
|
||||
needs: [scan-candidates, claude-assess]
|
||||
if: |
|
||||
always() &&
|
||||
needs.scan-candidates.outputs.has_candidates == 'true' &&
|
||||
needs.claude-assess.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Re-check gates and merge
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
MERGE_METHOD: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
SCAN_HEAD_SHA: ${{ matrix.head_sha }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
const scanHeadSha = process.env.SCAN_HEAD_SHA;
|
||||
|
||||
const removeLabels = async () => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: prNumber, name: 'claude-merge-gate-active',
|
||||
});
|
||||
} catch (e) {
|
||||
core.info('Label claude-merge-gate-active already removed or missing');
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const comments = ctx.comments;
|
||||
const verdictMin = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const verdict = mergeGate.findMergeGateVerdict(comments, verdictMin, ctx.headPushedAt);
|
||||
if (verdict !== 'APPROVE') {
|
||||
core.info(`Skip merge: verdict=${verdict || 'NONE'}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
if (!check.ready) {
|
||||
core.info(`Skip merge: ${check.reasons.join(', ')}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
if (check.headSha !== scanHeadSha) {
|
||||
core.info(`Skip merge: HEAD moved ${scanHeadSha.slice(0, 7)} → ${check.headSha.slice(0, 7)}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
const method = process.env.MERGE_METHOD || 'merge';
|
||||
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'],
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber,
|
||||
body: [
|
||||
'Merged by **Claude PR merge gate** (`claude-merge-gate.yml`).',
|
||||
`Verdict: MERGE_GATE_VERDICT: APPROVE`,
|
||||
`SHA: \`${check.headSha.slice(0, 7)}\``,
|
||||
`Method: ${method}`,
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
core.info(`Merged PR #${prNumber}`);
|
||||
} finally {
|
||||
await removeLabels();
|
||||
}
|
||||
+100
-10
@@ -123,6 +123,8 @@ jobs:
|
||||
github.event.action != 'opened' &&
|
||||
(github.event.action != 'labeled' || github.event.label.name == 'claude') &&
|
||||
(github.event_name != 'issue_comment' || contains(github.event.comment.body, '@claude')) &&
|
||||
(github.event_name != 'pull_request_review' || contains(github.event.review.body, '@claude')) &&
|
||||
(github.event_name != 'pull_request_review_comment' || contains(github.event.comment.body, '@claude')) &&
|
||||
(
|
||||
!contains(github.actor, '[bot]') ||
|
||||
github.actor == 'github-actions[bot]' ||
|
||||
@@ -161,6 +163,7 @@ jobs:
|
||||
if (!isPR) {
|
||||
core.setOutput('is_pr', 'false');
|
||||
core.setOutput('is_fork', 'false');
|
||||
core.setOutput('can_push_fork', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -173,20 +176,44 @@ jobs:
|
||||
pull_number: prNumber
|
||||
});
|
||||
|
||||
let mergeState = '';
|
||||
try {
|
||||
const gql = await github.graphql(
|
||||
`query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) { mergeStateStatus }
|
||||
}
|
||||
}`,
|
||||
{ owner: context.repo.owner, repo: context.repo.repo, number: prNumber }
|
||||
);
|
||||
mergeState = (gql.repository.pullRequest.mergeStateStatus || '').toUpperCase();
|
||||
} catch (e) {
|
||||
core.warning(`GraphQL mergeStateStatus failed: ${e.message}`);
|
||||
}
|
||||
if (!mergeState) {
|
||||
mergeState = (pr.data.mergeable_state || pr.data.merge_state_status || '').toUpperCase();
|
||||
}
|
||||
|
||||
core.setOutput('is_pr', 'true');
|
||||
core.setOutput('pr_number', String(prNumber));
|
||||
core.setOutput('pr_branch', pr.data.head.ref);
|
||||
core.setOutput('head_repo', pr.data.head.repo.full_name);
|
||||
core.setOutput('merge_state', (pr.data.mergeable_state || pr.data.merge_state_status || '').toUpperCase());
|
||||
core.setOutput('merge_state', mergeState);
|
||||
|
||||
const baseRepo = `${context.repo.owner}/${context.repo.repo}`;
|
||||
const maintainerCanModify = pr.data.maintainer_can_modify === true;
|
||||
core.setOutput('maintainer_can_modify', String(maintainerCanModify));
|
||||
|
||||
if (pr.data.head.repo && pr.data.head.repo.full_name !== baseRepo) {
|
||||
core.setOutput('is_fork', 'true');
|
||||
core.setOutput('can_push_fork', String(maintainerCanModify));
|
||||
core.setOutput('fork_repo', pr.data.head.repo.full_name);
|
||||
core.setOutput('clone_url', pr.data.head.repo.clone_url);
|
||||
core.setOutput('branch', pr.data.head.ref);
|
||||
return;
|
||||
}
|
||||
core.setOutput('is_fork', 'false');
|
||||
core.setOutput('can_push_fork', 'false');
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -201,30 +228,35 @@ jobs:
|
||||
PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }}
|
||||
PR_NUMBER: ${{ steps.check_fork.outputs.pr_number }}
|
||||
IS_FORK: ${{ steps.check_fork.outputs.is_fork }}
|
||||
CAN_PUSH_FORK: ${{ steps.check_fork.outputs.can_push_fork }}
|
||||
FORK_REPO: ${{ steps.check_fork.outputs.fork_repo }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
git fetch origin "pull/${PR_NUMBER}/head:${PR_BRANCH}"
|
||||
git checkout "${PR_BRANCH}"
|
||||
git fetch origin main
|
||||
|
||||
# If it's a fork, make origin point to local to trick claude-code-action's `git fetch origin <branch>`
|
||||
if [ "${IS_FORK}" = "true" ]; then
|
||||
if [ "${IS_FORK}" = "true" ] && [ "${CAN_PUSH_FORK}" = "true" ]; then
|
||||
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${FORK_REPO}.git"
|
||||
elif [ "${IS_FORK}" = "true" ]; then
|
||||
# Block push: trick claude-code-action's `git fetch origin <branch>`
|
||||
git remote set-url origin "file://$(pwd)"
|
||||
fi
|
||||
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GH_TOKEN: ${{ steps.check_fork.outputs.can_push_fork == 'true' && secrets.GH_TOKEN || steps.app-token.outputs.token }}
|
||||
with:
|
||||
allowed_bots: 'praisonai-triage-agent[bot]'
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
model: opus
|
||||
model: claude-opus-4-8
|
||||
trigger_phrase: "@claude"
|
||||
label_trigger: "claude"
|
||||
|
||||
direct_prompt: |
|
||||
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: THIS IS A PULL REQUEST FROM A FORK. YOU DO NOT HAVE PUSH PERMISSIONS TO THIS REPOSITORY. ONLY READ FILES, VALIDATE CODE, AND PROVIDE YOUR COMMENTS/FEEDBACK DIRECTLY IN THIS PR VIA COMMENTS. DO NOT ATTEMPT TO PUSH OR CREATE PULL REQUESTS.\n\n' || (steps.check_fork.outputs.is_pr == 'true' && 'NOTE: The branch is under MervinPraison/PraisonAI (not a fork). You are able to make modifications and push directly to this branch.\n\n' || 'NOTE: This is an issue (not a PR). Create a new fix branch per STEP 3 below.\n\n') }}
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && format('CONTEXT: PR #{0} on branch {1}. head.repo={2}, is_fork={3}, merge_state={4}. NEVER treat cursor/*, claude/* branch names or bot authors as forks — only is_fork=true means fork.\n\n', steps.check_fork.outputs.pr_number, steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.head_repo, steps.check_fork.outputs.is_fork, steps.check_fork.outputs.merge_state) || '' }}
|
||||
${{ steps.check_fork.outputs.is_fork == 'true' && steps.check_fork.outputs.can_push_fork != 'true' && 'CRITICAL: THIS IS A PULL REQUEST FROM A FORK WITHOUT MAINTAINER EDITS. YOU DO NOT HAVE PUSH PERMISSIONS. ONLY READ FILES, VALIDATE CODE, AND PROVIDE COMMENTS/FEEDBACK ON THIS PR. DO NOT ATTEMPT TO PUSH OR CREATE PULL REQUESTS.\n\n' || (steps.check_fork.outputs.can_push_fork == 'true' && format('NOTE: Fork PR with maintainer edits allowed. Push directly to fork {0} branch {1}.\n\n', steps.check_fork.outputs.fork_repo, steps.check_fork.outputs.pr_branch) || (steps.check_fork.outputs.is_pr == 'true' && steps.check_fork.outputs.is_fork != 'true' && 'NOTE: The branch is under MervinPraison/PraisonAI (not a fork). You are able to make modifications and push directly to this branch.\n\n' || (steps.check_fork.outputs.is_pr != 'true' && 'NOTE: This is an issue (not a PR). Create a new fix branch per STEP 3 below.\n\n' || ''))) }}
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && format('CONTEXT: PR #{0} on branch {1}. head.repo={2}, is_fork={3}, can_push_fork={4}, merge_state={5}. NEVER treat cursor/*, claude/* branch names or bot authors as forks — only is_fork=true means fork.\n\n', steps.check_fork.outputs.pr_number, steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.head_repo, steps.check_fork.outputs.is_fork, steps.check_fork.outputs.can_push_fork, steps.check_fork.outputs.merge_state) || '' }}
|
||||
You are working on the PraisonAI SDK. Follow AGENTS.md strictly.
|
||||
|
||||
STEP 0 — SETUP GIT IDENTITY & AUTH (GLOBAL — required for all repos):
|
||||
@@ -247,14 +279,15 @@ jobs:
|
||||
5. Plugins (PraisonAI-Plugins): Framework lifecycle extensions replacing systemic behavior (e.g. tracing, logging, metrics, hooks, guardrails).
|
||||
For items routed to an EXTERNAL repository (Tools, Docs, Plugins), follow STEP 3-ALT below.
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
|
||||
- Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
|
||||
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead. PRs with >100 lines added to agent.py require manual review (merge gate will not auto-merge).
|
||||
- Is there a name collision with existing exports in __init__.py?
|
||||
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
|
||||
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && steps.check_fork.outputs.is_fork != 'true' && steps.check_fork.outputs.merge_state == 'DIRTY' && format('STEP 3 — RESOLVE MERGE CONFLICTS ON PR (branch: {0}, PR #{1}):\n- DO NOT create a new branch or PR\n- git fetch origin main && git rebase origin/main\n- Resolve conflicts: keep this PR intent, merge in main newer logic\n- git add -A && git rebase --continue (repeat until done)\n- git push --force-with-lease origin {0}\n- Run targeted pytest on changed paths\n- Comment files resolved and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number) || '' }}
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && steps.check_fork.outputs.is_fork != 'true' && steps.check_fork.outputs.merge_state != 'DIRTY' && format('STEP 3 — IMPLEMENT ON EXISTING PR (branch: {0}, PR #{1}):\n- DO NOT create a new branch or PR\n- git checkout {0}\n- Implement minimal, focused fixes from reviewer feedback\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description>"\n- Push: git push origin {0}\n- Comment a summary of exact files modified and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number) || '' }}
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && (steps.check_fork.outputs.is_fork != 'true' || steps.check_fork.outputs.can_push_fork == 'true') && steps.check_fork.outputs.merge_state == 'DIRTY' && format('STEP 3 — RESOLVE MERGE CONFLICTS ON PR (branch: {0}, PR #{1}{2}):\n- DO NOT create a new branch or PR\n- git fetch origin main && git rebase origin/main\n- Resolve conflicts: keep this PR intent, merge in main newer logic\n- git add -A && git rebase --continue (repeat until done)\n- git push --force-with-lease origin {0}\n- Run targeted pytest on changed paths\n- Comment files resolved and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number, steps.check_fork.outputs.can_push_fork == 'true' && format(', fork: {0}', steps.check_fork.outputs.fork_repo) || '') || '' }}
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && (steps.check_fork.outputs.is_fork != 'true' || steps.check_fork.outputs.can_push_fork == 'true') && steps.check_fork.outputs.merge_state != 'DIRTY' && format('STEP 3 — IMPLEMENT ON EXISTING PR (branch: {0}, PR #{1}{2}):\n- DO NOT create a new branch or PR\n- git checkout {0}\n- Implement minimal, focused fixes from reviewer feedback\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description>"\n- Push: git push origin {0}\n- Comment a summary of exact files modified and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number, steps.check_fork.outputs.can_push_fork == 'true' && format(', fork: {0}', steps.check_fork.outputs.fork_repo) || '') || '' }}
|
||||
${{ steps.check_fork.outputs.is_pr != 'true' && 'STEP 3 — IMPLEMENT (for changes in THIS repo — PraisonAI):\n- Create a fix branch: git checkout -b claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\n- Implement a minimal, focused fix\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description> (fixes #$ISSUE_NUMBER)"\n- Push: git push origin claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\nCRITICAL: After pushing, you MUST immediately run: gh pr create --title "fix: <title>" --body "Fixes #$ISSUE_NUMBER" --head claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\nDo NOT stop after pushing. Do NOT say "manually create a PR". Always create the PR automatically.\n' || '' }}
|
||||
|
||||
STEP 3-ALT — IMPLEMENT IN EXTERNAL REPO (for PraisonAI-Tools, PraisonAIDocs, PraisonAI-Plugins):
|
||||
@@ -303,3 +336,60 @@ jobs:
|
||||
mcp__github__get_issue_comments
|
||||
mcp__github__update_issue
|
||||
timeout_minutes: 30
|
||||
|
||||
- name: Kick review chain for bot-created PR
|
||||
if: |
|
||||
steps.check_fork.outputs.is_pr != 'true' &&
|
||||
(github.event_name == 'issues' || github.event_name == 'issue_comment') &&
|
||||
github.event.issue != null
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
|
||||
const issueNumber = context.payload.issue.number;
|
||||
await chain.kickReviewChainForIssue(
|
||||
github, context.repo.owner, context.repo.repo, issueNumber, core
|
||||
);
|
||||
|
||||
- name: Sync pipeline status labels for PR
|
||||
if: steps.check_fork.outputs.is_pr == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const prNumber = parseInt('${{ steps.check_fork.outputs.pr_number }}', 10);
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, prNumber, core);
|
||||
|
||||
- name: Dispatch merge gate when PR pipeline ready
|
||||
if: steps.check_fork.outputs.is_pr == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = parseInt('${{ steps.check_fork.outputs.pr_number }}', 10);
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, {
|
||||
skipGlobalClaudeRunCheck: true,
|
||||
skipRecentClaudeCooldown: true,
|
||||
}
|
||||
);
|
||||
if (!check.ready) {
|
||||
core.info(`PR #${prNumber} not ready for merge gate: ${check.reasons.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
event_type: 'claude-merge-gate',
|
||||
client_payload: { pr_number: prNumber },
|
||||
});
|
||||
core.info(`Dispatched merge gate for PR #${prNumber}`);
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Deploy install scripts to praison.ai
|
||||
|
||||
# When install.sh or install.ps1 change on main, trigger praison.ai CI which
|
||||
# syncs scripts at build time and deploys to Azure AKS (existing azure-pipeline.yaml).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/praisonai/scripts/install.sh'
|
||||
- 'src/praisonai/scripts/install.ps1'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-install-scripts
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
trigger-website-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger praison.ai CI
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "SECRETS_ADMIN_PAT is not configured in this repository"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gh workflow run CI \
|
||||
--repo MervinPraison/praison.ai \
|
||||
--ref master
|
||||
|
||||
echo "Triggered praison.ai CI for PraisonAI commit ${SOURCE_SHA}"
|
||||
echo "Monitor: https://github.com/MervinPraison/praison.ai/actions/workflows/azure-pipeline.yaml"
|
||||
|
||||
sleep 10
|
||||
RUN_URL=$(gh run list \
|
||||
--repo MervinPraison/praison.ai \
|
||||
--workflow CI \
|
||||
--limit 1 \
|
||||
--json url,status,createdAt \
|
||||
--jq '.[0].url' 2>/dev/null || true)
|
||||
if [ -n "${RUN_URL}" ]; then
|
||||
echo "Latest praison.ai CI run: ${RUN_URL}"
|
||||
fi
|
||||
@@ -0,0 +1,184 @@
|
||||
name: Auto Merge Conflict → Claude
|
||||
|
||||
# Detects internal PRs with mergeStateStatus=DIRTY and posts @claude to trigger claude.yml.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *'
|
||||
|
||||
concurrency:
|
||||
group: merge-conflict-scan-${{ github.repository }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# PAT posts as MervinPraison so issue_comment triggers claude.yml (GITHUB_TOKEN cannot chain workflows)
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
detect-and-trigger:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Detect conflicting PRs and trigger Claude
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const baseRepo = `${owner}/${repo}`;
|
||||
const COOLDOWN_MS = 12 * 60 * 60 * 1000;
|
||||
const AUTO_ACTORS = ['github-actions[bot]', 'MervinPraison'];
|
||||
const LABEL = 'claude-conflict-pending';
|
||||
const COMMENT_BODY = [
|
||||
'@claude this PR has merge conflicts with `main`.',
|
||||
'Please rebase onto latest `main`, resolve conflicts (keep this PR\'s intent, merge in newer main logic),',
|
||||
'run targeted tests, and force-push with `--force-with-lease`.',
|
||||
'Comment which files you resolved.',
|
||||
'Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.',
|
||||
].join(' ');
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function getMergeState(prNumber) {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
mergeStateStatus
|
||||
maintainerCanModify
|
||||
isDraft
|
||||
headRef { repository { nameWithOwner } }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const result = await github.graphql(query, { owner, repo, number: prNumber });
|
||||
const prGql = result.repository.pullRequest;
|
||||
const status = (prGql?.mergeStateStatus || '').toUpperCase();
|
||||
if (status && status !== 'UNKNOWN') {
|
||||
return {
|
||||
status,
|
||||
isDraft: prGql.isDraft,
|
||||
headRepo: prGql.headRef?.repository?.nameWithOwner,
|
||||
maintainerCanModify: prGql.maintainerCanModify === true,
|
||||
};
|
||||
}
|
||||
if (attempt < 2) await sleep(10000);
|
||||
}
|
||||
const pr = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
return {
|
||||
status: (pr.data.mergeable_state || '').toUpperCase(),
|
||||
isDraft: pr.data.draft,
|
||||
headRepo: pr.data.head.repo?.full_name,
|
||||
maintainerCanModify: pr.data.maintainer_can_modify === true,
|
||||
};
|
||||
}
|
||||
|
||||
function hasFinalClaudeReviewTrigger(comments) {
|
||||
return comments.some((c) => {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!AUTO_ACTORS.includes(c.user.login)) return false;
|
||||
if (!body.includes('@claude')) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
});
|
||||
}
|
||||
|
||||
function hasRecentAutoComment(comments) {
|
||||
const cutoff = Date.now() - COOLDOWN_MS;
|
||||
return comments.some((c) => {
|
||||
if (!AUTO_ACTORS.includes(c.user.login)) return false;
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!body.includes('@claude') || !body.includes('merge conflict')) return false;
|
||||
return new Date(c.created_at).getTime() > cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
async function processPR(prNumber) {
|
||||
const { status, isDraft, headRepo, maintainerCanModify } = await getMergeState(prNumber);
|
||||
core.info(`PR #${prNumber}: mergeState=${status}, head=${headRepo}, draft=${isDraft}, maintainerCanModify=${maintainerCanModify}`);
|
||||
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
});
|
||||
const labels = issue.labels.map((l) => l.name);
|
||||
|
||||
if (status !== 'DIRTY' && labels.includes(LABEL)) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: LABEL,
|
||||
});
|
||||
core.info(`Removed ${LABEL} from PR #${prNumber}`);
|
||||
}
|
||||
|
||||
if (status !== 'DIRTY') return { skipped: true, reason: 'not dirty' };
|
||||
if (isDraft) return { skipped: true, reason: 'draft' };
|
||||
if (headRepo && headRepo !== baseRepo && !maintainerCanModify) {
|
||||
return { skipped: true, reason: 'fork without maintainer edits' };
|
||||
}
|
||||
if (labels.includes(LABEL)) {
|
||||
return { skipped: true, reason: 'conflict resolution pending' };
|
||||
}
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
if (hasRecentAutoComment(comments)) {
|
||||
return { skipped: true, reason: 'recent auto comment' };
|
||||
}
|
||||
|
||||
if (!hasFinalClaudeReviewTrigger(comments)) {
|
||||
return { skipped: true, reason: 'awaiting final claude review trigger' };
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: [LABEL],
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: COMMENT_BODY,
|
||||
});
|
||||
return { triggered: true };
|
||||
}
|
||||
|
||||
let prNumbers = [];
|
||||
if (context.eventName === 'pull_request') {
|
||||
prNumbers = [context.payload.pull_request.number];
|
||||
} else {
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
prNumbers = prs.map((p) => p.number);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const num of prNumbers) {
|
||||
results.push({ pr: num, ...(await processPR(num)) });
|
||||
}
|
||||
core.summary.addRaw('```json\n' + JSON.stringify(results, null, 2) + '\n```');
|
||||
await core.summary.write();
|
||||
@@ -0,0 +1,126 @@
|
||||
name: Nightly Release Gate
|
||||
|
||||
# Preflight checks before dispatching PyPI Release (patch bump).
|
||||
# Runs nightly at 00:00 UTC (midnight).
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Run preflight only; do not dispatch pypi-release.yml'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
|
||||
concurrency:
|
||||
group: nightly-release-gate
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Preflight — path changes and CI green
|
||||
id: preflight
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git fetch --tags origin
|
||||
|
||||
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
|
||||
|
||||
CHANGED=$(git diff --name-only "${LAST_TAG}" HEAD -- \
|
||||
src/praisonai \
|
||||
src/praisonai-agents || true)
|
||||
|
||||
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
|
||||
run: |
|
||||
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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh workflow run pypi-release.yml \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--ref main \
|
||||
-f bump=patch \
|
||||
-f source=nightly \
|
||||
-f dry_run=false
|
||||
echo "Dispatched pypi-release.yml (patch, source=nightly)"
|
||||
echo "Approve the pypi environment when prompted."
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Pipeline Status Sync
|
||||
|
||||
# Sync pipeline/* stage and blocker labels on open internal PRs.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Sync pipeline labels on open PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
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 }, core
|
||||
);
|
||||
@@ -0,0 +1,305 @@
|
||||
name: PyPI Release
|
||||
|
||||
# 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 both packages'
|
||||
required: true
|
||||
default: patch
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
dry_run:
|
||||
description: 'Preview versions and steps without publishing or pushing'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_agents:
|
||||
description: 'Skip agents publish (recovery if already on PyPI)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_wrapper:
|
||||
description: 'Publish agents only'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
agents_version:
|
||||
description: 'Override agents version (empty = auto bump from pyproject.toml)'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
wrapper_version:
|
||||
description: 'Override wrapper version (empty = auto bump from version.py)'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
source:
|
||||
description: 'Trigger source (audit trail)'
|
||||
required: false
|
||||
default: manual
|
||||
type: choice
|
||||
options:
|
||||
- manual
|
||||
- nightly
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: pypi-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
env:
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "MervinPraison"
|
||||
git config user.email "[email protected]"
|
||||
gh auth setup-git
|
||||
|
||||
- name: Release summary
|
||||
run: |
|
||||
echo "Source: ${{ inputs.source }}"
|
||||
echo "Bump: ${{ inputs.bump }}"
|
||||
echo "Dry run: ${{ inputs.dry_run }}"
|
||||
|
||||
- name: Compute release versions
|
||||
id: versions
|
||||
env:
|
||||
BUMP: ${{ inputs.bump }}
|
||||
AGENTS_OVERRIDE: ${{ inputs.agents_version }}
|
||||
WRAPPER_OVERRIDE: ${{ inputs.wrapper_version }}
|
||||
SKIP_AGENTS: ${{ inputs.skip_agents }}
|
||||
run: |
|
||||
python <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
|
||||
root = Path(".")
|
||||
bump = os.environ["BUMP"]
|
||||
|
||||
def bump_version(version: str) -> str:
|
||||
parts = version.split(".")
|
||||
if len(parts) != 3:
|
||||
print(f"Invalid version: {version}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
maj, min_, patch = (int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
if bump == "major":
|
||||
return f"{maj + 1}.0.0"
|
||||
if bump == "minor":
|
||||
return f"{maj}.{min_ + 1}.0"
|
||||
return f"{maj}.{min_}.{patch + 1}"
|
||||
|
||||
def validate_version(label: str, value: str) -> None:
|
||||
if value and not SEMVER.match(value):
|
||||
print(f"Invalid {label} override: {value!r} (expected X.Y.Z)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
agents_override = os.environ.get("AGENTS_OVERRIDE", "").strip()
|
||||
wrapper_override = os.environ.get("WRAPPER_OVERRIDE", "").strip()
|
||||
validate_version("agents_version", agents_override)
|
||||
validate_version("wrapper_version", wrapper_override)
|
||||
|
||||
agents_pyproject = root / "src/praisonai-agents/pyproject.toml"
|
||||
agents_content = agents_pyproject.read_text()
|
||||
agents_match = re.search(r'^version\s*=\s*"([^"]+)"', agents_content, re.MULTILINE)
|
||||
if not agents_match:
|
||||
print("Could not read agents version from pyproject.toml", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
current_agents = agents_match.group(1)
|
||||
if not SEMVER.match(current_agents):
|
||||
print(f"Invalid current agents version: {current_agents}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
wrapper_version_py = root / "src/praisonai/praisonai/version.py"
|
||||
wrapper_content = wrapper_version_py.read_text()
|
||||
wrapper_match = re.search(r'__version__ = "([^"]+)"', wrapper_content)
|
||||
if not wrapper_match:
|
||||
print("Could not read wrapper version from version.py", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
current_wrapper = wrapper_match.group(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"
|
||||
|
||||
if agents_override:
|
||||
agents_version = agents_override
|
||||
elif skip_agents:
|
||||
agents_version = current_agents
|
||||
else:
|
||||
agents_version = bump_version(current_agents)
|
||||
|
||||
if wrapper_override:
|
||||
wrapper_version = wrapper_override
|
||||
else:
|
||||
wrapper_version = bump_version(current_wrapper)
|
||||
|
||||
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"wrapper_version={wrapper_version}\n")
|
||||
fh.write(f"current_agents={current_agents}\n")
|
||||
fh.write(f"current_wrapper={current_wrapper}\n")
|
||||
|
||||
print(f"Agents: {current_agents} -> {agents_version}")
|
||||
print(f"Wrapper: {current_wrapper} -> {wrapper_version}")
|
||||
PY
|
||||
|
||||
- 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 "Wrapper version: ${{ steps.versions.outputs.wrapper_version }}"
|
||||
echo ""
|
||||
if [ "${{ inputs.skip_agents }}" != "true" ]; then
|
||||
echo "Would publish praisonaiagents ${{ steps.versions.outputs.agents_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 }}"
|
||||
echo "Would uv publish praisonai ${{ steps.versions.outputs.wrapper_version }}"
|
||||
fi
|
||||
|
||||
- name: Publish praisonaiagents
|
||||
if: inputs.dry_run != true && inputs.skip_agents != true
|
||||
working-directory: src/praisonai-agents
|
||||
env:
|
||||
NEW_VERSION: ${{ steps.versions.outputs.agents_version }}
|
||||
CURRENT: ${{ steps.versions.outputs.current_agents }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
path = Path("pyproject.toml")
|
||||
content = path.read_text()
|
||||
current = os.environ["CURRENT"]
|
||||
new = os.environ["NEW_VERSION"]
|
||||
path.write_text(content.replace(f'version = "{current}"', f'version = "{new}"', 1))
|
||||
PY
|
||||
|
||||
rm -rf dist
|
||||
uv lock
|
||||
uv build
|
||||
uv publish
|
||||
|
||||
- name: Wait for praisonaiagents on PyPI
|
||||
if: inputs.dry_run != true && inputs.skip_agents != true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE="praisonaiagents"
|
||||
VERSION="${{ steps.versions.outputs.agents_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 agents version bump
|
||||
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
|
||||
git diff --cached --quiet && { echo "No agents files to commit"; exit 0; }
|
||||
git commit -m "Bump praisonaiagents to ${{ steps.versions.outputs.agents_version }}"
|
||||
git pull --rebase origin main
|
||||
git push origin main
|
||||
|
||||
- name: Bump and release wrapper
|
||||
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 }}" \
|
||||
--wait --max-wait 600 --no-add-all
|
||||
|
||||
- name: Publish praisonai wrapper to PyPI
|
||||
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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE="praisonai"
|
||||
VERSION="${{ steps.versions.outputs.wrapper_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: Verify PyPI versions
|
||||
if: inputs.dry_run != true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
AGENTS="${{ steps.versions.outputs.agents_version }}"
|
||||
WRAPPER="${{ steps.versions.outputs.wrapper_version }}"
|
||||
|
||||
if [ "${{ inputs.skip_agents }}" != "true" ]; then
|
||||
echo "✅ praisonaiagents==${AGENTS}"
|
||||
fi
|
||||
|
||||
if [ "${{ inputs.skip_wrapper }}" != "true" ]; then
|
||||
echo "✅ praisonai==${WRAPPER}"
|
||||
fi
|
||||
@@ -8,15 +8,18 @@
|
||||
|
||||
name: Upload Python Package
|
||||
|
||||
# Superseded by pypi-release.yml (manual workflow_dispatch with uv publish).
|
||||
# Kept as workflow_dispatch-only fallback; release trigger removed to avoid
|
||||
# duplicate PyPI publish when bump_and_release creates a GitHub release.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: false
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
# PraisonAI Architecture
|
||||
|
||||
> **Last updated:** 2026-06-24
|
||||
>
|
||||
> Strategic architecture document for PraisonAI — a multi-agent AI framework.
|
||||
> 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. [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)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
PraisonAI is a multi-agent AI framework with broad capability surface across
|
||||
Python, TypeScript, and Rust SDKs. The framework's core strength is its
|
||||
feature breadth — agent abstractions, integrations, workflows, and active
|
||||
release cadence.
|
||||
|
||||
The strategic bet for the next two quarters is building a **Reliability +
|
||||
Orchestration + Observability** core to unlock adoption and trust:
|
||||
|
||||
| Dimension | Current State | Target |
|
||||
|-----------|--------------|--------|
|
||||
| Reliability | Cross-platform mismatches, optional dep fragility | Deterministic gating, adapter abstraction, parity CI |
|
||||
| Determinism | Flaky tests, global state collisions | Isolated test fixtures, traceable tenant IDs |
|
||||
| Developer UX | Complex onboarding, implicit config | Golden-path CLI, Doctor Auto-Fix |
|
||||
| Observability | Minimal runtime tracing | Structured event bus, replay engine, failure classifier |
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Deterministic by default**, flexible by opt-in
|
||||
- **Policy-enforced execution boundaries** at every layer
|
||||
- **Structured events** for all lifecycle transitions
|
||||
- **Capability isolation** per agent and per tool
|
||||
|
||||
---
|
||||
|
||||
## 2. System Overview
|
||||
|
||||
```
|
||||
User/SDK/CLI
|
||||
→ Workflow Compiler (graph + policy + schemas)
|
||||
→ Execution Orchestrator (state machine, retries, fallbacks)
|
||||
→ Tool Runtime (sandbox + approval + capability scope)
|
||||
→ Model Runtime (provider abstraction + rate/timeout policy)
|
||||
→ Observability Bus (trace events + metrics + cost)
|
||||
→ Replay Engine (checkpoint + deterministic re-run)
|
||||
→ Persistence (session, memory, artifacts, run ledger)
|
||||
```
|
||||
|
||||
### Runtime Components
|
||||
|
||||
| Component | Responsibility | Interface |
|
||||
|-----------|---------------|-----------|
|
||||
| API/CLI Gateway | Accepts run requests, validates config/profile | `RunRequest`, `RunProfile` |
|
||||
| Workflow Compiler | Converts YAML/DSL to normalized execution graph | `CompiledGraph` |
|
||||
| Orchestrator | Executes graph state machine with retries/fallbacks | `ExecutionState`, `StepTransition` |
|
||||
| Tool Runtime | Runs tools with approval/sandbox policies | `ToolCall`, `ToolResult` |
|
||||
| Model Runtime | Provider routing, timeout, budget control | `ModelRequest`, `ModelResponse` |
|
||||
| Observability Bus | Streams structured events + metrics | `RunEvent` schema |
|
||||
| Replay Engine | Restarts from checkpoint with deterministic inputs | `ReplayRequest` |
|
||||
|
||||
### Existing Architecture (Python SDK)
|
||||
|
||||
The Python SDK is the primary runtime and is organised as follows:
|
||||
|
||||
- `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 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/`)
|
||||
|
||||
---
|
||||
|
||||
## 3. Layered Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph UX["Interface Layer"]
|
||||
CLI[CLI]
|
||||
SDK[SDK]
|
||||
API[API Gateway]
|
||||
end
|
||||
|
||||
subgraph Control["Control Plane"]
|
||||
Compiler[Workflow Compiler]
|
||||
Policy[Policy Engine]
|
||||
Orchestrator[Execution Orchestrator]
|
||||
end
|
||||
|
||||
subgraph Runtime["Runtime Plane"]
|
||||
ModelRT[Model Runtime]
|
||||
ToolRT[Tool Runtime]
|
||||
MemoryRT[Memory Runtime]
|
||||
end
|
||||
|
||||
subgraph Observe["Observability Plane"]
|
||||
EventBus[Run Event Bus]
|
||||
Metrics[Metrics + Cost]
|
||||
Replay[Replay Engine]
|
||||
end
|
||||
|
||||
subgraph Data["Data Plane"]
|
||||
Ledger[Run Ledger]
|
||||
Checkpoints[Checkpoints]
|
||||
Artifacts[Artifacts Store]
|
||||
end
|
||||
|
||||
CLI --> API
|
||||
SDK --> API
|
||||
API --> Compiler
|
||||
API --> Policy
|
||||
Compiler --> Orchestrator
|
||||
Policy --> Orchestrator
|
||||
Orchestrator --> ModelRT
|
||||
Orchestrator --> ToolRT
|
||||
Orchestrator --> MemoryRT
|
||||
Orchestrator --> EventBus
|
||||
EventBus --> Metrics
|
||||
EventBus --> Replay
|
||||
Orchestrator --> Ledger
|
||||
Orchestrator --> Checkpoints
|
||||
ToolRT --> Artifacts
|
||||
Replay --> Checkpoints
|
||||
```
|
||||
|
||||
### Interface Layer
|
||||
|
||||
- **CLI** — The `praisonai` command-line entry point
|
||||
- **SDK** — Python library API (`from praisonai import Agent`)
|
||||
- **API Gateway** — HTTP/WebSocket REST API (`praisonai api` or `a2a`/`a2ui`)
|
||||
|
||||
### Control Plane
|
||||
|
||||
- **Workflow Compiler** — Converts YAML workflows and SDK-defined graphs into
|
||||
a normalized `CompiledGraph` with adjacency validation and cycle detection.
|
||||
- **Policy Engine** — Enforces runtime policies (budget, timeout, approval
|
||||
gates, capability validation) before and during execution.
|
||||
- **Execution Orchestrator** — Drives the state machine through graph nodes,
|
||||
handling retry, fallback, and failure classification.
|
||||
|
||||
### Runtime Plane
|
||||
|
||||
- **Model Runtime** — Provider abstraction layer. Routes to OpenAI, Anthropic,
|
||||
Gemini, Ollama, DeepSeek, etc. Handles rate limiting, token tracking, and
|
||||
automatic failover between providers.
|
||||
- **Tool Runtime** — Executes tool calls with configurable sandboxing,
|
||||
approval gates, retry policies, and capability scope validation.
|
||||
- **Memory Runtime** — Manages session memory, persistent memory stores,
|
||||
and knowledge retrieval across in-memory, SQLite, and MongoDB backends.
|
||||
|
||||
### Observability Plane
|
||||
|
||||
- **Run Event Bus** — Structured event streaming for all lifecycle transitions
|
||||
(START, INPUT, MODEL_CALL, TOOL_CALL, ERROR, RETRY, COMPLETE).
|
||||
- **Metrics + Cost** — Token counting, cost tracking, and performance metrics.
|
||||
- **Replay Engine** — Deterministic replay from the last stable checkpoint,
|
||||
with state hash verification.
|
||||
|
||||
### Data Plane
|
||||
|
||||
- **Run Ledger** — Persistent record of all runs with state, metrics, and
|
||||
failure classifications.
|
||||
- **Checkpoints** — Point-in-time snapshots of run state, memory, and
|
||||
artifacts for replay and recovery.
|
||||
- **Artifacts Store** — Tool outputs, generated files, and intermediate
|
||||
results.
|
||||
|
||||
---
|
||||
|
||||
## 4. Core Data Contracts
|
||||
|
||||
### RunRequest
|
||||
|
||||
```text
|
||||
RunRequest {
|
||||
run_id: UUID,
|
||||
workflow_ref: string,
|
||||
inputs: object,
|
||||
profile: {safe_mode, budget, timeout, approval_policy},
|
||||
context: {user_id, workspace_id, env}
|
||||
}
|
||||
```
|
||||
|
||||
### RunEvent
|
||||
|
||||
```text
|
||||
RunEvent {
|
||||
ts: ISO8601,
|
||||
run_id: UUID,
|
||||
step_id: string,
|
||||
type: START | INPUT | MODEL_CALL | TOOL_CALL |
|
||||
ERROR | RETRY | COMPLETE,
|
||||
payload: object,
|
||||
cost: {tokens_in, tokens_out, usd},
|
||||
latency_ms: number
|
||||
}
|
||||
```
|
||||
|
||||
### Checkpoint
|
||||
|
||||
```text
|
||||
Checkpoint {
|
||||
run_id: UUID,
|
||||
step_id: string,
|
||||
state_hash: string,
|
||||
memory_snapshot_ref: string,
|
||||
artifact_refs: string[]
|
||||
}
|
||||
```
|
||||
|
||||
### Execution Sequence
|
||||
|
||||
```text
|
||||
1) Client submits RunRequest
|
||||
2) Gateway validates profile + schema
|
||||
3) Compiler resolves workflow → CompiledGraph
|
||||
4) Orchestrator starts node execution
|
||||
5) Node may call Model Runtime or Tool Runtime
|
||||
6) Every transition emits RunEvent
|
||||
7) On failure: classifier decides retry/fallback/abort
|
||||
8) Checkpoint stored after each critical step
|
||||
9) Final state + artifacts persisted to run ledger
|
||||
10) Replay can resume from last stable checkpoint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Reliability by Design
|
||||
|
||||
### Error Taxonomy
|
||||
|
||||
All failures are classified into one of five categories for automatic
|
||||
remediation:
|
||||
|
||||
| Category | Description | Default Action |
|
||||
|----------|-------------|----------------|
|
||||
| `config` | Invalid configuration or profile | Abort with remediation hint |
|
||||
| `dependency` | Missing optional module or capability | Degrade / skip optional path |
|
||||
| `tool` | Tool execution failure | Retry with backoff and policy cap |
|
||||
| `model` | Model API error or timeout | Fallback to alternate provider/route |
|
||||
| `infra` | Network or resource exhaustion | Circuit-breaker + replay from checkpoint |
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S[Step Failure] --> T{Failure Type?}
|
||||
T -->|Config| C1[Abort + config remediation hint]
|
||||
T -->|Dependency| C2[Degrade capability / skip optional path]
|
||||
T -->|Tool| C3[Retry with backoff and policy cap]
|
||||
T -->|Model| C4[Fallback model/provider route]
|
||||
T -->|Infra| C5[Circuit-breaker + replay from checkpoint]
|
||||
```
|
||||
|
||||
### Hardening Checklist
|
||||
|
||||
- **Dependency gates** — Optional modules declared as capabilities;
|
||||
unavailable capability results in skip/degrade, not crash.
|
||||
- **Cross-platform adapters** — OS-specific implementations behind a single
|
||||
interface (e.g., file lock adapter for Windows/Linux/macOS).
|
||||
- **Idempotent fixtures** — Integration tests use unique tenant/workspace IDs
|
||||
and guaranteed teardown.
|
||||
- **Fail-safe output mode** — CLI rendering falls back to ASCII-safe mode on
|
||||
encoding mismatch.
|
||||
- **CI parity matrix** — Run smoke workflows across Windows, Linux, and macOS.
|
||||
|
||||
### Deterministic Test Pattern
|
||||
|
||||
```python
|
||||
# Integration test pattern — always use unique, traceable IDs
|
||||
import uuid
|
||||
|
||||
def test_agent_workflow():
|
||||
tenant_id = f"test-{uuid.uuid4().hex[:8]}"
|
||||
agent = Agent(name=f"agent-{tenant_id}", ...)
|
||||
result = agent.run("task")
|
||||
assert result.status == "success"
|
||||
# teardown is guaranteed via fixture or context manager
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Observability & Telemetry
|
||||
|
||||
### Current Telemetry Stack
|
||||
|
||||
PraisonAI already includes:
|
||||
|
||||
- **OpenTelemetry integration** — Manual and auto-instrumentation for
|
||||
traces, metrics, and logs.
|
||||
- **Token tracking** — Per-call and cumulative token/cost tracking.
|
||||
- **Performance monitoring** — Real-time dashboards and monitoring views.
|
||||
- **LangTrace integration** — LangTrace provider for tracing.
|
||||
- **Run outcomes** — Structured `RunOutcome` objects with status, duration,
|
||||
token usage, and error classification.
|
||||
|
||||
### Planned Enhancements
|
||||
|
||||
- **Run Event Bus** — Stream all lifecycle events as structured `RunEvent`
|
||||
payloads.
|
||||
- **Failure classifier** — Automatic failure category detection with
|
||||
remediation hints.
|
||||
- **Run ledger visualization** — Timeline view of runs with step-by-step
|
||||
event drill-down.
|
||||
- **Replay integration** — Checkpoint-based run replay from the
|
||||
observability dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 7. Replay & Checkpointing
|
||||
|
||||
### Design
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User/Client
|
||||
participant G as Gateway
|
||||
participant C as Compiler
|
||||
participant O as Orchestrator
|
||||
participant R as Model/Tool Runtime
|
||||
participant E as Event Bus
|
||||
participant L as Run Ledger
|
||||
participant K as Checkpoint Store
|
||||
|
||||
U->>G: Submit RunRequest
|
||||
G->>C: Validate + compile workflow
|
||||
C-->>O: CompiledGraph
|
||||
O->>R: Execute next step
|
||||
R-->>O: Step output / error
|
||||
O->>E: Emit structured RunEvent
|
||||
O->>K: Persist checkpoint
|
||||
O->>L: Persist run state + metrics
|
||||
O-->>U: Final result / failure summary
|
||||
```
|
||||
|
||||
### Current State
|
||||
|
||||
- Basic run outcome persistence exists
|
||||
- Session persistence for bot/multi-turn conversations
|
||||
- Token and cost tracking per run
|
||||
|
||||
### Roadmap
|
||||
|
||||
- Full checkpoint creation at configurable step granularity
|
||||
- State hash verification for deterministic replay
|
||||
- Replay API endpoint (`POST /runs/{id}/replay`)
|
||||
- Checkpoint pruning and retention policy
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation Roadmap
|
||||
|
||||
### Quarter 1: Trust Foundation (Q3 2026)
|
||||
|
||||
| Priority | Project | Description | Status |
|
||||
|----------|---------|-------------|--------|
|
||||
| P0 | Reliability Core | Cross-platform hardening, optional-dep gating, deterministic fixtures | In progress |
|
||||
| P0 | Doctor Auto-Fix | Automated environment diagnosis and repairs | Planned |
|
||||
| P1 | Golden-Path CLI | Single canonical flow: init → run → test → deploy | Planned |
|
||||
| P1 | CI Parity Matrix | Cross-platform smoke tests in CI pipeline | Planned |
|
||||
|
||||
### Quarter 2: Operational Excellence (Q4 2026)
|
||||
|
||||
| Priority | Project | Description | Status |
|
||||
|----------|---------|-------------|--------|
|
||||
| P1 | Trace + Replay | Run timeline, checkpoint replay, failure classifier | Planned |
|
||||
| P1 | Failure Classifier | Automatic failure category detection with remediation | Planned |
|
||||
| P2 | Graph Studio UX | Visual deterministic orchestration editor/inspector | Planned |
|
||||
| P2 | Safe Production Profiles | Policy presets: guardrails, approvals, cost/time caps | Planned |
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title PraisonAI Architecture Program (2 Quarters)
|
||||
dateFormat YYYY-MM-DD
|
||||
section Quarter 1
|
||||
Reliability hardening + parity CI :a1, 2026-07-01, 45d
|
||||
Deterministic test isolation :a2, 2026-07-10, 50d
|
||||
Doctor Auto-Fix MVP :a3, 2026-07-20, 40d
|
||||
Golden-path CLI :a4, 2026-08-01, 45d
|
||||
section Quarter 2
|
||||
Trace bus + run ledger :b1, 2026-10-01, 45d
|
||||
Replay engine :b2, 2026-10-15, 45d
|
||||
Failure classifier :b3, 2026-11-01, 30d
|
||||
Graph UX beta + Safe profiles :b4, 2026-11-10, 50d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Success Metrics
|
||||
|
||||
### KPI Targets
|
||||
|
||||
| KPI | Target | Primary Workstream |
|
||||
|-----|--------|-------------------|
|
||||
| Time-to-first-successful run | 30-40% reduction | Golden-path CLI + Doctor Auto-Fix |
|
||||
| Cross-platform issue rate | 50% reduction | Reliability Core + adapter layer |
|
||||
| Incident triage time | 40% reduction | Trace + Replay + failure classifier |
|
||||
| CI confidence | Flaky test rate <2% | Deterministic tests + fixture isolation |
|
||||
|
||||
### Governance
|
||||
|
||||
- **Architecture Review Council** — Reviews runtime-contract changes
|
||||
- **Release Quality Gate** — Platform matrix + deterministic test thresholds
|
||||
- **Monthly telemetry review** — DX, reliability, adoption trends
|
||||
|
||||
|
||||
---
|
||||
|
||||
*This document is a living reference. Update as the architecture evolves.*
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
# Security Policy
|
||||
|
||||
PraisonAI takes security seriously. We appreciate responsible disclosure from researchers and users.
|
||||
|
||||
## Supported packages
|
||||
|
||||
| Package | Registry | Source path |
|
||||
|---------|----------|-------------|
|
||||
| `praisonaiagents` | [PyPI](https://pypi.org/project/praisonaiagents/) | `src/praisonai-agents/` |
|
||||
| `praisonai` | [PyPI](https://pypi.org/project/praisonai/) | `src/praisonai/` |
|
||||
| `praisonai-platform` | [PyPI](https://pypi.org/project/praisonai-platform/) | `src/praisonai-platform/` |
|
||||
| `praisonai` | [npm](https://www.npmjs.com/package/praisonai) | `src/praisonai-ts/` |
|
||||
|
||||
Report issues against the **specific package** where the vulnerability exists. A platform-only issue does not necessarily affect the core SDK, and vice versa.
|
||||
|
||||
## In scope
|
||||
|
||||
- Authentication, authorisation, and multi-tenant isolation (e.g. workspace boundaries)
|
||||
- Tool execution, sandbox escape, and code execution paths
|
||||
- SSRF, injection, and unsafe deserialisation in shipped code
|
||||
- MCP, gateway, and bot/webhook integrations
|
||||
- Secrets handling, JWT/session configuration, and default credentials
|
||||
- Supply-chain issues in published package artefacts
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Issues in forks or deployments not maintained in [MervinPraison/PraisonAI](https://github.com/MervinPraison/PraisonAI)
|
||||
- Vulnerabilities requiring physical access or fully compromised operator machines
|
||||
- Reports against versions no longer published or without a supported upgrade path
|
||||
- Theoretical issues with no practical impact in documented, supported configurations
|
||||
- Behaviour covered by an explicit, documented opt-out (e.g. dev-only env vars)
|
||||
|
||||
## How to report
|
||||
|
||||
**Preferred:** open a **private** security advisory:
|
||||
|
||||
[https://github.com/MervinPraison/PraisonAI/security/advisories/new](https://github.com/MervinPraison/PraisonAI/security/advisories/new)
|
||||
|
||||
Include:
|
||||
|
||||
1. Affected package and version
|
||||
2. Clear reproduction steps
|
||||
3. Impact assessment (confidentiality, integrity, availability)
|
||||
4. Suggested fix (optional)
|
||||
|
||||
Please allow reasonable time for triage and a patched release before public disclosure.
|
||||
|
||||
## What to expect
|
||||
|
||||
1. **Triage** — we validate the report and assign a disposition (`real`, `duplicate`, `wontfix`, etc.)
|
||||
2. **Fix** — minimal, backward-compatible patch on `main` where appropriate
|
||||
3. **Release** — patched version published to PyPI/npm **before** the advisory is published
|
||||
4. **Advisory** — GitHub Security Advisory with reporter credit and CVE where applicable
|
||||
|
||||
## Resources
|
||||
|
||||
- **Repository:** https://github.com/MervinPraison/PraisonAI
|
||||
- **Documentation:** https://docs.praison.ai
|
||||
- **Website:** https://praison.ai
|
||||
- **Advisories:** https://github.com/MervinPraison/PraisonAI/security/advisories
|
||||
|
||||
## Maintainer workflow
|
||||
|
||||
Contributors and agents handling audits should follow:
|
||||
|
||||
`src/praisonai-agents/.agent/workflows/security-audit.md`
|
||||
@@ -30,6 +30,7 @@ from praisonaiagents import Agent, AutoAgents, AutoRagAgent, ContextAgent, DeepR
|
||||
Methods:
|
||||
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">aclose</a>() -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">add_mcp_server</a>(name: str, mcp: Any) -> Any</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">agent_id</a>() -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">analyze_prompt</a>(prompt: str) -> set</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>() -> Optional[bool]</code>
|
||||
@@ -59,6 +60,7 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to_async</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">is_closed</a>() -> bool</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">list_mcp_servers</a>() -> List[str]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_instance</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_instance</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_model</a>() -> Optional[str]</code>
|
||||
@@ -70,6 +72,8 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rag</a>() -> Optional[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rag_query</a>(question: str, **kwargs) -> 'RAGResult'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">redo</a>() -> bool</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">refresh_tools</a>() -> List[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">remove_mcp_server</a>(name: str) -> bool</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieval_config</a>() -> Optional[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieve</a>(query: str, **kwargs) -> 'ContextPack'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rules_manager</a>() -> Optional[Any]</code>
|
||||
@@ -479,11 +483,16 @@ Methods:
|
||||
* <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_skill</a>(skill_path: str) -> 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">clear</a>() -> None</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) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete_skill</a>(name: str) -> dict</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">edit_skill</a>(name: str, content: str) -> dict</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>
|
||||
@@ -492,15 +501,24 @@ Methods:
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skills_diagnostics</a>() -> Dict[str, ValidationResult]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_user_invocable_skills</a>() -> List[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">invoke</a>(name: str, raw_args: str = '', session_id: Optional[str] = None, shell_exec: bool = False) -> Optional[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">list_archived_skills</a>() -> List[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">list_pending</a>() -> List[dict]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">load_resources</a>(name: str) -> bool</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch_skill</a>(name: str, old_string: str, new_string: str, file_path: str = None, replace_all: bool = False) -> 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) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch</a>(name: str, old_string: str, new_string: str, file_path: Optional[str] = None, replace_all: bool = False, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch_skill</a>(name: str, old_string: str, new_string: str, file_path: str = None, replace_all: bool = False, propose: Optional[bool] = None) -> dict</code>
|
||||
* <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">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>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skills</a>() -> List[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">to_prompt</a>() -> str</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">validate_skill_capabilities</a>(skill_name: str, force_refresh: bool = False) -> ValidationResult</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_skill_file</a>(name: str, file_path: str, file_content: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_file</a>(name: str, file_path: str, file_content: str, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_skill_file</a>(name: str, file_path: str, file_content: str, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillMetadata">SkillMetadata.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">from_properties</a>(props: SkillProperties) -> 'SkillMetadata'</code>
|
||||
* <code title="class SkillProperties">SkillProperties.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">idle_days</a>() -> Optional[float]</code>
|
||||
* <code title="class SkillProperties">SkillProperties.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">to_dict</a>() -> dict</code>
|
||||
* <code title="class SkillsConfig">SkillsConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
|
||||
@@ -801,6 +819,7 @@ Methods:
|
||||
* <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>
|
||||
@@ -843,6 +862,11 @@ Methods:
|
||||
* <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>
|
||||
@@ -872,6 +896,10 @@ Methods:
|
||||
* <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>
|
||||
@@ -893,19 +921,18 @@ Methods:
|
||||
* <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 config <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor db <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor doctor-callback <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 fix <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 tools <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>
|
||||
@@ -945,6 +972,11 @@ Methods:
|
||||
* <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>
|
||||
@@ -1099,6 +1131,8 @@ Methods:
|
||||
* <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>
|
||||
@@ -1108,6 +1142,10 @@ Methods:
|
||||
* <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>
|
||||
@@ -1143,6 +1181,7 @@ Methods:
|
||||
* <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>
|
||||
@@ -1184,6 +1223,9 @@ Methods:
|
||||
* <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>
|
||||
@@ -1335,7 +1377,7 @@ export { // Python parity additions
|
||||
MinimalTelemetry, AgentTelemetry, PerformanceMonitor, TelemetryCollector, TelemetryIntegration, cleanupTelemetry, cleanupTelemetryResources, createAgentTelemetry, createConsoleSink, createHTTPSink, createPerformanceMonitor, createTelemetryIntegration, disablePerformanceMode, disableTelemetry, enablePerformanceMode, enableTelemetry, getMinimalTelemetry, getTelemetry } from "./telemetry";
|
||||
export { // Subagent Tool (agent-as-tool pattern)
|
||||
SubagentTool, BaseTool, FunctionTool, ToolRegistry, ToolResult, ToolValidationError, createDelegator, createSubagentTool, createSubagentTools, createTool, getRegistry, getTool, registerTool, tool, validateTool } from "./tools";
|
||||
export { airweaveSearch, bedrockBrowserClick, bedrockBrowserFill, bedrockBrowserNavigate, bedrockCodeInterpreter, codeExecution, codeMode, createCustomTool, exaSearch, firecrawlCrawl, firecrawlScrape, parallelSearch, perplexitySearch, registerCustomTool, registerLocalTool, registerNpmTool, superagentGuard, superagentRedact, superagentVerify, tavilyCrawl, tavilyExtract, tavilySearch, valyuBioSearch, valyuCompanyResearch, valyuEconomicsSearch, valyuFinanceSearch, valyuPaperSearch, valyuPatentSearch, valyuSecSearch, valyuWebSearch } from "./tools/builtins";
|
||||
export { airweaveSearch, bedrockBrowserClick, bedrockBrowserFill, bedrockBrowserNavigate, bedrockCodeInterpreter, codeExecution, codeMode, createCustomTool, crwCrawl, crwScrape, exaSearch, firecrawlCrawl, firecrawlScrape, parallelSearch, perplexitySearch, registerCustomTool, registerLocalTool, registerNpmTool, superagentGuard, superagentRedact, superagentVerify, tavilyCrawl, tavilyExtract, tavilySearch, valyuBioSearch, valyuCompanyResearch, valyuEconomicsSearch, valyuFinanceSearch, valyuPaperSearch, valyuPatentSearch, valyuSecSearch, valyuWebSearch } from "./tools/builtins";
|
||||
export { BudgetExceededError, MissingDependencyError, MissingEnvVarError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getToolsRegistry, get_registry, get_tool, register_tool, resetToolsRegistry, validate_tool } from "./tools/registry";
|
||||
export type { InstallHints, PraisonTool, RedactionHooks, RegisteredTool, ToolCapabilities, ToolExecutionContext, ToolExecutionResult, ToolFactory, ToolHooks, ToolInstallStatus, ToolLimits, ToolLogger, ToolMetadata, ToolMiddleware, ToolParameterProperty, ToolParameterSchema } from "./tools/registry";
|
||||
export { registerBuiltinTools, tools } from "./tools/tools";
|
||||
|
||||
@@ -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.70" \
|
||||
"praisonai>=4.6.80" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -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.70" \
|
||||
"praisonai>=4.6.80" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -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.70" \
|
||||
"praisonai>=4.6.80" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# Industry Templates for PraisonAI
|
||||
|
||||
This directory contains industry-specific agent templates based on the SRAO Framework, providing pre-built workflows for rapid deployment of agent workforces across different industries.
|
||||
|
||||
## Overview
|
||||
|
||||
These templates demonstrate how PraisonAI can be used to create specialized agent teams for various industries, with **70% code reuse** across different sectors through shared patterns and abstractions.
|
||||
|
||||
## Available Templates
|
||||
|
||||
### 1. Manufacturing (`manufacturing_template.py`)
|
||||
**Key Agents:**
|
||||
- **ParseOrder**: Extracts and structures order information from various formats
|
||||
- **CheckInventory**: Validates material availability and supply chain status
|
||||
- **OptimizeSchedule**: Optimizes production scheduling based on constraints
|
||||
- **DefectDetect**: Quality control and defect detection
|
||||
|
||||
**Features:**
|
||||
- Order processing with multiple input formats
|
||||
- Real-time inventory management
|
||||
- Production optimization algorithms
|
||||
- Quality inspection with defect detection
|
||||
- Fallback strategies for each critical step
|
||||
|
||||
### 2. Energy (`energy_template.py`)
|
||||
**Key Agents:**
|
||||
- **SCADAReader**: Processes SCADA telemetry data from wind turbines
|
||||
- **VibrationAnalyzer**: Analyzes vibration patterns for predictive maintenance
|
||||
- **PowerForecaster**: Predicts power generation based on weather and history
|
||||
- **MaintenanceScheduler**: Schedules predictive maintenance
|
||||
|
||||
**Features:**
|
||||
- Real-time wind farm monitoring
|
||||
- Vibration-based fault detection
|
||||
- Power generation forecasting
|
||||
- Grid integration and load balancing
|
||||
- Predictive maintenance scheduling
|
||||
|
||||
### 3. Healthcare (`healthcare_template.py`)
|
||||
**Key Agents:**
|
||||
- **VitalSignsCapture**: Processes patient vital signs from monitoring devices
|
||||
- **EMRRetrieval**: Retrieves electronic medical records with HIPAA compliance
|
||||
- **TriageRecommendation**: Provides ESI-based triage recommendations
|
||||
- **ResourceAllocator**: Manages hospital resource allocation
|
||||
|
||||
**Features:**
|
||||
- Emergency triage workflow
|
||||
- HIPAA-compliant data handling
|
||||
- Patient safety protocols
|
||||
- Resource optimization
|
||||
- Cross-department coordination
|
||||
|
||||
### 4. Agriculture (`agriculture_template.py`)
|
||||
**Key Agents:**
|
||||
- **MultispectralAnalyzer**: Analyzes multispectral imagery for crop health
|
||||
- **DiseaseIdentifier**: Identifies crop diseases and pests using AI
|
||||
- **SprayRecommender**: Recommends targeted spraying strategies
|
||||
- **YieldPredictor**: Predicts crop yield based on conditions
|
||||
|
||||
**Features:**
|
||||
- Precision agriculture with drone/satellite imagery
|
||||
- Pest and disease early warning system
|
||||
- Optimized chemical application
|
||||
- Yield forecasting
|
||||
- Sustainable farming patterns
|
||||
|
||||
### 5. Transportation (`transportation_template.py`)
|
||||
**Key Agents:**
|
||||
- **LiDARFusion**: Processes LiDAR point cloud data for infrastructure
|
||||
- **DisplacementCalculator**: Calculates structural displacement
|
||||
- **HeatmapGenerator**: Generates safety visualization heatmaps
|
||||
- **MaintenancePlanner**: Plans infrastructure maintenance
|
||||
|
||||
**Features:**
|
||||
- Tunnel/bridge safety monitoring
|
||||
- LiDAR-based structural analysis
|
||||
- Real-time safety assessment
|
||||
- Traffic flow optimization
|
||||
- Predictive maintenance scheduling
|
||||
|
||||
## Key Features Across All Templates
|
||||
|
||||
### 1. I/O Schemas
|
||||
Each template includes Pydantic models for structured data handling:
|
||||
- Input validation
|
||||
- Type safety
|
||||
- Clear data contracts between agents
|
||||
|
||||
### 2. SLA Requirements
|
||||
Every agent has defined Service Level Agreements:
|
||||
- Processing time requirements
|
||||
- Response time guarantees
|
||||
- Performance benchmarks
|
||||
|
||||
### 3. Fallback Strategies
|
||||
Robust error handling with fallback mechanisms:
|
||||
- Graceful degradation
|
||||
- Manual intervention queues
|
||||
- Conservative estimates when systems fail
|
||||
|
||||
### 4. Cross-Industry Patterns (70% Reuse)
|
||||
Reusable base patterns that work across industries:
|
||||
- Data parsing agents
|
||||
- Resource availability checkers
|
||||
- Optimization engines
|
||||
- Inspection/quality control agents
|
||||
|
||||
## Usage Example
|
||||
|
||||
```python
|
||||
from manufacturing_template import (
|
||||
order_parser,
|
||||
inventory_checker,
|
||||
schedule_optimizer,
|
||||
quality_inspector,
|
||||
manufacturing_workflow
|
||||
)
|
||||
|
||||
# Process a manufacturing order
|
||||
order_text = "Customer ABC needs 100 units of Product-XYZ by March 15th, high priority"
|
||||
result = manufacturing_workflow(order_text)
|
||||
print(result)
|
||||
```
|
||||
|
||||
## Adapting Templates for Your Industry
|
||||
|
||||
### Using Base Patterns
|
||||
Each template includes reusable pattern classes that can be adapted:
|
||||
|
||||
```python
|
||||
from manufacturing_template import IndustryAgentPattern
|
||||
|
||||
# Create a custom data parser for any domain
|
||||
custom_parser = IndustryAgentPattern.create_data_parser(
|
||||
name="CustomParser",
|
||||
domain="retail",
|
||||
sla_seconds=20
|
||||
)
|
||||
|
||||
# Create a resource checker for any resource type
|
||||
resource_checker = IndustryAgentPattern.create_resource_checker(
|
||||
name="StaffAvailability",
|
||||
resource_type="customer_service_staff",
|
||||
sla_seconds=3
|
||||
)
|
||||
```
|
||||
|
||||
### Creating Custom Workflows
|
||||
Combine agents from different templates or create new ones:
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, tool
|
||||
|
||||
# Define custom tools
|
||||
@tool
|
||||
def custom_analysis(data: str) -> Dict:
|
||||
"""Your custom analysis logic"""
|
||||
return {"result": "analyzed"}
|
||||
|
||||
# Create custom agent
|
||||
custom_agent = Agent(
|
||||
name="CustomAnalyzer",
|
||||
instructions="Your specific instructions",
|
||||
tools=[custom_analysis]
|
||||
)
|
||||
```
|
||||
|
||||
## Integration with PraisonAI Core
|
||||
|
||||
These templates are built on top of PraisonAI's core Agent framework:
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, tool, AgentTeam, Task
|
||||
|
||||
# Combine multiple industry agents into a team
|
||||
team = AgentTeam(
|
||||
agents=[order_parser, inventory_checker, schedule_optimizer],
|
||||
tasks=[
|
||||
Task(name="parse", agent=order_parser),
|
||||
Task(name="check", agent=inventory_checker),
|
||||
Task(name="optimize", agent=schedule_optimizer)
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Start with Templates**: Use these as starting points and customize for your specific needs
|
||||
2. **Maintain SLAs**: Define clear performance requirements for each agent
|
||||
3. **Implement Fallbacks**: Always have fallback strategies for critical operations
|
||||
4. **Use Type Safety**: Leverage Pydantic models for data validation
|
||||
5. **Monitor Performance**: Track agent performance against SLAs
|
||||
6. **Iterate and Improve**: Continuously refine based on real-world usage
|
||||
|
||||
## Contributing
|
||||
|
||||
To add a new industry template:
|
||||
|
||||
1. Follow the existing template structure
|
||||
2. Include all key components:
|
||||
- I/O Schemas (Pydantic models)
|
||||
- Agent definitions with SLAs
|
||||
- Tools for agent capabilities
|
||||
- Complete workflow function
|
||||
- Fallback strategies
|
||||
- Cross-industry patterns
|
||||
3. Add comprehensive documentation
|
||||
4. Include usage examples
|
||||
|
||||
## License
|
||||
|
||||
These templates are part of the PraisonAI project and follow the same licensing terms.
|
||||
|
||||
## References
|
||||
|
||||
Based on the [SRAO Framework](https://github.com/beixuan577/SRAO-Framework) (MIT License)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Industry Templates for PraisonAI
|
||||
================================
|
||||
|
||||
Pre-built agent templates for rapid deployment across different industries.
|
||||
Based on SRAO Framework with 70% cross-industry code reuse.
|
||||
|
||||
Available templates:
|
||||
- Manufacturing: Order processing, inventory, scheduling, quality control
|
||||
- Energy: Wind farm monitoring, predictive maintenance, power forecasting
|
||||
- Healthcare: Emergency triage, EMR retrieval, resource allocation
|
||||
- Agriculture: Precision farming, pest detection, yield prediction
|
||||
- Transportation: Infrastructure monitoring, safety assessment, maintenance
|
||||
|
||||
Example usage:
|
||||
from Industry_Templates.manufacturing_template import manufacturing_workflow
|
||||
|
||||
result = manufacturing_workflow("Customer order text")
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "PraisonAI Team"
|
||||
|
||||
# Import the modules so they're accessible via the package
|
||||
from . import manufacturing_template
|
||||
from . import energy_template
|
||||
from . import healthcare_template
|
||||
from . import agriculture_template
|
||||
from . import transportation_template
|
||||
|
||||
# Export commonly used items for convenience
|
||||
__all__ = [
|
||||
"manufacturing_template",
|
||||
"energy_template",
|
||||
"healthcare_template",
|
||||
"agriculture_template",
|
||||
"transportation_template"
|
||||
]
|
||||
@@ -0,0 +1,551 @@
|
||||
"""
|
||||
Agriculture Industry Template
|
||||
=============================
|
||||
Precision agriculture and pest management workflow
|
||||
Based on SRAO Framework with IoT sensor integration
|
||||
|
||||
Key agents:
|
||||
- MultispectralAnalyzer: Analyzes multispectral imagery
|
||||
- DiseaseIdentifier: Identifies crop diseases and pests
|
||||
- SprayRecommender: Recommends targeted spraying strategies
|
||||
- YieldPredictor: Predicts crop yield based on conditions
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, tool
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# Crop health levels
|
||||
class CropHealthLevel(str, Enum):
|
||||
EXCELLENT = "excellent"
|
||||
GOOD = "good"
|
||||
FAIR = "fair"
|
||||
POOR = "poor"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
# Pest/Disease severity
|
||||
class SeverityLevel(str, Enum):
|
||||
NONE = "none"
|
||||
LOW = "low"
|
||||
MODERATE = "moderate"
|
||||
HIGH = "high"
|
||||
SEVERE = "severe"
|
||||
|
||||
|
||||
# I/O Schemas
|
||||
class MultispectralData(BaseModel):
|
||||
"""Multispectral imagery analysis data"""
|
||||
field_id: str
|
||||
capture_time: str
|
||||
ndvi_index: float # Normalized Difference Vegetation Index
|
||||
ndre_index: float # Normalized Difference Red Edge
|
||||
ccci_index: float # Canopy Chlorophyll Content Index
|
||||
moisture_level: float # Soil moisture percentage
|
||||
temperature: float # Celsius
|
||||
affected_area: float # Hectares
|
||||
gps_coordinates: Tuple[float, float]
|
||||
|
||||
|
||||
class PestDiseaseReport(BaseModel):
|
||||
"""Pest and disease identification report"""
|
||||
report_id: str
|
||||
field_id: str
|
||||
detection_time: str
|
||||
pest_type: Optional[str]
|
||||
disease_type: Optional[str]
|
||||
severity: SeverityLevel
|
||||
affected_crops: List[str]
|
||||
spread_rate: float # % per day
|
||||
environmental_factors: Dict[str, Any]
|
||||
confidence_score: float
|
||||
|
||||
|
||||
class SprayRecommendation(BaseModel):
|
||||
"""Targeted spraying recommendation"""
|
||||
recommendation_id: str
|
||||
field_id: str
|
||||
treatment_type: str # pesticide, fungicide, herbicide, fertilizer
|
||||
product_name: str
|
||||
application_rate: float # L/hectare
|
||||
target_zones: List[Dict[str, Any]] # GPS zones
|
||||
optimal_time: str
|
||||
weather_window: Dict[str, Any]
|
||||
cost_estimate: float
|
||||
environmental_impact: str # low, medium, high
|
||||
|
||||
|
||||
class YieldForecast(BaseModel):
|
||||
"""Crop yield prediction"""
|
||||
forecast_id: str
|
||||
field_id: str
|
||||
crop_type: str
|
||||
predicted_yield: float # tons/hectare
|
||||
confidence_interval: Tuple[float, float]
|
||||
harvest_window: Tuple[str, str]
|
||||
quality_grade: str # A, B, C
|
||||
market_price_estimate: float
|
||||
risk_factors: List[str]
|
||||
|
||||
|
||||
# Agriculture-specific tools
|
||||
@tool
|
||||
def analyze_multispectral_imagery(field_id: str, image_data: Dict) -> Dict:
|
||||
"""Analyze multispectral drone/satellite imagery for crop health"""
|
||||
# Simulate multispectral analysis
|
||||
ndvi = 0.75 # Healthy vegetation typically 0.6-0.9
|
||||
|
||||
# Determine health based on NDVI
|
||||
if ndvi > 0.8:
|
||||
health = CropHealthLevel.EXCELLENT
|
||||
affected_area = 0.0
|
||||
elif ndvi > 0.6:
|
||||
health = CropHealthLevel.GOOD
|
||||
affected_area = 2.5
|
||||
elif ndvi > 0.4:
|
||||
health = CropHealthLevel.FAIR
|
||||
affected_area = 5.0
|
||||
else:
|
||||
health = CropHealthLevel.POOR
|
||||
affected_area = 10.0
|
||||
|
||||
return {
|
||||
"field_id": field_id,
|
||||
"capture_time": datetime.now().isoformat(),
|
||||
"ndvi_index": ndvi,
|
||||
"ndre_index": 0.68,
|
||||
"ccci_index": 0.72,
|
||||
"moisture_level": 35.0,
|
||||
"temperature": 24.5,
|
||||
"affected_area": affected_area,
|
||||
"gps_coordinates": (40.7128, -74.0060),
|
||||
"health_level": health.value
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def identify_pest_disease(spectral_data: Dict, historical_data: List) -> Dict:
|
||||
"""Identify pests and diseases using AI image recognition"""
|
||||
# Simulate pest/disease detection
|
||||
ndvi = spectral_data.get("ndvi_index", 0.75)
|
||||
affected_area = spectral_data.get("affected_area", 0)
|
||||
|
||||
if affected_area > 8.0:
|
||||
pest_type = "aphids"
|
||||
disease_type = "powdery_mildew"
|
||||
severity = SeverityLevel.HIGH
|
||||
spread_rate = 2.5
|
||||
elif affected_area > 4.0:
|
||||
pest_type = "whiteflies"
|
||||
disease_type = "leaf_spot"
|
||||
severity = SeverityLevel.MODERATE
|
||||
spread_rate = 1.5
|
||||
elif affected_area > 0:
|
||||
pest_type = None
|
||||
disease_type = "early_blight"
|
||||
severity = SeverityLevel.LOW
|
||||
spread_rate = 0.5
|
||||
else:
|
||||
pest_type = None
|
||||
disease_type = None
|
||||
severity = SeverityLevel.NONE
|
||||
spread_rate = 0.0
|
||||
|
||||
return {
|
||||
"report_id": f"PDR-{datetime.now().strftime('%Y%m%d%H%M')}",
|
||||
"field_id": spectral_data["field_id"],
|
||||
"detection_time": datetime.now().isoformat(),
|
||||
"pest_type": pest_type,
|
||||
"disease_type": disease_type,
|
||||
"severity": severity.value,
|
||||
"affected_crops": ["wheat", "corn"],
|
||||
"spread_rate": spread_rate,
|
||||
"environmental_factors": {
|
||||
"humidity": 65,
|
||||
"temperature": spectral_data.get("temperature", 25),
|
||||
"rainfall_last_week": 15 # mm
|
||||
},
|
||||
"confidence_score": 0.85
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def calculate_spray_recommendation(pest_report: Dict, field_data: Dict, weather: Dict) -> Dict:
|
||||
"""Calculate optimal spraying strategy for pest/disease control"""
|
||||
severity = pest_report.get("severity", "none")
|
||||
|
||||
if severity == "high" or severity == "severe":
|
||||
treatment = "pesticide"
|
||||
product = "Imidacloprid"
|
||||
rate = 0.5
|
||||
impact = "medium"
|
||||
elif severity == "moderate":
|
||||
treatment = "fungicide"
|
||||
product = "Azoxystrobin"
|
||||
rate = 0.3
|
||||
impact = "low"
|
||||
elif severity == "low":
|
||||
treatment = "organic_treatment"
|
||||
product = "Neem Oil"
|
||||
rate = 0.2
|
||||
impact = "minimal"
|
||||
else:
|
||||
treatment = "fertilizer"
|
||||
product = "NPK 20-20-20"
|
||||
rate = 0.4
|
||||
impact = "positive"
|
||||
|
||||
# Calculate target zones based on affected area
|
||||
affected_area = field_data.get("affected_area", 0)
|
||||
if affected_area > 0:
|
||||
target_zones = [
|
||||
{
|
||||
"zone_id": "A1",
|
||||
"gps_boundary": [(40.71, -74.00), (40.72, -74.01)],
|
||||
"priority": "high"
|
||||
}
|
||||
]
|
||||
else:
|
||||
target_zones = []
|
||||
|
||||
return {
|
||||
"recommendation_id": f"SPR-{datetime.now().strftime('%Y%m%d%H%M')}",
|
||||
"field_id": field_data["field_id"],
|
||||
"treatment_type": treatment,
|
||||
"product_name": product,
|
||||
"application_rate": rate,
|
||||
"affected_area": affected_area,
|
||||
"target_zones": target_zones,
|
||||
"optimal_time": (datetime.now() + timedelta(days=1)).isoformat(),
|
||||
"weather_window": {
|
||||
"wind_speed": weather.get("wind_speed", 5),
|
||||
"precipitation": weather.get("precipitation", 0),
|
||||
"suitable": True
|
||||
},
|
||||
"cost_estimate": rate * affected_area * 50, # $/hectare
|
||||
"environmental_impact": impact
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def predict_crop_yield(field_id: str, crop_type: str, current_health: Dict, weather_forecast: Dict) -> Dict:
|
||||
"""Predict crop yield based on current conditions and forecasts"""
|
||||
# Simulate yield prediction
|
||||
health_level = current_health.get("health_level", "good")
|
||||
|
||||
base_yield = {
|
||||
"wheat": 3.5,
|
||||
"corn": 9.5,
|
||||
"soybeans": 2.8,
|
||||
"rice": 6.0
|
||||
}.get(crop_type, 5.0)
|
||||
|
||||
# Adjust based on health
|
||||
health_multiplier = {
|
||||
"excellent": 1.2,
|
||||
"good": 1.0,
|
||||
"fair": 0.8,
|
||||
"poor": 0.6,
|
||||
"critical": 0.4
|
||||
}.get(health_level, 1.0)
|
||||
|
||||
predicted_yield = base_yield * health_multiplier
|
||||
|
||||
# Quality grade based on conditions
|
||||
if health_level in ["excellent", "good"]:
|
||||
quality = "A"
|
||||
price_multiplier = 1.2
|
||||
elif health_level == "fair":
|
||||
quality = "B"
|
||||
price_multiplier = 1.0
|
||||
else:
|
||||
quality = "C"
|
||||
price_multiplier = 0.8
|
||||
|
||||
return {
|
||||
"forecast_id": f"YLD-{datetime.now().strftime('%Y%m%d%H%M')}",
|
||||
"field_id": field_id,
|
||||
"crop_type": crop_type,
|
||||
"predicted_yield": predicted_yield,
|
||||
"confidence_interval": (predicted_yield * 0.9, predicted_yield * 1.1),
|
||||
"harvest_window": (
|
||||
(datetime.now() + timedelta(days=60)).isoformat(),
|
||||
(datetime.now() + timedelta(days=75)).isoformat()
|
||||
),
|
||||
"quality_grade": quality,
|
||||
"market_price_estimate": predicted_yield * 250 * price_multiplier, # $/ton
|
||||
"risk_factors": ["weather_variability", "pest_pressure", "market_volatility"]
|
||||
}
|
||||
|
||||
|
||||
# Agriculture agent definitions
|
||||
multispectral_agent = Agent(
|
||||
name="MultispectralAnalyzer",
|
||||
instructions="""You are a remote sensing specialist for precision agriculture.
|
||||
Analyze multispectral imagery from drones and satellites.
|
||||
Calculate vegetation indices (NDVI, NDRE, CCCI) to assess crop health.
|
||||
Identify stress zones and anomalies in field conditions.
|
||||
SLA: Process imagery within 2 minutes per field.""",
|
||||
tools=[analyze_multispectral_imagery]
|
||||
)
|
||||
|
||||
disease_agent = Agent(
|
||||
name="DiseaseIdentifier",
|
||||
instructions="""You are a plant pathology expert with AI vision capabilities.
|
||||
Identify crop diseases, pests, and nutrient deficiencies.
|
||||
Assess severity levels and predict spread patterns.
|
||||
Consider environmental factors affecting disease development.
|
||||
SLA: Complete identification within 30 seconds.""",
|
||||
tools=[identify_pest_disease]
|
||||
)
|
||||
|
||||
spray_agent = Agent(
|
||||
name="SprayRecommender",
|
||||
instructions="""You are a precision application specialist.
|
||||
Recommend targeted spraying strategies for pest and disease control.
|
||||
Optimize chemical usage to minimize environmental impact.
|
||||
Consider weather windows and application regulations.
|
||||
SLA: Generate recommendations within 1 minute.""",
|
||||
tools=[calculate_spray_recommendation]
|
||||
)
|
||||
|
||||
yield_agent = Agent(
|
||||
name="YieldPredictor",
|
||||
instructions="""You are a crop yield forecasting specialist.
|
||||
Predict harvest yields based on current conditions and historical data.
|
||||
Assess crop quality grades and market value estimates.
|
||||
Identify risk factors that could impact final yield.
|
||||
SLA: Generate forecast within 45 seconds.""",
|
||||
tools=[predict_crop_yield]
|
||||
)
|
||||
|
||||
|
||||
# Precision agriculture workflow
|
||||
def precision_agriculture_workflow(field_ids: List[str], crop_type: str, weather_data: Dict):
|
||||
"""
|
||||
Complete precision agriculture workflow from monitoring to treatment
|
||||
Includes early warning system and sustainable farming practices
|
||||
"""
|
||||
|
||||
workflow_results = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"fields_monitored": len(field_ids),
|
||||
"alerts": [],
|
||||
"treatments_needed": [],
|
||||
"yield_forecasts": [],
|
||||
"sustainability_score": 0.0
|
||||
}
|
||||
|
||||
for field_id in field_ids:
|
||||
try:
|
||||
# Step 1: Multispectral analysis
|
||||
spectral_analysis = multispectral_agent.start(
|
||||
f"Analyze multispectral imagery for field {field_id}"
|
||||
)
|
||||
|
||||
# Step 2: Disease and pest detection
|
||||
pest_report = disease_agent.start(
|
||||
f"Identify pests/diseases from spectral data: {spectral_analysis}"
|
||||
)
|
||||
|
||||
# Check for alerts
|
||||
if pest_report.get("severity") in ["high", "severe"]:
|
||||
workflow_results["alerts"].append({
|
||||
"field_id": field_id,
|
||||
"type": "pest_disease_alert",
|
||||
"severity": pest_report["severity"],
|
||||
"pest": pest_report.get("pest_type"),
|
||||
"disease": pest_report.get("disease_type"),
|
||||
"action": "immediate_treatment_required"
|
||||
})
|
||||
|
||||
# Step 3: Generate spray recommendation
|
||||
spray_recommendation = spray_agent.start(
|
||||
f"Calculate spray strategy for pest report: {pest_report}, "
|
||||
f"field: {spectral_analysis}, weather: {weather_data}"
|
||||
)
|
||||
workflow_results["treatments_needed"].append(spray_recommendation)
|
||||
|
||||
# Step 4: Yield prediction
|
||||
yield_forecast = yield_agent.start(
|
||||
f"Predict yield for field {field_id}, crop {crop_type}, "
|
||||
f"health: {spectral_analysis}, weather: {weather_data}"
|
||||
)
|
||||
workflow_results["yield_forecasts"].append(yield_forecast)
|
||||
|
||||
except Exception as e:
|
||||
# Fallback: Schedule manual inspection
|
||||
workflow_results["alerts"].append({
|
||||
"field_id": field_id,
|
||||
"type": "monitoring_failure",
|
||||
"error": str(e),
|
||||
"action": "schedule_manual_inspection"
|
||||
})
|
||||
|
||||
# Calculate sustainability score
|
||||
total_chemical = sum(
|
||||
t.get("application_rate", 0) * t.get("affected_area", 0)
|
||||
for t in workflow_results["treatments_needed"]
|
||||
)
|
||||
workflow_results["sustainability_score"] = max(0, 100 - (total_chemical * 10))
|
||||
|
||||
return workflow_results
|
||||
|
||||
|
||||
# Sustainable farming patterns
|
||||
class SustainableFarmingPatterns:
|
||||
"""
|
||||
Patterns for sustainable and regenerative agriculture
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def integrated_pest_management(pest_level: str) -> Dict:
|
||||
"""IPM strategy based on pest pressure"""
|
||||
strategies = {
|
||||
"low": {
|
||||
"method": "biological_control",
|
||||
"actions": ["release_beneficial_insects", "plant_trap_crops"],
|
||||
"chemical_use": 0
|
||||
},
|
||||
"moderate": {
|
||||
"method": "targeted_intervention",
|
||||
"actions": ["spot_treatment", "pheromone_traps", "minimal_spray"],
|
||||
"chemical_use": 30
|
||||
},
|
||||
"high": {
|
||||
"method": "integrated_approach",
|
||||
"actions": ["systematic_spray", "crop_rotation", "resistant_varieties"],
|
||||
"chemical_use": 70
|
||||
}
|
||||
}
|
||||
return strategies.get(pest_level, strategies["moderate"])
|
||||
|
||||
@staticmethod
|
||||
def precision_irrigation(moisture_level: float, crop_stage: str) -> Dict:
|
||||
"""Calculate optimal irrigation based on soil moisture and crop stage"""
|
||||
# Water requirements by growth stage (mm/day)
|
||||
stage_requirements = {
|
||||
"germination": 3.0,
|
||||
"vegetative": 5.0,
|
||||
"flowering": 7.0,
|
||||
"grain_filling": 6.0,
|
||||
"maturation": 2.0
|
||||
}
|
||||
|
||||
required = stage_requirements.get(crop_stage, 5.0)
|
||||
deficit = max(0, 40 - moisture_level) # Target 40% moisture
|
||||
|
||||
return {
|
||||
"irrigation_needed": deficit > 10,
|
||||
"amount_mm": deficit * 2,
|
||||
"method": "drip" if deficit < 20 else "sprinkler",
|
||||
"schedule": "night_irrigation" if deficit > 15 else "morning_irrigation",
|
||||
"water_saved": (100 - deficit) / 100 * 1000 # Liters per hectare
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def crop_rotation_optimizer(current_crop: str, soil_data: Dict) -> str:
|
||||
"""Recommend next crop for rotation based on soil health"""
|
||||
rotations = {
|
||||
"wheat": ["soybeans", "canola"], # Nitrogen fixation
|
||||
"corn": ["soybeans", "alfalfa"], # Break disease cycle
|
||||
"soybeans": ["wheat", "corn"], # Utilize fixed nitrogen
|
||||
"rice": ["legumes", "vegetables"], # Soil structure improvement
|
||||
}
|
||||
|
||||
return rotations.get(current_crop, ["cover_crops"])[0]
|
||||
|
||||
|
||||
# IoT sensor integration patterns
|
||||
class IoTSensorPatterns:
|
||||
"""
|
||||
Patterns for integrating IoT sensors in smart farming
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_sensor_network_agent(sensor_types: List[str]) -> Agent:
|
||||
"""Create agent for managing IoT sensor networks"""
|
||||
return Agent(
|
||||
name="SensorNetworkManager",
|
||||
instructions=f"""Manage IoT sensor network with {', '.join(sensor_types)}.
|
||||
Collect real-time data from field sensors.
|
||||
Detect sensor anomalies and maintenance needs.
|
||||
Aggregate data for precision agriculture decisions.
|
||||
SLA: Data collection every 15 minutes."""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def process_sensor_data(sensor_readings: List[Dict]) -> Dict:
|
||||
"""Process and aggregate IoT sensor data"""
|
||||
aggregated = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"sensor_count": len(sensor_readings),
|
||||
"averages": {},
|
||||
"alerts": []
|
||||
}
|
||||
|
||||
# Calculate averages
|
||||
for reading in sensor_readings:
|
||||
for key, value in reading.items():
|
||||
if isinstance(value, (int, float)):
|
||||
if key not in aggregated["averages"]:
|
||||
aggregated["averages"][key] = []
|
||||
aggregated["averages"][key].append(value)
|
||||
|
||||
# Convert to means
|
||||
for key in aggregated["averages"]:
|
||||
values = aggregated["averages"][key]
|
||||
aggregated["averages"][key] = sum(values) / len(values)
|
||||
|
||||
# Check for anomalies
|
||||
if key == "soil_ph" and (aggregated["averages"][key] < 6.0 or aggregated["averages"][key] > 7.5):
|
||||
aggregated["alerts"].append({
|
||||
"type": "soil_ph_anomaly",
|
||||
"value": aggregated["averages"][key],
|
||||
"action": "adjust_soil_treatment"
|
||||
})
|
||||
|
||||
return aggregated
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
# Monitor multiple fields
|
||||
fields = ["FIELD-001", "FIELD-002", "FIELD-003"]
|
||||
weather = {
|
||||
"temperature": 25,
|
||||
"humidity": 60,
|
||||
"wind_speed": 8,
|
||||
"precipitation": 0,
|
||||
"forecast": "partly_cloudy"
|
||||
}
|
||||
|
||||
result = precision_agriculture_workflow(fields, "wheat", weather)
|
||||
print("Agriculture workflow result:", result)
|
||||
|
||||
# Sustainable farming example
|
||||
ipm_strategy = SustainableFarmingPatterns.integrated_pest_management("moderate")
|
||||
print("IPM Strategy:", ipm_strategy)
|
||||
|
||||
irrigation_plan = SustainableFarmingPatterns.precision_irrigation(25.0, "flowering")
|
||||
print("Irrigation Plan:", irrigation_plan)
|
||||
|
||||
next_crop = SustainableFarmingPatterns.crop_rotation_optimizer("wheat", {})
|
||||
print("Recommended next crop:", next_crop)
|
||||
|
||||
# IoT integration example
|
||||
sensor_agent = IoTSensorPatterns.create_sensor_network_agent(
|
||||
["soil_moisture", "temperature", "humidity", "light", "ph"]
|
||||
)
|
||||
|
||||
sensor_data = [
|
||||
{"sensor_id": "S001", "soil_moisture": 35, "temperature": 24, "soil_ph": 6.8},
|
||||
{"sensor_id": "S002", "soil_moisture": 32, "temperature": 25, "soil_ph": 7.2},
|
||||
{"sensor_id": "S003", "soil_moisture": 38, "temperature": 23, "soil_ph": 5.8}
|
||||
]
|
||||
|
||||
processed_data = IoTSensorPatterns.process_sensor_data(sensor_data)
|
||||
print("Processed sensor data:", processed_data)
|
||||
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
Energy Industry Template
|
||||
========================
|
||||
Wind farm monitoring and power optimization workflow
|
||||
Based on SRAO Framework patterns with predictive maintenance
|
||||
|
||||
Key agents:
|
||||
- SCADAReader: Processes SCADA telemetry data
|
||||
- VibrationAnalyzer: Analyzes turbine vibration patterns
|
||||
- PowerForecaster: Predicts power generation
|
||||
- MaintenanceScheduler: Schedules predictive maintenance
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, tool
|
||||
from typing import Dict, List, Any, Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
# I/O Schemas
|
||||
class TurbineData(BaseModel):
|
||||
"""Wind turbine telemetry data"""
|
||||
turbine_id: str
|
||||
timestamp: str
|
||||
wind_speed: float # m/s
|
||||
power_output: float # MW
|
||||
rotor_speed: float # RPM
|
||||
blade_pitch: float # degrees
|
||||
nacelle_temperature: float # Celsius
|
||||
vibration_level: float # mm/s
|
||||
operational_status: str # running, stopped, maintenance
|
||||
|
||||
|
||||
class VibrationAnalysis(BaseModel):
|
||||
"""Vibration analysis results"""
|
||||
turbine_id: str
|
||||
analysis_time: str
|
||||
vibration_rms: float
|
||||
frequency_peaks: List[float]
|
||||
anomaly_detected: bool
|
||||
failure_probability: float
|
||||
recommended_action: str
|
||||
|
||||
|
||||
class PowerForecast(BaseModel):
|
||||
"""Power generation forecast"""
|
||||
forecast_id: str
|
||||
start_time: str
|
||||
end_time: str
|
||||
predicted_output: List[float] # MW per hour
|
||||
confidence_interval: float
|
||||
weather_factors: Dict[str, Any]
|
||||
grid_demand: float
|
||||
|
||||
|
||||
class MaintenancePlan(BaseModel):
|
||||
"""Maintenance scheduling plan"""
|
||||
plan_id: str
|
||||
turbine_id: str
|
||||
maintenance_type: str # preventive, predictive, corrective
|
||||
scheduled_date: str
|
||||
estimated_duration: int # hours
|
||||
required_parts: List[str]
|
||||
technician_team: str
|
||||
production_loss: float # MW
|
||||
|
||||
|
||||
# Tools for energy management
|
||||
@tool
|
||||
def read_scada_telemetry(turbine_id: str, time_range: str = "last_hour") -> Dict:
|
||||
"""Read SCADA system telemetry for wind turbines"""
|
||||
# Simulate SCADA data reading
|
||||
return {
|
||||
"turbine_id": turbine_id,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"wind_speed": 12.5,
|
||||
"power_output": 2.3,
|
||||
"rotor_speed": 16.8,
|
||||
"blade_pitch": 8.5,
|
||||
"nacelle_temperature": 45.2,
|
||||
"vibration_level": 3.2,
|
||||
"operational_status": "running"
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def analyze_vibration_patterns(turbine_data: Dict) -> Dict:
|
||||
"""Analyze vibration patterns for early fault detection"""
|
||||
# Simulate vibration analysis
|
||||
vibration_level = turbine_data.get("vibration_level", 0)
|
||||
anomaly = vibration_level > 5.0 # Threshold for anomaly
|
||||
|
||||
return {
|
||||
"turbine_id": turbine_data["turbine_id"],
|
||||
"analysis_time": datetime.now().isoformat(),
|
||||
"vibration_rms": vibration_level,
|
||||
"frequency_peaks": [10.5, 25.3, 50.0], # Hz
|
||||
"anomaly_detected": anomaly,
|
||||
"failure_probability": min(vibration_level / 10.0, 1.0),
|
||||
"recommended_action": "immediate_inspection" if anomaly else "continue_monitoring"
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def forecast_power_generation(weather_data: Dict, historical_data: List) -> Dict:
|
||||
"""Forecast power generation based on weather and historical patterns"""
|
||||
# Simulate power forecasting
|
||||
base_forecast = [2.0 + (i * 0.1) for i in range(24)] # 24-hour forecast
|
||||
|
||||
return {
|
||||
"forecast_id": f"FCST-{datetime.now().strftime('%Y%m%d%H%M')}",
|
||||
"start_time": datetime.now().isoformat(),
|
||||
"end_time": (datetime.now() + timedelta(hours=24)).isoformat(),
|
||||
"predicted_output": base_forecast,
|
||||
"confidence_interval": 0.85,
|
||||
"weather_factors": weather_data,
|
||||
"grid_demand": sum(base_forecast)
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def schedule_maintenance(turbine_id: str, maintenance_type: str, urgency: str = "normal") -> Dict:
|
||||
"""Schedule maintenance based on condition monitoring"""
|
||||
# Calculate maintenance window
|
||||
if urgency == "urgent":
|
||||
scheduled_date = (datetime.now() + timedelta(days=1)).isoformat()
|
||||
else:
|
||||
scheduled_date = (datetime.now() + timedelta(days=7)).isoformat()
|
||||
|
||||
return {
|
||||
"plan_id": f"MNT-{datetime.now().strftime('%Y%m%d%H%M')}",
|
||||
"turbine_id": turbine_id,
|
||||
"maintenance_type": maintenance_type,
|
||||
"scheduled_date": scheduled_date,
|
||||
"estimated_duration": 4,
|
||||
"required_parts": ["bearing_set", "lubricant", "filter"],
|
||||
"technician_team": "Team-A",
|
||||
"production_loss": 8.0 # MW lost during maintenance
|
||||
}
|
||||
|
||||
|
||||
# Agent Definitions with SLA requirements
|
||||
scada_reader = Agent(
|
||||
name="SCADAReader",
|
||||
instructions="""You are a SCADA system specialist for wind farms.
|
||||
Process real-time telemetry data from wind turbines.
|
||||
Identify operational anomalies and performance deviations.
|
||||
SLA: Process telemetry within 1 second for real-time monitoring.""",
|
||||
tools=[read_scada_telemetry]
|
||||
)
|
||||
|
||||
vibration_analyzer = Agent(
|
||||
name="VibrationAnalyzer",
|
||||
instructions="""You are a vibration analysis expert for rotating machinery.
|
||||
Analyze vibration patterns to detect early signs of mechanical failure.
|
||||
Use FFT analysis and pattern recognition for fault diagnosis.
|
||||
SLA: Complete analysis within 5 seconds per turbine.""",
|
||||
tools=[analyze_vibration_patterns]
|
||||
)
|
||||
|
||||
power_forecaster = Agent(
|
||||
name="PowerForecaster",
|
||||
instructions="""You are a power generation forecasting specialist.
|
||||
Predict power output based on weather forecasts and historical patterns.
|
||||
Consider grid demand and optimize generation schedules.
|
||||
SLA: Generate 24-hour forecast within 30 seconds.""",
|
||||
tools=[forecast_power_generation]
|
||||
)
|
||||
|
||||
maintenance_scheduler = Agent(
|
||||
name="MaintenanceScheduler",
|
||||
instructions="""You are a predictive maintenance planning expert.
|
||||
Schedule maintenance based on condition monitoring and failure predictions.
|
||||
Minimize production loss while ensuring turbine reliability.
|
||||
SLA: Create maintenance plan within 1 minute.""",
|
||||
tools=[schedule_maintenance]
|
||||
)
|
||||
|
||||
|
||||
# Energy workflow with grid integration
|
||||
def energy_monitoring_workflow(turbine_ids: List[str], weather_forecast: Dict):
|
||||
"""
|
||||
Complete energy monitoring and optimization workflow
|
||||
Includes predictive maintenance and grid integration
|
||||
"""
|
||||
|
||||
results = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"turbines_monitored": len(turbine_ids),
|
||||
"alerts": [],
|
||||
"maintenance_required": [],
|
||||
"power_forecast": None
|
||||
}
|
||||
|
||||
# Step 1: Monitor all turbines
|
||||
for turbine_id in turbine_ids:
|
||||
try:
|
||||
# Read SCADA data
|
||||
scada_data = scada_reader.start(
|
||||
f"Read telemetry for turbine {turbine_id}"
|
||||
)
|
||||
|
||||
# Analyze vibrations
|
||||
vibration_result = vibration_analyzer.start(
|
||||
f"Analyze vibrations for data: {scada_data}"
|
||||
)
|
||||
|
||||
# Check for anomalies
|
||||
if vibration_result.get("anomaly_detected"):
|
||||
results["alerts"].append({
|
||||
"turbine_id": turbine_id,
|
||||
"type": "vibration_anomaly",
|
||||
"severity": "high" if vibration_result.get("failure_probability", 0) > 0.7 else "medium"
|
||||
})
|
||||
|
||||
# Schedule maintenance if needed
|
||||
maintenance_plan = maintenance_scheduler.start(
|
||||
f"Schedule maintenance for turbine {turbine_id} with urgency based on {vibration_result}"
|
||||
)
|
||||
results["maintenance_required"].append(maintenance_plan)
|
||||
|
||||
except Exception as e:
|
||||
# Fallback: Mark turbine for manual inspection
|
||||
results["alerts"].append({
|
||||
"turbine_id": turbine_id,
|
||||
"type": "monitoring_failure",
|
||||
"error": str(e),
|
||||
"action": "manual_inspection_required"
|
||||
})
|
||||
|
||||
# Step 2: Power generation forecast
|
||||
try:
|
||||
historical_data = [] # Would be fetched from database
|
||||
power_forecast = power_forecaster.start(
|
||||
f"Forecast power with weather {weather_forecast} and history {historical_data}"
|
||||
)
|
||||
results["power_forecast"] = power_forecast
|
||||
|
||||
# Grid integration check
|
||||
if power_forecast.get("predicted_output"):
|
||||
total_predicted = sum(power_forecast["predicted_output"])
|
||||
grid_demand = power_forecast.get("grid_demand", 0)
|
||||
|
||||
if total_predicted < grid_demand * 0.9:
|
||||
results["alerts"].append({
|
||||
"type": "supply_shortage",
|
||||
"predicted": total_predicted,
|
||||
"demand": grid_demand,
|
||||
"action": "consider_backup_sources"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# Fallback: Use conservative forecast
|
||||
results["power_forecast"] = {
|
||||
"status": "fallback_mode",
|
||||
"conservative_estimate": len(turbine_ids) * 1.5, # MW
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# Cross-industry pattern adaptation
|
||||
class EnergyPatternAdapter:
|
||||
"""
|
||||
Adapts manufacturing patterns for energy sector
|
||||
Demonstrates 70% code reuse across industries
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def adapt_for_solar_farm():
|
||||
"""Adapt wind farm agents for solar farm monitoring"""
|
||||
return Agent(
|
||||
name="SolarMonitor",
|
||||
instructions="""Monitor solar panel efficiency and temperature.
|
||||
Track irradiance levels and predict power output.
|
||||
Detect panel degradation and soiling issues.
|
||||
SLA: Real-time monitoring with 2-second updates."""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def adapt_for_power_grid():
|
||||
"""Adapt for power grid management"""
|
||||
return Agent(
|
||||
name="GridBalancer",
|
||||
instructions="""Balance power supply and demand across the grid.
|
||||
Manage load distribution and prevent blackouts.
|
||||
Coordinate with renewable and traditional sources.
|
||||
SLA: Sub-second response for grid stability."""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def adapt_for_battery_storage():
|
||||
"""Adapt for battery energy storage systems"""
|
||||
return Agent(
|
||||
name="BatteryOptimizer",
|
||||
instructions="""Optimize battery charge/discharge cycles.
|
||||
Predict battery degradation and schedule replacements.
|
||||
Maximize energy arbitrage opportunities.
|
||||
SLA: Optimization decision within 10 seconds."""
|
||||
)
|
||||
|
||||
|
||||
# Fallback strategies for critical operations
|
||||
class EnergyFallbackStrategies:
|
||||
"""
|
||||
Fallback strategies for maintaining grid stability
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def turbine_failure_fallback(failed_turbine_id: str, available_turbines: List[str]):
|
||||
"""Handle turbine failure by redistributing load"""
|
||||
return {
|
||||
"strategy": "load_redistribution",
|
||||
"failed_turbine": failed_turbine_id,
|
||||
"compensating_turbines": available_turbines[:3], # Use top 3 turbines
|
||||
"increase_output_by": 0.3, # MW per turbine
|
||||
"duration": "until_maintenance_complete"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def forecast_failure_fallback(last_known_forecast: Optional[Dict]):
|
||||
"""Handle forecasting system failure"""
|
||||
if last_known_forecast:
|
||||
return {
|
||||
"strategy": "use_last_known",
|
||||
"forecast": last_known_forecast,
|
||||
"confidence": 0.6,
|
||||
"validity": "4_hours"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"strategy": "seasonal_average",
|
||||
"estimated_output": 50.0, # MW based on seasonal average
|
||||
"confidence": 0.4,
|
||||
"recommendation": "increase_reserves"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def communication_failure_fallback():
|
||||
"""Handle SCADA communication failure"""
|
||||
return {
|
||||
"strategy": "local_control_mode",
|
||||
"actions": [
|
||||
"Switch turbines to autonomous mode",
|
||||
"Use local safety thresholds",
|
||||
"Log data locally for later sync",
|
||||
"Alert maintenance teams"
|
||||
],
|
||||
"recovery_check_interval": 300 # seconds
|
||||
}
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
# Monitor wind farm
|
||||
turbine_list = [f"WT-{i:03d}" for i in range(1, 11)] # 10 turbines
|
||||
weather = {"wind_speed": 14.0, "direction": "NW", "temperature": 15.0}
|
||||
|
||||
result = energy_monitoring_workflow(turbine_list, weather)
|
||||
print("Energy monitoring result:", result)
|
||||
|
||||
# Demonstrate cross-industry adaptation
|
||||
solar_monitor = EnergyPatternAdapter.adapt_for_solar_farm()
|
||||
grid_balancer = EnergyPatternAdapter.adapt_for_power_grid()
|
||||
battery_optimizer = EnergyPatternAdapter.adapt_for_battery_storage()
|
||||
|
||||
# Fallback examples
|
||||
turbine_fallback = EnergyFallbackStrategies.turbine_failure_fallback(
|
||||
"WT-003", ["WT-001", "WT-002", "WT-004", "WT-005"]
|
||||
)
|
||||
print("Turbine failure fallback:", turbine_fallback)
|
||||
@@ -0,0 +1,478 @@
|
||||
"""
|
||||
Healthcare Industry Template
|
||||
============================
|
||||
Emergency triage and patient care coordination workflow
|
||||
Based on SRAO Framework with HIPAA-compliant patterns
|
||||
|
||||
Key agents:
|
||||
- VitalSignsCapture: Processes patient vital signs
|
||||
- EMRRetrieval: Retrieves electronic medical records
|
||||
- TriageRecommendation: Provides triage recommendations
|
||||
- ResourceAllocator: Manages hospital resource allocation
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, tool
|
||||
from typing import Dict, List, Any, Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# Triage levels following Emergency Severity Index (ESI)
|
||||
class TriageLevel(str, Enum):
|
||||
RESUSCITATION = "1_resuscitation" # Immediate life-saving intervention
|
||||
EMERGENT = "2_emergent" # High risk, severe pain/distress
|
||||
URGENT = "3_urgent" # Stable but needs multiple resources
|
||||
LESS_URGENT = "4_less_urgent" # Stable, one resource needed
|
||||
NON_URGENT = "5_non_urgent" # Stable, no resources needed
|
||||
|
||||
|
||||
# I/O Schemas
|
||||
class VitalSigns(BaseModel):
|
||||
"""Patient vital signs data"""
|
||||
patient_id: str
|
||||
timestamp: str
|
||||
heart_rate: int # bpm
|
||||
blood_pressure_systolic: int # mmHg
|
||||
blood_pressure_diastolic: int # mmHg
|
||||
respiratory_rate: int # breaths/min
|
||||
temperature: float # Celsius
|
||||
oxygen_saturation: int # percentage
|
||||
pain_scale: int # 0-10
|
||||
consciousness_level: str # alert, verbal, pain, unresponsive (AVPU)
|
||||
|
||||
|
||||
class MedicalHistory(BaseModel):
|
||||
"""Patient medical history from EMR"""
|
||||
patient_id: str
|
||||
allergies: List[str]
|
||||
chronic_conditions: List[str]
|
||||
current_medications: List[Dict[str, str]]
|
||||
recent_visits: List[Dict[str, Any]]
|
||||
emergency_contacts: List[Dict[str, str]]
|
||||
insurance_status: str
|
||||
|
||||
|
||||
class TriageAssessment(BaseModel):
|
||||
"""Triage assessment and recommendations"""
|
||||
assessment_id: str
|
||||
patient_id: str
|
||||
triage_level: TriageLevel
|
||||
chief_complaint: str
|
||||
recommended_department: str
|
||||
estimated_wait_time: int # minutes
|
||||
required_resources: List[str]
|
||||
clinical_notes: str
|
||||
red_flags: List[str]
|
||||
|
||||
|
||||
class ResourceAllocation(BaseModel):
|
||||
"""Hospital resource allocation"""
|
||||
allocation_id: str
|
||||
patient_id: str
|
||||
assigned_bed: Optional[str]
|
||||
assigned_staff: List[str]
|
||||
equipment_needed: List[str]
|
||||
department: str
|
||||
priority_score: float
|
||||
estimated_treatment_time: int # minutes
|
||||
|
||||
|
||||
# Healthcare-specific tools
|
||||
@tool
|
||||
def capture_vital_signs(patient_id: str) -> Dict:
|
||||
"""Capture patient vital signs from monitoring devices"""
|
||||
# Simulate vital signs capture
|
||||
return {
|
||||
"patient_id": patient_id,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"heart_rate": 78,
|
||||
"blood_pressure_systolic": 130,
|
||||
"blood_pressure_diastolic": 85,
|
||||
"respiratory_rate": 16,
|
||||
"temperature": 37.2,
|
||||
"oxygen_saturation": 98,
|
||||
"pain_scale": 6,
|
||||
"consciousness_level": "alert"
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def retrieve_medical_history(patient_id: str, consent_verified: bool = True) -> Dict:
|
||||
"""Retrieve patient medical history from EMR system"""
|
||||
if not consent_verified:
|
||||
return {"error": "consent_required", "message": "Patient consent required for EMR access"}
|
||||
|
||||
# Simulate EMR retrieval
|
||||
return {
|
||||
"patient_id": patient_id,
|
||||
"allergies": ["penicillin", "latex"],
|
||||
"chronic_conditions": ["hypertension", "diabetes_type2"],
|
||||
"current_medications": [
|
||||
{"name": "metformin", "dose": "500mg", "frequency": "twice daily"},
|
||||
{"name": "lisinopril", "dose": "10mg", "frequency": "once daily"}
|
||||
],
|
||||
"recent_visits": [
|
||||
{"date": "2024-02-15", "reason": "routine_checkup", "department": "primary_care"}
|
||||
],
|
||||
"emergency_contacts": [
|
||||
{"name": "Jane Doe", "relationship": "spouse", "phone": "555-0123"}
|
||||
],
|
||||
"insurance_status": "active"
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def calculate_triage_priority(vital_signs: Dict, medical_history: Dict, chief_complaint: str) -> Dict:
|
||||
"""Calculate triage priority based on clinical indicators"""
|
||||
# Simulate triage algorithm
|
||||
priority_score = 0
|
||||
red_flags = []
|
||||
|
||||
# Check vital signs
|
||||
if vital_signs["heart_rate"] > 120 or vital_signs["heart_rate"] < 50:
|
||||
priority_score += 3
|
||||
red_flags.append("abnormal_heart_rate")
|
||||
|
||||
if vital_signs["oxygen_saturation"] < 92:
|
||||
priority_score += 4
|
||||
red_flags.append("low_oxygen")
|
||||
|
||||
if vital_signs["consciousness_level"] != "alert":
|
||||
priority_score += 5
|
||||
red_flags.append("altered_consciousness")
|
||||
|
||||
# Determine triage level
|
||||
if priority_score >= 8:
|
||||
triage_level = TriageLevel.RESUSCITATION
|
||||
department = "emergency"
|
||||
wait_time = 0
|
||||
elif priority_score >= 5:
|
||||
triage_level = TriageLevel.EMERGENT
|
||||
department = "emergency"
|
||||
wait_time = 10
|
||||
elif priority_score >= 3:
|
||||
triage_level = TriageLevel.URGENT
|
||||
department = "urgent_care"
|
||||
wait_time = 30
|
||||
else:
|
||||
triage_level = TriageLevel.LESS_URGENT
|
||||
department = "primary_care"
|
||||
wait_time = 60
|
||||
|
||||
return {
|
||||
"assessment_id": f"TRG-{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||
"patient_id": vital_signs["patient_id"],
|
||||
"triage_level": triage_level.value,
|
||||
"chief_complaint": chief_complaint,
|
||||
"recommended_department": department,
|
||||
"estimated_wait_time": wait_time,
|
||||
"required_resources": ["physician", "nurse", "monitoring"],
|
||||
"clinical_notes": f"Priority score: {priority_score}",
|
||||
"red_flags": red_flags
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def allocate_resources(patient_id: str, triage_level: str, department: str) -> Dict:
|
||||
"""Allocate hospital resources based on triage assessment"""
|
||||
# Simulate resource allocation
|
||||
if "resuscitation" in triage_level:
|
||||
bed = "ER-01"
|
||||
staff = ["Dr. Smith", "Nurse Johnson", "Nurse Williams"]
|
||||
equipment = ["cardiac_monitor", "defibrillator", "crash_cart"]
|
||||
priority = 10.0
|
||||
elif "emergent" in triage_level:
|
||||
bed = "ER-05"
|
||||
staff = ["Dr. Brown", "Nurse Davis"]
|
||||
equipment = ["vital_signs_monitor", "IV_pump"]
|
||||
priority = 8.0
|
||||
else:
|
||||
bed = "UC-12"
|
||||
staff = ["PA Miller", "Nurse Garcia"]
|
||||
equipment = ["basic_monitor"]
|
||||
priority = 5.0
|
||||
|
||||
return {
|
||||
"allocation_id": f"RES-{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||
"patient_id": patient_id,
|
||||
"assigned_bed": bed,
|
||||
"assigned_staff": staff,
|
||||
"equipment_needed": equipment,
|
||||
"department": department,
|
||||
"priority_score": priority,
|
||||
"estimated_treatment_time": 45
|
||||
}
|
||||
|
||||
|
||||
# Healthcare agent definitions with SLA requirements
|
||||
vital_signs_agent = Agent(
|
||||
name="VitalSignsCapture",
|
||||
instructions="""You are a clinical vital signs specialist.
|
||||
Capture and interpret patient vital signs from various monitoring devices.
|
||||
Identify abnormal values and trends that require immediate attention.
|
||||
Follow HIPAA guidelines for patient data handling.
|
||||
SLA: Capture and process within 30 seconds.""",
|
||||
tools=[capture_vital_signs]
|
||||
)
|
||||
|
||||
emr_agent = Agent(
|
||||
name="EMRRetrieval",
|
||||
instructions="""You are an EMR system specialist with HIPAA compliance expertise.
|
||||
Retrieve relevant patient medical history while ensuring data privacy.
|
||||
Verify patient consent and maintain audit trails.
|
||||
Highlight relevant allergies and contraindications.
|
||||
SLA: Retrieve records within 5 seconds.""",
|
||||
tools=[retrieve_medical_history]
|
||||
)
|
||||
|
||||
triage_agent = Agent(
|
||||
name="TriageRecommendation",
|
||||
instructions="""You are an emergency triage specialist following ESI guidelines.
|
||||
Assess patient severity and provide triage recommendations.
|
||||
Consider vital signs, medical history, and presenting symptoms.
|
||||
Identify red flags that require immediate intervention.
|
||||
SLA: Complete assessment within 1 minute.""",
|
||||
tools=[calculate_triage_priority]
|
||||
)
|
||||
|
||||
resource_agent = Agent(
|
||||
name="ResourceAllocator",
|
||||
instructions="""You are a hospital resource management specialist.
|
||||
Allocate beds, staff, and equipment based on patient needs and priority.
|
||||
Optimize resource utilization while maintaining quality of care.
|
||||
Coordinate between departments for efficient patient flow.
|
||||
SLA: Allocate resources within 30 seconds.""",
|
||||
tools=[allocate_resources]
|
||||
)
|
||||
|
||||
|
||||
# Healthcare workflow with safety protocols
|
||||
def emergency_triage_workflow(patient_id: str, chief_complaint: str):
|
||||
"""
|
||||
Complete emergency triage workflow from arrival to resource allocation
|
||||
Includes safety checks and fallback protocols
|
||||
"""
|
||||
|
||||
workflow_result = {
|
||||
"patient_id": patient_id,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"status": "in_progress",
|
||||
"safety_checks": [],
|
||||
"clinical_pathway": []
|
||||
}
|
||||
|
||||
# Step 1: Vital signs capture with validation
|
||||
try:
|
||||
vital_signs = vital_signs_agent.start(
|
||||
f"Capture vital signs for patient {patient_id}"
|
||||
)
|
||||
workflow_result["clinical_pathway"].append({
|
||||
"step": "vital_signs",
|
||||
"status": "completed",
|
||||
"data": vital_signs
|
||||
})
|
||||
|
||||
# Safety check: Validate vital signs are within possible ranges
|
||||
if vital_signs.get("heart_rate", 0) < 20 or vital_signs.get("heart_rate", 0) > 300:
|
||||
workflow_result["safety_checks"].append({
|
||||
"type": "vital_signs_validation",
|
||||
"issue": "impossible_heart_rate",
|
||||
"action": "manual_verification_required"
|
||||
})
|
||||
except Exception as e:
|
||||
# Fallback: Manual vital signs entry
|
||||
workflow_result["clinical_pathway"].append({
|
||||
"step": "vital_signs",
|
||||
"status": "manual_entry_required",
|
||||
"error": str(e)
|
||||
})
|
||||
vital_signs = {"manual_entry": True}
|
||||
|
||||
# Step 2: EMR retrieval with consent check
|
||||
try:
|
||||
medical_history = emr_agent.start(
|
||||
f"Retrieve medical history for patient {patient_id} with verified consent"
|
||||
)
|
||||
workflow_result["clinical_pathway"].append({
|
||||
"step": "emr_retrieval",
|
||||
"status": "completed",
|
||||
"records_retrieved": True
|
||||
})
|
||||
|
||||
# Safety check: Allergy alerts
|
||||
if medical_history.get("allergies"):
|
||||
workflow_result["safety_checks"].append({
|
||||
"type": "allergy_alert",
|
||||
"allergies": medical_history["allergies"],
|
||||
"action": "notify_all_providers"
|
||||
})
|
||||
except Exception as e:
|
||||
# Fallback: Proceed with limited information
|
||||
medical_history = {
|
||||
"patient_id": patient_id,
|
||||
"allergies": [],
|
||||
"note": "EMR_unavailable_proceed_with_caution"
|
||||
}
|
||||
workflow_result["safety_checks"].append({
|
||||
"type": "emr_unavailable",
|
||||
"action": "collect_history_manually"
|
||||
})
|
||||
|
||||
# Step 3: Triage assessment
|
||||
try:
|
||||
triage_assessment = triage_agent.start(
|
||||
f"Assess triage for patient with vitals {vital_signs}, "
|
||||
f"history {medical_history}, complaint: {chief_complaint}"
|
||||
)
|
||||
workflow_result["clinical_pathway"].append({
|
||||
"step": "triage_assessment",
|
||||
"status": "completed",
|
||||
"triage_level": triage_assessment.get("triage_level")
|
||||
})
|
||||
|
||||
# Safety check: Red flags requiring immediate action
|
||||
if triage_assessment.get("red_flags"):
|
||||
workflow_result["safety_checks"].append({
|
||||
"type": "clinical_red_flags",
|
||||
"flags": triage_assessment["red_flags"],
|
||||
"action": "immediate_physician_notification"
|
||||
})
|
||||
except Exception as e:
|
||||
# Fallback: Conservative triage (assume urgent)
|
||||
triage_assessment = {
|
||||
"triage_level": TriageLevel.URGENT.value,
|
||||
"recommended_department": "emergency",
|
||||
"fallback": True
|
||||
}
|
||||
|
||||
# Step 4: Resource allocation
|
||||
try:
|
||||
resource_allocation = resource_agent.start(
|
||||
f"Allocate resources for patient {patient_id} "
|
||||
f"with triage {triage_assessment.get('triage_level')} "
|
||||
f"to {triage_assessment.get('recommended_department')}"
|
||||
)
|
||||
workflow_result["clinical_pathway"].append({
|
||||
"step": "resource_allocation",
|
||||
"status": "completed",
|
||||
"bed_assigned": resource_allocation.get("assigned_bed"),
|
||||
"staff_assigned": resource_allocation.get("assigned_staff")
|
||||
})
|
||||
except Exception as e:
|
||||
# Fallback: Queue for next available
|
||||
workflow_result["clinical_pathway"].append({
|
||||
"step": "resource_allocation",
|
||||
"status": "queued",
|
||||
"queue_position": "next_available",
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
workflow_result["status"] = "completed"
|
||||
return workflow_result
|
||||
|
||||
|
||||
# HIPAA-compliant data handling patterns
|
||||
class HIPAACompliancePatterns:
|
||||
"""
|
||||
Reusable patterns for HIPAA-compliant healthcare workflows
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def anonymize_patient_data(data: Dict) -> Dict:
|
||||
"""Remove PHI for analytics and reporting"""
|
||||
safe_fields = ["triage_level", "department", "wait_time", "resource_type"]
|
||||
return {k: v for k, v in data.items() if k in safe_fields}
|
||||
|
||||
@staticmethod
|
||||
def audit_log_access(user_id: str, patient_id: str, action: str, reason: str):
|
||||
"""Create audit trail for EMR access"""
|
||||
return {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"user_id": user_id,
|
||||
"patient_id": patient_id,
|
||||
"action": action,
|
||||
"reason": reason,
|
||||
"ip_address": "10.0.0.1", # Would be actual IP
|
||||
"session_id": "SEC-" + datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def encrypt_sensitive_data(data: Dict) -> Dict:
|
||||
"""Encrypt sensitive fields (placeholder for real encryption)"""
|
||||
sensitive_fields = ["ssn", "dob", "address", "phone"]
|
||||
encrypted = data.copy()
|
||||
for field in sensitive_fields:
|
||||
if field in encrypted:
|
||||
encrypted[field] = f"ENCRYPTED_{field.upper()}"
|
||||
return encrypted
|
||||
|
||||
|
||||
# Cross-department coordination patterns
|
||||
class HealthcareCoordinationPatterns:
|
||||
"""
|
||||
Patterns for coordinating across healthcare departments
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def coordinate_lab_orders(patient_id: str, triage_level: str) -> Agent:
|
||||
"""Create agent for lab order coordination"""
|
||||
return Agent(
|
||||
name="LabCoordinator",
|
||||
instructions=f"""Coordinate laboratory orders for patient {patient_id}.
|
||||
Priority level: {triage_level}.
|
||||
Ensure STAT orders for critical patients.
|
||||
Track specimen collection and result delivery.
|
||||
SLA: Order placement within 2 minutes."""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def coordinate_radiology(patient_id: str, imaging_type: str) -> Agent:
|
||||
"""Create agent for radiology coordination"""
|
||||
return Agent(
|
||||
name="RadiologyCoordinator",
|
||||
instructions=f"""Schedule {imaging_type} imaging for patient {patient_id}.
|
||||
Check for contrast allergies and pregnancy status.
|
||||
Coordinate with transport for patient movement.
|
||||
SLA: Schedule within 5 minutes for urgent cases."""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def coordinate_pharmacy(patient_id: str, medications: List[str]) -> Agent:
|
||||
"""Create agent for pharmacy coordination"""
|
||||
return Agent(
|
||||
name="PharmacyCoordinator",
|
||||
instructions=f"""Verify and dispense medications for patient {patient_id}.
|
||||
Check for drug interactions and allergies.
|
||||
Ensure proper dosing based on patient parameters.
|
||||
SLA: Medication verification within 3 minutes."""
|
||||
)
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
# Process emergency patient
|
||||
patient_id = "PT-2024-00123"
|
||||
chief_complaint = "severe chest pain radiating to left arm"
|
||||
|
||||
result = emergency_triage_workflow(patient_id, chief_complaint)
|
||||
print("Triage workflow result:", result)
|
||||
|
||||
# Create specialized coordinators
|
||||
lab_coordinator = HealthcareCoordinationPatterns.coordinate_lab_orders(
|
||||
patient_id, TriageLevel.EMERGENT.value
|
||||
)
|
||||
|
||||
radiology_coordinator = HealthcareCoordinationPatterns.coordinate_radiology(
|
||||
patient_id, "chest_xray"
|
||||
)
|
||||
|
||||
pharmacy_coordinator = HealthcareCoordinationPatterns.coordinate_pharmacy(
|
||||
patient_id, ["aspirin", "nitroglycerin"]
|
||||
)
|
||||
|
||||
# HIPAA compliance example
|
||||
audit_log = HIPAACompliancePatterns.audit_log_access(
|
||||
"DR-001", patient_id, "EMR_ACCESS", "emergency_triage"
|
||||
)
|
||||
print("Audit log created:", audit_log)
|
||||
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
Manufacturing Industry Template
|
||||
================================
|
||||
Order scheduling, inventory management, and quality inspection workflow
|
||||
Based on SRAO Framework patterns with 70% cross-industry reuse
|
||||
|
||||
Key agents:
|
||||
- ParseOrder: Extracts order details from various formats
|
||||
- CheckInventory: Validates material availability
|
||||
- OptimizeSchedule: Optimizes production scheduling
|
||||
- DefectDetect: Quality control and defect detection
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, tool
|
||||
from typing import Dict, List, Any
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# I/O Schemas
|
||||
class OrderDetails(BaseModel):
|
||||
"""Structured order information"""
|
||||
order_id: str
|
||||
customer_id: str
|
||||
product_id: str
|
||||
quantity: int
|
||||
priority: str = "normal" # normal, high, urgent
|
||||
due_date: str
|
||||
requirements: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class InventoryStatus(BaseModel):
|
||||
"""Material availability status"""
|
||||
material_id: str
|
||||
available_quantity: int
|
||||
reorder_level: int
|
||||
supplier_lead_time: int
|
||||
location: str
|
||||
|
||||
|
||||
class ProductionSchedule(BaseModel):
|
||||
"""Optimized production schedule"""
|
||||
schedule_id: str
|
||||
production_line: str
|
||||
start_time: str
|
||||
end_time: str
|
||||
assigned_workers: List[str]
|
||||
estimated_completion: str
|
||||
bottleneck_analysis: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class QualityReport(BaseModel):
|
||||
"""Quality inspection results"""
|
||||
batch_id: str
|
||||
inspection_time: str
|
||||
defect_rate: float
|
||||
defect_types: List[str]
|
||||
passed: bool
|
||||
recommendations: List[str]
|
||||
|
||||
|
||||
# Tools for agents
|
||||
@tool
|
||||
def extract_order_details(text: str) -> Dict:
|
||||
"""Extract structured order information from unstructured text"""
|
||||
# Simulate order parsing logic
|
||||
return {
|
||||
"order_id": "ORD-2024-001",
|
||||
"customer_id": "CUST-123",
|
||||
"product_id": "PROD-456",
|
||||
"quantity": 100,
|
||||
"priority": "high",
|
||||
"due_date": "2024-03-15",
|
||||
"requirements": {"material": "steel", "finish": "polished"}
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def check_material_availability(product_id: str, quantity: int) -> Dict:
|
||||
"""Check if materials are available for production"""
|
||||
# Simulate inventory check
|
||||
return {
|
||||
"material_id": "MAT-789",
|
||||
"available_quantity": 500,
|
||||
"reorder_level": 100,
|
||||
"supplier_lead_time": 7,
|
||||
"location": "Warehouse-A"
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def calculate_optimal_schedule(order: Dict, inventory: Dict) -> Dict:
|
||||
"""Calculate optimal production schedule based on constraints"""
|
||||
# Simulate scheduling optimization
|
||||
return {
|
||||
"schedule_id": "SCH-2024-001",
|
||||
"production_line": "Line-3",
|
||||
"start_time": "2024-03-10T08:00:00",
|
||||
"end_time": "2024-03-14T17:00:00",
|
||||
"assigned_workers": ["Worker-001", "Worker-002"],
|
||||
"estimated_completion": "2024-03-14T16:30:00",
|
||||
"bottleneck_analysis": {
|
||||
"critical_path": "Assembly",
|
||||
"buffer_time": 2.5
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def perform_quality_inspection(batch_id: str) -> Dict:
|
||||
"""Perform automated quality inspection on production batch"""
|
||||
# Simulate quality control
|
||||
return {
|
||||
"batch_id": batch_id,
|
||||
"inspection_time": "2024-03-14T17:00:00",
|
||||
"defect_rate": 0.02,
|
||||
"defect_types": ["surface_scratch", "dimension_variance"],
|
||||
"passed": True,
|
||||
"recommendations": [
|
||||
"Adjust machine calibration on Line-3",
|
||||
"Increase inspection frequency for next batch"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# Agent Definitions with SLA requirements
|
||||
order_parser = Agent(
|
||||
name="ParseOrder",
|
||||
instructions="""You are an order parsing specialist. Extract and structure order information
|
||||
from various formats (email, PDF, API). Ensure all required fields are captured and validated.
|
||||
SLA: Process within 30 seconds per order.""",
|
||||
tools=[extract_order_details]
|
||||
)
|
||||
|
||||
inventory_checker = Agent(
|
||||
name="CheckInventory",
|
||||
instructions="""You are an inventory management specialist. Verify material availability,
|
||||
check reorder points, and identify potential supply chain issues.
|
||||
SLA: Real-time response within 5 seconds.""",
|
||||
tools=[check_material_availability]
|
||||
)
|
||||
|
||||
schedule_optimizer = Agent(
|
||||
name="OptimizeSchedule",
|
||||
instructions="""You are a production scheduling expert. Optimize production schedules
|
||||
based on order priority, resource availability, and constraints. Minimize idle time
|
||||
and maximize throughput.
|
||||
SLA: Generate schedule within 2 minutes for complex orders.""",
|
||||
tools=[calculate_optimal_schedule]
|
||||
)
|
||||
|
||||
quality_inspector = Agent(
|
||||
name="DefectDetect",
|
||||
instructions="""You are a quality control specialist. Perform automated inspections,
|
||||
detect defects using vision analysis, and provide improvement recommendations.
|
||||
SLA: Complete inspection within 1 minute per batch.""",
|
||||
tools=[perform_quality_inspection]
|
||||
)
|
||||
|
||||
|
||||
# Workflow with fallback strategies
|
||||
def manufacturing_workflow(order_text: str):
|
||||
"""
|
||||
Complete manufacturing workflow from order to quality control
|
||||
Includes fallback strategies for each critical step
|
||||
"""
|
||||
|
||||
# Step 1: Parse order with fallback
|
||||
try:
|
||||
order_details = order_parser.start(f"Parse this order: {order_text}")
|
||||
if not order_details:
|
||||
# Fallback: Manual review queue
|
||||
return {"status": "manual_review_required", "reason": "order_parsing_failed"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "stage": "order_parsing", "error": str(e)}
|
||||
|
||||
# Step 2: Check inventory with fallback
|
||||
try:
|
||||
inventory_status = inventory_checker.start(
|
||||
f"Check materials for order: {order_details}"
|
||||
)
|
||||
if not inventory_status:
|
||||
# Fallback: Alternative supplier check
|
||||
return {"status": "alternative_supplier_needed", "order": order_details}
|
||||
except Exception as e:
|
||||
# Fallback: Conservative estimate with safety margin
|
||||
inventory_status = {"estimated": True, "safety_margin": 1.5}
|
||||
|
||||
# Step 3: Optimize schedule with fallback
|
||||
try:
|
||||
schedule = schedule_optimizer.start(
|
||||
f"Create schedule for order {order_details} with inventory {inventory_status}"
|
||||
)
|
||||
if not schedule:
|
||||
# Fallback: Standard scheduling template
|
||||
schedule = {"template": "standard", "priority": "normal"}
|
||||
except Exception as e:
|
||||
# Fallback: Queue for next available slot
|
||||
schedule = {"status": "queued", "estimated_start": "next_available"}
|
||||
|
||||
# Step 4: Quality inspection with fallback
|
||||
try:
|
||||
quality_report = quality_inspector.start(
|
||||
f"Inspect batch for schedule {schedule}"
|
||||
)
|
||||
if not quality_report:
|
||||
# Fallback: Manual inspection required
|
||||
quality_report = {"requires_manual": True, "inspector_assigned": "QC-Team-1"}
|
||||
except Exception as e:
|
||||
# Fallback: Quarantine for review
|
||||
quality_report = {"status": "quarantined", "review_required": True}
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"order": order_details,
|
||||
"inventory": inventory_status,
|
||||
"schedule": schedule,
|
||||
"quality": quality_report
|
||||
}
|
||||
|
||||
|
||||
# Cross-industry reusable patterns (70% reuse)
|
||||
class IndustryAgentPattern:
|
||||
"""
|
||||
Base pattern reusable across industries
|
||||
Provides common agent capabilities that can be specialized
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_data_parser(name: str, domain: str, sla_seconds: int = 30):
|
||||
"""Create a data parsing agent for any domain"""
|
||||
return Agent(
|
||||
name=name,
|
||||
instructions=f"""You are a {domain} data parsing specialist.
|
||||
Extract and structure information from various formats.
|
||||
Validate all required fields according to {domain} standards.
|
||||
SLA: Process within {sla_seconds} seconds."""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_resource_checker(name: str, resource_type: str, sla_seconds: int = 5):
|
||||
"""Create a resource availability checker for any resource type"""
|
||||
return Agent(
|
||||
name=name,
|
||||
instructions=f"""You are a {resource_type} availability specialist.
|
||||
Verify {resource_type} availability and identify constraints.
|
||||
Provide real-time status and alternative options if needed.
|
||||
SLA: Response within {sla_seconds} seconds."""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_optimizer(name: str, optimization_target: str, sla_minutes: int = 2):
|
||||
"""Create an optimization agent for any target metric"""
|
||||
return Agent(
|
||||
name=name,
|
||||
instructions=f"""You are an optimization expert for {optimization_target}.
|
||||
Find optimal solutions considering all constraints and priorities.
|
||||
Minimize waste and maximize efficiency.
|
||||
SLA: Generate solution within {sla_minutes} minutes."""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_inspector(name: str, inspection_type: str, sla_minutes: int = 1):
|
||||
"""Create an inspection agent for any quality metric"""
|
||||
return Agent(
|
||||
name=name,
|
||||
instructions=f"""You are a {inspection_type} inspection specialist.
|
||||
Perform automated analysis and detect anomalies.
|
||||
Provide actionable recommendations for improvement.
|
||||
SLA: Complete inspection within {sla_minutes} minute(s)."""
|
||||
)
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
# Create a manufacturing workflow instance
|
||||
order = "Customer ABC needs 100 units of Product-XYZ by March 15th, high priority"
|
||||
|
||||
# Option 1: Use specialized agents
|
||||
result = manufacturing_workflow(order)
|
||||
print("Manufacturing workflow result:", result)
|
||||
|
||||
# Option 2: Use reusable patterns for other industries
|
||||
# These patterns can be adapted for Energy, Healthcare, Agriculture, Transportation
|
||||
energy_parser = IndustryAgentPattern.create_data_parser(
|
||||
"EnergyDataParser", "wind farm telemetry", sla_seconds=10
|
||||
)
|
||||
|
||||
healthcare_checker = IndustryAgentPattern.create_resource_checker(
|
||||
"BedAvailability", "hospital bed", sla_seconds=3
|
||||
)
|
||||
|
||||
agriculture_optimizer = IndustryAgentPattern.create_optimizer(
|
||||
"IrrigationOptimizer", "water usage", sla_minutes=5
|
||||
)
|
||||
|
||||
transport_inspector = IndustryAgentPattern.create_inspector(
|
||||
"TunnelSafety", "structural integrity", sla_minutes=2
|
||||
)
|
||||
@@ -0,0 +1,599 @@
|
||||
"""
|
||||
Transportation Industry Template
|
||||
================================
|
||||
Tunnel safety monitoring and infrastructure management workflow
|
||||
Based on SRAO Framework with LiDAR and sensor fusion
|
||||
|
||||
Key agents:
|
||||
- LiDARFusion: Processes LiDAR point cloud data
|
||||
- DisplacementCalculator: Calculates structural displacement
|
||||
- HeatmapGenerator: Generates safety heatmaps
|
||||
- MaintenancePlanner: Plans infrastructure maintenance
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, tool
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
import math
|
||||
|
||||
|
||||
# Safety levels for infrastructure
|
||||
class SafetyLevel(str, Enum):
|
||||
SAFE = "safe"
|
||||
CAUTION = "caution"
|
||||
WARNING = "warning"
|
||||
DANGER = "danger"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
# Infrastructure types
|
||||
class InfrastructureType(str, Enum):
|
||||
TUNNEL = "tunnel"
|
||||
BRIDGE = "bridge"
|
||||
HIGHWAY = "highway"
|
||||
RAILWAY = "railway"
|
||||
AIRPORT = "airport"
|
||||
|
||||
|
||||
# I/O Schemas
|
||||
class LiDARScan(BaseModel):
|
||||
"""LiDAR scan data for infrastructure"""
|
||||
scan_id: str
|
||||
infrastructure_id: str
|
||||
scan_time: str
|
||||
point_cloud_size: int # Number of points
|
||||
scan_resolution: float # Points per square meter
|
||||
coverage_area: float # Square meters
|
||||
anomaly_points: List[Tuple[float, float, float]] # XYZ coordinates
|
||||
reference_baseline: Optional[Dict[str, Any]]
|
||||
|
||||
|
||||
class DisplacementAnalysis(BaseModel):
|
||||
"""Structural displacement analysis"""
|
||||
analysis_id: str
|
||||
infrastructure_id: str
|
||||
analysis_time: str
|
||||
max_displacement: float # millimeters
|
||||
displacement_vector: Tuple[float, float, float] # XYZ displacement
|
||||
critical_zones: List[Dict[str, Any]]
|
||||
settlement_rate: float # mm/month
|
||||
tilt_angle: float # degrees
|
||||
safety_factor: float
|
||||
|
||||
|
||||
class SafetyHeatmap(BaseModel):
|
||||
"""Infrastructure safety heatmap"""
|
||||
heatmap_id: str
|
||||
infrastructure_id: str
|
||||
generation_time: str
|
||||
grid_resolution: float # meters
|
||||
safety_scores: List[List[float]] # 2D grid of safety scores
|
||||
critical_areas: List[Dict[str, Any]]
|
||||
overall_safety: SafetyLevel
|
||||
risk_trends: Dict[str, Any]
|
||||
|
||||
|
||||
class MaintenanceSchedule(BaseModel):
|
||||
"""Maintenance planning schedule"""
|
||||
schedule_id: str
|
||||
infrastructure_id: str
|
||||
maintenance_type: str # preventive, corrective, emergency
|
||||
priority_level: str # low, medium, high, critical
|
||||
scheduled_date: str
|
||||
estimated_duration: int # hours
|
||||
required_resources: List[str]
|
||||
traffic_impact: str
|
||||
cost_estimate: float
|
||||
|
||||
|
||||
# Transportation-specific tools
|
||||
@tool
|
||||
def process_lidar_scan(infrastructure_id: str, scan_type: str = "full") -> Dict:
|
||||
"""Process LiDAR point cloud data for structural analysis"""
|
||||
# Simulate LiDAR processing
|
||||
return {
|
||||
"scan_id": f"LIDAR-{datetime.now().strftime('%Y%m%d%H%M')}",
|
||||
"infrastructure_id": infrastructure_id,
|
||||
"scan_time": datetime.now().isoformat(),
|
||||
"point_cloud_size": 5000000, # 5 million points
|
||||
"scan_resolution": 100.0, # points/m²
|
||||
"coverage_area": 50000.0, # m²
|
||||
"anomaly_points": [
|
||||
(10.5, 20.3, 5.1),
|
||||
(15.2, 25.8, 5.3),
|
||||
(20.1, 30.5, 5.5)
|
||||
],
|
||||
"reference_baseline": {
|
||||
"date": "2024-01-01",
|
||||
"baseline_id": "BSL-001"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def calculate_displacement(current_scan: Dict, baseline_scan: Dict) -> Dict:
|
||||
"""Calculate structural displacement from LiDAR scans"""
|
||||
# Simulate displacement calculation
|
||||
anomalies = current_scan.get("anomaly_points", [])
|
||||
|
||||
if len(anomalies) > 5:
|
||||
max_displacement = 15.0
|
||||
safety_factor = 0.6
|
||||
safety_level = SafetyLevel.WARNING
|
||||
elif len(anomalies) > 2:
|
||||
max_displacement = 8.0
|
||||
safety_factor = 0.8
|
||||
safety_level = SafetyLevel.CAUTION
|
||||
else:
|
||||
max_displacement = 3.0
|
||||
safety_factor = 0.95
|
||||
safety_level = SafetyLevel.SAFE
|
||||
|
||||
return {
|
||||
"analysis_id": f"DISP-{datetime.now().strftime('%Y%m%d%H%M')}",
|
||||
"infrastructure_id": current_scan["infrastructure_id"],
|
||||
"analysis_time": datetime.now().isoformat(),
|
||||
"max_displacement": max_displacement,
|
||||
"displacement_vector": (2.5, 1.8, max_displacement),
|
||||
"critical_zones": [
|
||||
{
|
||||
"zone_id": "CZ-001",
|
||||
"location": "tunnel_crown",
|
||||
"displacement": max_displacement * 0.8,
|
||||
"risk": "high" if max_displacement > 10 else "medium"
|
||||
}
|
||||
],
|
||||
"settlement_rate": max_displacement / 30, # mm/month
|
||||
"tilt_angle": math.degrees(math.atan(max_displacement / 1000)),
|
||||
"safety_factor": safety_factor
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def generate_safety_heatmap(displacement_data: Dict, sensor_data: List[Dict]) -> Dict:
|
||||
"""Generate safety heatmap visualization for infrastructure"""
|
||||
# Simulate heatmap generation
|
||||
safety_factor = displacement_data.get("safety_factor", 1.0)
|
||||
|
||||
# Create grid (simplified 10x10)
|
||||
grid_size = 10
|
||||
safety_grid = []
|
||||
critical_areas = []
|
||||
|
||||
for i in range(grid_size):
|
||||
row = []
|
||||
for j in range(grid_size):
|
||||
# Generate safety score based on position and displacement
|
||||
base_score = safety_factor
|
||||
distance_factor = math.sqrt((i-5)**2 + (j-5)**2) / 10
|
||||
safety_score = min(1.0, base_score + (0.2 * distance_factor))
|
||||
row.append(safety_score)
|
||||
|
||||
if safety_score < 0.7:
|
||||
critical_areas.append({
|
||||
"grid_position": (i, j),
|
||||
"safety_score": safety_score,
|
||||
"recommended_action": "immediate_inspection"
|
||||
})
|
||||
safety_grid.append(row)
|
||||
|
||||
# Determine overall safety
|
||||
avg_safety = sum(sum(row) for row in safety_grid) / (grid_size * grid_size)
|
||||
if avg_safety > 0.9:
|
||||
overall = SafetyLevel.SAFE
|
||||
elif avg_safety > 0.8:
|
||||
overall = SafetyLevel.CAUTION
|
||||
elif avg_safety > 0.7:
|
||||
overall = SafetyLevel.WARNING
|
||||
else:
|
||||
overall = SafetyLevel.DANGER
|
||||
|
||||
return {
|
||||
"heatmap_id": f"HEAT-{datetime.now().strftime('%Y%m%d%H%M')}",
|
||||
"infrastructure_id": displacement_data["infrastructure_id"],
|
||||
"generation_time": datetime.now().isoformat(),
|
||||
"grid_resolution": 5.0, # meters
|
||||
"safety_scores": safety_grid,
|
||||
"critical_areas": critical_areas,
|
||||
"overall_safety": overall.value,
|
||||
"risk_trends": {
|
||||
"trend": "deteriorating" if avg_safety < 0.8 else "stable",
|
||||
"rate": -0.02 if avg_safety < 0.8 else 0.0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def plan_maintenance(infrastructure_id: str, safety_analysis: Dict, traffic_data: Dict) -> Dict:
|
||||
"""Plan maintenance based on safety analysis and traffic patterns"""
|
||||
safety_level = safety_analysis.get("overall_safety", "safe")
|
||||
|
||||
# Determine maintenance urgency
|
||||
if safety_level in ["danger", "critical"]:
|
||||
maintenance_type = "emergency"
|
||||
priority = "critical"
|
||||
days_until = 1
|
||||
duration = 48
|
||||
elif safety_level == "warning":
|
||||
maintenance_type = "corrective"
|
||||
priority = "high"
|
||||
days_until = 7
|
||||
duration = 24
|
||||
elif safety_level == "caution":
|
||||
maintenance_type = "preventive"
|
||||
priority = "medium"
|
||||
days_until = 30
|
||||
duration = 12
|
||||
else:
|
||||
maintenance_type = "preventive"
|
||||
priority = "low"
|
||||
days_until = 90
|
||||
duration = 8
|
||||
|
||||
# Traffic impact assessment
|
||||
peak_traffic = traffic_data.get("peak_volume", 1000)
|
||||
if peak_traffic > 5000:
|
||||
traffic_impact = "severe"
|
||||
cost_multiplier = 2.0
|
||||
elif peak_traffic > 2000:
|
||||
traffic_impact = "moderate"
|
||||
cost_multiplier = 1.5
|
||||
else:
|
||||
traffic_impact = "minimal"
|
||||
cost_multiplier = 1.0
|
||||
|
||||
return {
|
||||
"schedule_id": f"MAINT-{datetime.now().strftime('%Y%m%d%H%M')}",
|
||||
"infrastructure_id": infrastructure_id,
|
||||
"maintenance_type": maintenance_type,
|
||||
"priority_level": priority,
|
||||
"scheduled_date": (datetime.now() + timedelta(days=days_until)).isoformat(),
|
||||
"estimated_duration": duration,
|
||||
"required_resources": [
|
||||
"inspection_team",
|
||||
"repair_crew",
|
||||
"safety_equipment",
|
||||
"traffic_control"
|
||||
],
|
||||
"traffic_impact": traffic_impact,
|
||||
"cost_estimate": 50000 * cost_multiplier * (duration / 24)
|
||||
}
|
||||
|
||||
|
||||
# Transportation agent definitions
|
||||
lidar_agent = Agent(
|
||||
name="LiDARFusion",
|
||||
instructions="""You are a LiDAR data processing specialist for infrastructure.
|
||||
Process point cloud data from mobile and terrestrial LiDAR scanners.
|
||||
Perform data fusion from multiple sensor sources.
|
||||
Identify structural anomalies and deformations.
|
||||
SLA: Process scan data within 5 minutes.""",
|
||||
tools=[process_lidar_scan]
|
||||
)
|
||||
|
||||
displacement_agent = Agent(
|
||||
name="DisplacementCalculator",
|
||||
instructions="""You are a structural displacement analysis expert.
|
||||
Calculate precise displacement measurements from temporal scans.
|
||||
Analyze settlement patterns and structural movements.
|
||||
Predict future displacement trends using ML models.
|
||||
SLA: Complete analysis within 2 minutes.""",
|
||||
tools=[calculate_displacement]
|
||||
)
|
||||
|
||||
heatmap_agent = Agent(
|
||||
name="HeatmapGenerator",
|
||||
instructions="""You are a safety visualization specialist.
|
||||
Generate intuitive safety heatmaps for infrastructure monitoring.
|
||||
Integrate multiple data sources for comprehensive risk assessment.
|
||||
Highlight critical areas requiring immediate attention.
|
||||
SLA: Generate heatmap within 1 minute.""",
|
||||
tools=[generate_safety_heatmap]
|
||||
)
|
||||
|
||||
maintenance_agent = Agent(
|
||||
name="MaintenancePlanner",
|
||||
instructions="""You are an infrastructure maintenance planning expert.
|
||||
Schedule maintenance based on structural health and traffic patterns.
|
||||
Optimize maintenance windows to minimize disruption.
|
||||
Coordinate resources and estimate costs.
|
||||
SLA: Create maintenance plan within 3 minutes.""",
|
||||
tools=[plan_maintenance]
|
||||
)
|
||||
|
||||
|
||||
# Transportation infrastructure monitoring workflow
|
||||
def infrastructure_monitoring_workflow(
|
||||
infrastructure_ids: List[str],
|
||||
infrastructure_type: InfrastructureType,
|
||||
traffic_data: Dict
|
||||
):
|
||||
"""
|
||||
Complete infrastructure monitoring workflow from scanning to maintenance
|
||||
Includes safety assessment and predictive maintenance
|
||||
"""
|
||||
|
||||
monitoring_results = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"infrastructure_type": infrastructure_type.value,
|
||||
"structures_monitored": len(infrastructure_ids),
|
||||
"safety_assessments": [],
|
||||
"maintenance_required": [],
|
||||
"emergency_alerts": [],
|
||||
"network_health_score": 0.0
|
||||
}
|
||||
|
||||
total_safety_score = 0.0
|
||||
|
||||
for infra_id in infrastructure_ids:
|
||||
try:
|
||||
# Step 1: LiDAR scanning
|
||||
lidar_scan = lidar_agent.start(
|
||||
f"Process LiDAR scan for {infrastructure_type.value} {infra_id}"
|
||||
)
|
||||
|
||||
# Step 2: Displacement analysis
|
||||
# Get baseline (would be from database in real scenario)
|
||||
baseline = {"scan_id": "BSL-001", "point_cloud": []}
|
||||
|
||||
displacement_analysis = displacement_agent.start(
|
||||
f"Calculate displacement for scan: {lidar_scan} against baseline: {baseline}"
|
||||
)
|
||||
|
||||
# Step 3: Generate safety heatmap
|
||||
sensor_data = [] # Would include IoT sensor readings
|
||||
safety_heatmap = heatmap_agent.start(
|
||||
f"Generate heatmap for displacement: {displacement_analysis} "
|
||||
f"with sensors: {sensor_data}"
|
||||
)
|
||||
|
||||
monitoring_results["safety_assessments"].append({
|
||||
"infrastructure_id": infra_id,
|
||||
"safety_level": safety_heatmap.get("overall_safety"),
|
||||
"max_displacement": displacement_analysis.get("max_displacement"),
|
||||
"critical_zones": len(safety_heatmap.get("critical_areas", []))
|
||||
})
|
||||
|
||||
# Check for emergency conditions
|
||||
if safety_heatmap.get("overall_safety") in ["danger", "critical"]:
|
||||
monitoring_results["emergency_alerts"].append({
|
||||
"infrastructure_id": infra_id,
|
||||
"alert_level": "emergency",
|
||||
"safety_status": safety_heatmap["overall_safety"],
|
||||
"immediate_action": "close_to_traffic",
|
||||
"notification_sent": True
|
||||
})
|
||||
|
||||
# Step 4: Emergency maintenance planning
|
||||
maintenance_plan = maintenance_agent.start(
|
||||
f"Plan emergency maintenance for {infra_id} "
|
||||
f"with safety: {safety_heatmap} and traffic: {traffic_data}"
|
||||
)
|
||||
monitoring_results["maintenance_required"].append(maintenance_plan)
|
||||
|
||||
elif safety_heatmap.get("overall_safety") in ["warning", "caution"]:
|
||||
# Regular maintenance planning
|
||||
maintenance_plan = maintenance_agent.start(
|
||||
f"Plan maintenance for {infra_id} "
|
||||
f"with safety: {safety_heatmap} and traffic: {traffic_data}"
|
||||
)
|
||||
monitoring_results["maintenance_required"].append(maintenance_plan)
|
||||
|
||||
# Calculate safety score contribution
|
||||
safety_mapping = {
|
||||
"safe": 1.0, "caution": 0.8, "warning": 0.6,
|
||||
"danger": 0.3, "critical": 0.1
|
||||
}
|
||||
total_safety_score += safety_mapping.get(
|
||||
safety_heatmap.get("overall_safety", "safe"), 0.5
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Fallback: Manual inspection
|
||||
monitoring_results["emergency_alerts"].append({
|
||||
"infrastructure_id": infra_id,
|
||||
"alert_level": "system_failure",
|
||||
"error": str(e),
|
||||
"action": "dispatch_manual_inspection_team"
|
||||
})
|
||||
|
||||
# Calculate network health score
|
||||
if infrastructure_ids:
|
||||
monitoring_results["network_health_score"] = (
|
||||
total_safety_score / len(infrastructure_ids) * 100
|
||||
)
|
||||
|
||||
return monitoring_results
|
||||
|
||||
|
||||
# Multi-modal transportation patterns
|
||||
class MultiModalTransportPatterns:
|
||||
"""
|
||||
Patterns for different transportation infrastructure types
|
||||
Demonstrates cross-industry reuse for transportation sector
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def adapt_for_bridge_monitoring() -> Agent:
|
||||
"""Adapt tunnel monitoring for bridge structures"""
|
||||
return Agent(
|
||||
name="BridgeMonitor",
|
||||
instructions="""Monitor bridge structural health using sensors.
|
||||
Track deck deflection, cable tension, and vibration patterns.
|
||||
Detect fatigue cracks and corrosion.
|
||||
Consider wind and seismic loads.
|
||||
SLA: Real-time monitoring with 10-second updates."""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def adapt_for_railway_tracks() -> Agent:
|
||||
"""Adapt for railway track monitoring"""
|
||||
return Agent(
|
||||
name="RailwayMonitor",
|
||||
instructions="""Monitor railway track geometry and condition.
|
||||
Detect rail defects, gauge variations, and ballast degradation.
|
||||
Analyze wheel-rail interaction forces.
|
||||
Predict track maintenance needs.
|
||||
SLA: Process track inspection data within 3 minutes per km."""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def adapt_for_airport_runways() -> Agent:
|
||||
"""Adapt for airport runway monitoring"""
|
||||
return Agent(
|
||||
name="RunwayMonitor",
|
||||
instructions="""Monitor airport runway surface conditions.
|
||||
Detect FOD (Foreign Object Debris) and surface irregularities.
|
||||
Assess pavement condition and friction levels.
|
||||
Monitor drainage and lighting systems.
|
||||
SLA: Complete runway scan within 10 minutes."""
|
||||
)
|
||||
|
||||
|
||||
# Intelligent traffic management integration
|
||||
class TrafficManagementPatterns:
|
||||
"""
|
||||
Patterns for integrating with intelligent traffic systems
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def optimize_traffic_flow(maintenance_window: Dict, traffic_patterns: Dict) -> Dict:
|
||||
"""Optimize traffic flow during maintenance"""
|
||||
peak_hours = traffic_patterns.get("peak_hours", [7, 9, 17, 19])
|
||||
maintenance_start = maintenance_window.get("start_time", "02:00")
|
||||
|
||||
# Calculate optimal diversion routes
|
||||
return {
|
||||
"strategy": "dynamic_routing",
|
||||
"diversion_routes": [
|
||||
{"route_id": "ALT-1", "capacity": 0.6, "added_time": 15},
|
||||
{"route_id": "ALT-2", "capacity": 0.3, "added_time": 20}
|
||||
],
|
||||
"lane_management": {
|
||||
"reversible_lanes": True,
|
||||
"peak_direction": "inbound" if int(maintenance_start[:2]) < 12 else "outbound"
|
||||
},
|
||||
"public_transport": {
|
||||
"increase_frequency": True,
|
||||
"additional_services": 4
|
||||
},
|
||||
"estimated_delay": 10 # minutes
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def incident_response_plan(incident_type: str, location: Dict) -> Dict:
|
||||
"""Generate incident response plan"""
|
||||
response_times = {
|
||||
"accident": 5,
|
||||
"breakdown": 10,
|
||||
"infrastructure_failure": 2,
|
||||
"weather_hazard": 15
|
||||
}
|
||||
|
||||
return {
|
||||
"incident_id": f"INC-{datetime.now().strftime('%Y%m%d%H%M')}",
|
||||
"response_time": response_times.get(incident_type, 10),
|
||||
"resources_dispatched": [
|
||||
r for r in [
|
||||
"emergency_response_team",
|
||||
"traffic_control_unit",
|
||||
"maintenance_crew" if "infrastructure" in incident_type else None
|
||||
] if r is not None
|
||||
],
|
||||
"traffic_management": {
|
||||
"lane_closures": 2,
|
||||
"speed_reduction": 30, # km/h
|
||||
"message_signs": ["Incident ahead", "Reduce speed", "Merge left"]
|
||||
},
|
||||
"estimated_clearance": 45 # minutes
|
||||
}
|
||||
|
||||
|
||||
# Predictive maintenance patterns
|
||||
class PredictiveMaintenancePatterns:
|
||||
"""
|
||||
ML-based predictive maintenance for transportation infrastructure
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def predict_failure_probability(
|
||||
displacement_trend: List[float],
|
||||
environmental_factors: Dict
|
||||
) -> float:
|
||||
"""Predict probability of structural failure"""
|
||||
# Simplified prediction model
|
||||
trend_slope = (displacement_trend[-1] - displacement_trend[0]) / len(displacement_trend)
|
||||
temperature_factor = abs(environmental_factors.get("temp_variation", 0)) / 50
|
||||
moisture_factor = environmental_factors.get("humidity", 50) / 100
|
||||
|
||||
base_probability = 0.1
|
||||
trend_contribution = max(0, trend_slope * 10)
|
||||
env_contribution = (temperature_factor + moisture_factor) * 0.2
|
||||
|
||||
return min(1.0, base_probability + trend_contribution + env_contribution)
|
||||
|
||||
@staticmethod
|
||||
def optimize_maintenance_schedule(
|
||||
infrastructure_conditions: List[Dict],
|
||||
budget_constraint: float
|
||||
) -> List[Dict]:
|
||||
"""Optimize maintenance schedule across infrastructure network"""
|
||||
# Sort by urgency (safety_factor * cost_efficiency)
|
||||
prioritized = sorted(
|
||||
infrastructure_conditions,
|
||||
key=lambda x: (1 - x.get("safety_factor", 1)) * (1 / x.get("cost", 1)),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
scheduled = []
|
||||
total_cost = 0
|
||||
|
||||
for infra in prioritized:
|
||||
if total_cost + infra.get("cost", 0) <= budget_constraint:
|
||||
scheduled.append({
|
||||
"infrastructure_id": infra["id"],
|
||||
"scheduled_date": infra["recommended_date"],
|
||||
"priority": "high" if infra["safety_factor"] < 0.7 else "medium"
|
||||
})
|
||||
total_cost += infra.get("cost", 0)
|
||||
|
||||
return scheduled
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
# Monitor tunnel network
|
||||
tunnel_ids = [f"TUN-{i:03d}" for i in range(1, 6)]
|
||||
traffic = {
|
||||
"peak_volume": 3000,
|
||||
"off_peak_volume": 800,
|
||||
"peak_hours": [7, 9, 17, 19]
|
||||
}
|
||||
|
||||
result = infrastructure_monitoring_workflow(
|
||||
tunnel_ids,
|
||||
InfrastructureType.TUNNEL,
|
||||
traffic
|
||||
)
|
||||
print("Infrastructure monitoring result:", result)
|
||||
|
||||
# Multi-modal adaptation examples
|
||||
bridge_monitor = MultiModalTransportPatterns.adapt_for_bridge_monitoring()
|
||||
railway_monitor = MultiModalTransportPatterns.adapt_for_railway_tracks()
|
||||
runway_monitor = MultiModalTransportPatterns.adapt_for_airport_runways()
|
||||
|
||||
# Traffic management example
|
||||
maintenance = {"start_time": "22:00", "duration": 8}
|
||||
traffic_plan = TrafficManagementPatterns.optimize_traffic_flow(maintenance, traffic)
|
||||
print("Traffic optimization plan:", traffic_plan)
|
||||
|
||||
# Predictive maintenance example
|
||||
displacement_history = [2.0, 2.3, 2.8, 3.5, 4.3] # mm over months
|
||||
environment = {"temp_variation": 30, "humidity": 75}
|
||||
|
||||
failure_prob = PredictiveMaintenancePatterns.predict_failure_probability(
|
||||
displacement_history, environment
|
||||
)
|
||||
print(f"Failure probability: {failure_prob:.2%}")
|
||||
@@ -214,8 +214,13 @@ def example_subagent_delegation():
|
||||
assert hasattr(agent, "delegate"), "Agent should have delegate method"
|
||||
assert callable(agent.delegate), "delegate should be callable"
|
||||
|
||||
# Check _create_subagent method
|
||||
subagent = agent._create_subagent("explorer")
|
||||
# Create a subagent using the public profile API
|
||||
from praisonaiagents.agents.profiles import BUILTIN_PROFILES, get_profile
|
||||
explorer_profile = get_profile("explorer") if "explorer" in BUILTIN_PROFILES else None
|
||||
subagent = Agent(
|
||||
name="subagent_explorer",
|
||||
instructions=explorer_profile.system_prompt if explorer_profile else "You are an explorer assistant.",
|
||||
)
|
||||
print(f"Created subagent: {subagent.name}")
|
||||
print(f"Subagent instructions: {subagent.instructions[:50]}...")
|
||||
|
||||
|
||||
@@ -67,7 +67,11 @@ def main():
|
||||
|
||||
# 4. Subagent delegation
|
||||
print("\n4. Subagent Delegation:")
|
||||
subagent = agent._create_subagent("explorer")
|
||||
explorer_profile = get_profile("explorer") if "explorer" in BUILTIN_PROFILES else None
|
||||
subagent = Agent(
|
||||
name="subagent_explorer",
|
||||
instructions=explorer_profile.system_prompt if explorer_profile else "You are an explorer assistant.",
|
||||
)
|
||||
print(f" Created subagent: {subagent.name}")
|
||||
print(f" Profile: explorer (read-only)")
|
||||
|
||||
|
||||
@@ -4,43 +4,70 @@ Basic handoff example demonstrating agent-to-agent delegation.
|
||||
This example shows how agents can hand off tasks to specialized agents.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import TypedDict, Optional
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Create specialized agents
|
||||
# Define typed payload contracts for each handoff
|
||||
class BillingPayload(BaseModel):
|
||||
account_id: str
|
||||
invoice_amount: Optional[float] = None
|
||||
issue_type: str = "billing"
|
||||
|
||||
class RefundPayload(TypedDict):
|
||||
transaction_id: str
|
||||
amount: float
|
||||
reason: str
|
||||
|
||||
class TechnicalPayload(BaseModel):
|
||||
error_code: Optional[str] = None
|
||||
device_info: str
|
||||
user_agent: str
|
||||
|
||||
# Create specialized agents that expect typed payloads from the triage
|
||||
# (In a real scenario, the agents would validate and use the payload.)
|
||||
billing_agent = Agent(
|
||||
name="Billing Agent",
|
||||
role="Billing Specialist",
|
||||
goal="Handle all billing-related inquiries and tasks",
|
||||
backstory="I am an expert in billing systems, payment processing, and invoice management."
|
||||
goal="Handle all billing-related inquiries and tasks using structured data",
|
||||
backstory="I am an expert in billing systems, payment processing, and invoice management.",
|
||||
input_payload_schema=BillingPayload # hypothetical but consistent with design
|
||||
)
|
||||
|
||||
refund_agent = Agent(
|
||||
name="Refund Agent",
|
||||
role="Refund Specialist",
|
||||
goal="Process refund requests and handle refund-related issues",
|
||||
backstory="I specialize in processing refunds, evaluating refund eligibility, and ensuring customer satisfaction."
|
||||
goal="Process refund requests and handle refund-related issues with validated data",
|
||||
backstory="I specialize in processing refunds, evaluating refund eligibility, and ensuring customer satisfaction.",
|
||||
input_payload_schema=RefundPayload
|
||||
)
|
||||
|
||||
technical_support_agent = Agent(
|
||||
name="Technical Support",
|
||||
role="Technical Support Specialist",
|
||||
goal="Resolve technical issues and provide technical assistance",
|
||||
backstory="I am skilled in troubleshooting technical problems and providing solutions."
|
||||
goal="Resolve technical issues and provide technical assistance with structured context",
|
||||
backstory="I am skilled in troubleshooting technical problems and providing solutions.",
|
||||
input_payload_schema=TechnicalPayload
|
||||
)
|
||||
|
||||
# Create a triage agent with handoffs to specialized agents
|
||||
# Create a triage agent with typed handoffs
|
||||
triage_agent = Agent(
|
||||
name="Triage Agent",
|
||||
role="Customer Service Triage",
|
||||
goal="Understand customer needs and route them to the appropriate specialist",
|
||||
goal="Understand customer needs and route them to the appropriate specialist with a validated payload",
|
||||
backstory="I analyze customer requests and direct them to the most suitable specialist for efficient resolution.",
|
||||
instructions="""Analyze the customer's request and determine which specialist can best help:
|
||||
- For billing questions, payment issues, or invoices, transfer to the Billing Agent
|
||||
- For refund requests or refund status inquiries, transfer to the Refund Agent
|
||||
- For technical problems or product issues, transfer to Technical Support
|
||||
|
||||
Always explain why you're transferring the customer before doing so.""",
|
||||
handoffs=[billing_agent, refund_agent, technical_support_agent]
|
||||
Always explain why you're transferring the customer before doing so.
|
||||
When handing off, populate the appropriate payload with extracted data.""",
|
||||
handoffs=[
|
||||
(billing_agent, BillingPayload),
|
||||
(refund_agent, RefundPayload),
|
||||
(technical_support_agent, TechnicalPayload)
|
||||
]
|
||||
)
|
||||
|
||||
# Example usage
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Example: Using MuAPI with PraisonAI for image generation.
|
||||
|
||||
MuAPI (https://muapi.ai) provides unified access to 400+ generative media
|
||||
models — Flux, Midjourney, GPT-4o Image, Google Imagen, Seedream, Veo3,
|
||||
Kling, Wan, and more — through a single API.
|
||||
|
||||
This example creates a PraisonAI agent that generates images on demand
|
||||
using the MuAPI image generation API as a custom tool.
|
||||
|
||||
Setup:
|
||||
pip install praisonaiagents requests
|
||||
export MUAPI_API_KEY=your_key_here
|
||||
# Get an API key at https://muapi.ai/dashboard/api-keys
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import requests
|
||||
from praisonaiagents import Agent, Tool
|
||||
|
||||
|
||||
MUAPI_BASE_URL = "https://api.muapi.ai/api/v1"
|
||||
|
||||
ALLOWED_MODELS = {
|
||||
"flux-schnell", "flux-dev", "flux-kontext-dev", "flux-kontext-pro",
|
||||
"midjourney", "gpt4o", "imagen4", "imagen4-fast", "seedream",
|
||||
"hidream-fast", "hidream-dev", "reve", "ideogram", "hunyuan",
|
||||
}
|
||||
|
||||
|
||||
def generate_image(prompt: str, model: str = "flux-schnell") -> str:
|
||||
"""Generate an image using MuAPI and return the output URL.
|
||||
|
||||
Args:
|
||||
prompt: Text description of the image to generate.
|
||||
model: MuAPI model ID. Options include flux-schnell, flux-dev,
|
||||
midjourney, gpt4o, imagen4, seedream, hidream-fast, reve.
|
||||
|
||||
Returns:
|
||||
URL of the generated image.
|
||||
"""
|
||||
if model not in ALLOWED_MODELS:
|
||||
raise ValueError(
|
||||
f"Unsupported model '{model}'. Choose from: {sorted(ALLOWED_MODELS)}"
|
||||
)
|
||||
|
||||
api_key = os.environ.get("MUAPI_API_KEY", "")
|
||||
if not api_key:
|
||||
raise ValueError("MUAPI_API_KEY environment variable not set")
|
||||
|
||||
headers = {"x-api-key": api_key, "Content-Type": "application/json"}
|
||||
|
||||
# Submit the generation request
|
||||
submit_resp = requests.post(
|
||||
f"{MUAPI_BASE_URL}/{model}",
|
||||
headers=headers,
|
||||
json={"prompt": prompt},
|
||||
timeout=30,
|
||||
)
|
||||
submit_resp.raise_for_status()
|
||||
submit_data = submit_resp.json()
|
||||
request_id = submit_data.get("request_id")
|
||||
if not request_id:
|
||||
raise RuntimeError(f"MuAPI did not return a request_id: {submit_data}")
|
||||
|
||||
# Poll until completion
|
||||
for _ in range(120):
|
||||
time.sleep(3)
|
||||
poll_resp = requests.get(
|
||||
f"{MUAPI_BASE_URL}/predictions/{request_id}/result",
|
||||
headers={"x-api-key": api_key},
|
||||
timeout=15,
|
||||
)
|
||||
poll_resp.raise_for_status()
|
||||
data = poll_resp.json()
|
||||
status = data.get("status")
|
||||
if status == "completed":
|
||||
outputs = data.get("outputs") or []
|
||||
if not outputs:
|
||||
raise RuntimeError(f"Generation completed but returned no outputs: {data}")
|
||||
return outputs[0]
|
||||
if status in ("failed", "cancelled"):
|
||||
raise RuntimeError(f"Image generation {status}: {data.get('error', '')}")
|
||||
|
||||
raise TimeoutError("Image generation timed out after 6 minutes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
image_tool = Tool(
|
||||
name="generate_image",
|
||||
description=(
|
||||
"Generate an image from a text description using MuAPI's 400+ model library. "
|
||||
"Supported models: flux-schnell (fast), flux-dev, midjourney, gpt4o, imagen4, "
|
||||
"seedream, hidream-fast, reve. Returns a URL to the generated image."
|
||||
),
|
||||
function=generate_image,
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name="Image Generator",
|
||||
instructions=(
|
||||
"You are a creative image generation assistant. "
|
||||
"When asked to create an image, use the generate_image tool. "
|
||||
"Pick the best model for the task: flux-schnell for speed, "
|
||||
"midjourney for artistic quality, gpt4o for photorealism."
|
||||
),
|
||||
tools=[image_tool],
|
||||
)
|
||||
|
||||
# Example: generate a product photo
|
||||
response = agent.start(
|
||||
"Generate a photorealistic product photo of a sleek black coffee mug "
|
||||
"on a white marble table with soft morning light. Use the best model for photorealism."
|
||||
)
|
||||
print(response)
|
||||
|
||||
# Example: generate concept art
|
||||
response = agent.start(
|
||||
"Create a cyberpunk cityscape at night with neon reflections on wet streets. "
|
||||
"Use midjourney for artistic quality."
|
||||
)
|
||||
print(response)
|
||||
@@ -539,7 +539,7 @@ Repeat Phases 12 → 13 → 14 until the mergeability gate in Phase 13 is fully
|
||||
|
||||
### When the PR has merge conflicts
|
||||
|
||||
If `mergeStateStatus=DIRTY`, ask Claude to rebase rather than resolving conflicts yourself on non-trivial logic:
|
||||
If `mergeStateStatus=DIRTY`, the **Auto Merge Conflict → Claude** workflow (`.github/workflows/merge-conflict-claude.yml`) posts `@claude` automatically on push to `main` and on PR sync. You can still comment manually:
|
||||
|
||||
```bash
|
||||
gh pr comment $PR --repo $REPO --body "@claude this PR has merge conflicts with \`main\` (PR #<X> was merged, touching the same regions). Please rebase onto latest \`main\`, re-run \`pytest <paths>\`, and force-push."
|
||||
@@ -571,6 +571,17 @@ gh pr diff $PR --repo $REPO | less
|
||||
|
||||
### 15b. Merge
|
||||
|
||||
**Primary:** Trigger or wait for **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`):
|
||||
|
||||
```bash
|
||||
gh workflow run claude-merge-gate.yml --repo $REPO -f pr_number=$PR
|
||||
gh pr view $PR --repo $REPO --json state,mergedAt,labels
|
||||
```
|
||||
|
||||
The gate merges when `MERGE_GATE_VERDICT: APPROVE`, CI is green, and the PR lacks `no-auto-merge`. Do not merge while `claude-conflict-pending` or during active `@claude` review/fix.
|
||||
|
||||
**Manual fallback** (opt-out or gate unavailable):
|
||||
|
||||
```bash
|
||||
gh pr merge $PR --repo $REPO --squash --delete-branch
|
||||
# --squash preserves linear main; --delete-branch keeps the repo clean
|
||||
|
||||
@@ -755,6 +755,20 @@ Developer Push ─────────────────────
|
||||
|
||||
**Result**: `https://praison.ai/install.sh` is automatically updated **only when the source files change**!
|
||||
|
||||
### Auto-deploy when install scripts change (PraisonAI → praison.ai)
|
||||
|
||||
Pushing `install.sh` or `install.ps1` to PraisonAI **`main`** triggers [`.github/workflows/deploy-install-scripts.yml`](/Users/praison/praisonai-package/.github/workflows/deploy-install-scripts.yml), which dispatches the existing praison.ai **`CI`** workflow (`workflow_dispatch` on `master`). No manual website push or `make upgrade` is required.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
pushMain["PraisonAI push main\ninstall.sh|install.ps1"] --> deployWF["deploy-install-scripts.yml"]
|
||||
deployWF --> dispatch["gh workflow run CI"]
|
||||
dispatch --> siteCI["praison.ai azure-pipeline.yaml"]
|
||||
siteCI --> live["https://praison.ai/install.sh"]
|
||||
```
|
||||
|
||||
Manual re-deploy: `gh workflow run "Deploy install scripts to praison.ai" --repo MervinPraison/PraisonAI`
|
||||
|
||||
### How install.sh is Served
|
||||
|
||||
The `web/install.sh` file is copied into the Docker image at `/var/www/html/web/install.sh` and served by nginx:
|
||||
@@ -797,20 +811,20 @@ git push origin master
|
||||
|
||||
**Task 2: Update install.sh and deploy**
|
||||
```bash
|
||||
# 1. Update install.sh
|
||||
# 1. Update install.sh / install.ps1
|
||||
vim /Users/praison/praisonai-package/src/praisonai/scripts/install.sh
|
||||
|
||||
# 2. Test install.sh
|
||||
bash /Users/praison/praisonai-package/src/praisonai/scripts/install.sh --dry-run
|
||||
bash /Users/praison/praisonai-package/src/praisonai/scripts/test-install-smoke.sh
|
||||
|
||||
# 3. Commit and push
|
||||
git add /Users/praison/praisonai-package/src/praisonai/scripts/install.sh
|
||||
# 3. Commit and push to PraisonAI main — auto-triggers praison.ai deploy
|
||||
git add src/praisonai/scripts/install.sh src/praisonai/scripts/install.ps1
|
||||
git commit -m "fix: update install script"
|
||||
git push origin main
|
||||
# deploy-install-scripts.yml → praison.ai CI → https://praison.ai/install.sh
|
||||
|
||||
# 4. Deploy website (if needed)
|
||||
cd /Users/praison/Sites/localhost/praisonai
|
||||
make upgrade
|
||||
# Manual re-deploy (optional):
|
||||
gh workflow run "Deploy install scripts to praison.ai" --repo MervinPraison/PraisonAI
|
||||
```
|
||||
|
||||
**Task 3: Rollback deployment**
|
||||
|
||||
@@ -137,6 +137,15 @@ Final rule: conclude only when evidence shows missing = 0.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
PUBLISH WORKFLOW (reference)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
GitHub Actions (recommended):
|
||||
Manual: Actions → PyPI Release → Run workflow on main (bump defaults to patch)
|
||||
Nightly: `.github/workflows/nightly-release-gate.yml` runs at 00:00 UTC (midnight) when:
|
||||
- changes exist in src/praisonai or src/praisonai-agents since last v* tag
|
||||
- Core Tests succeeded on current main HEAD
|
||||
→ dispatches pypi-release.yml with bump=patch, source=nightly
|
||||
→ still requires pypi environment approval before publish
|
||||
Ad-hoc minor/major: manual PyPI Release only (explicit bump selection)
|
||||
|
||||
Step 1 — Publish praisonaiagents (Core SDK):
|
||||
cd /Users/praison/praisonai-package/src/praisonai-agents
|
||||
praisonai publish pypi
|
||||
@@ -161,4 +170,4 @@ Step 3 — Publish praisonai-tools (External, if needed):
|
||||
# Bump version in pyproject.toml, then:
|
||||
python3.13 -m build && uv run twine upload dist/*
|
||||
|
||||
PraisonAI PRs: NEVER merge automatically — user merges manually.
|
||||
PraisonAI PRs: default merge via **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`) when validation passes. Add label `no-auto-merge` to opt out. Manual `gh pr merge` only as fallback when the gate cannot run.
|
||||
@@ -187,7 +187,7 @@ gh pr create \
|
||||
|
||||
**Commit prefixes:** `fix:`, `feat:`, `security:`, `refactor:`, `docs:`, `test:`
|
||||
|
||||
> **Do NOT merge automatically** — user merges manually unless explicitly told otherwise.
|
||||
> **Merge:** Default is the **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`). Do not merge manually unless the PR has `no-auto-merge` or you are explicitly told to use `gh pr merge`.
|
||||
|
||||
### 5b. Verify PR state
|
||||
|
||||
|
||||
@@ -129,7 +129,9 @@ python -m pytest tests/ -q \
|
||||
|
||||
## Step 6 — Approve or Request Changes
|
||||
|
||||
**If the PR is good:**
|
||||
**Primary (automated):** The **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`) assesses readiness and merges when `MERGE_GATE_VERDICT: APPROVE`, CI is green, and the PR lacks `no-auto-merge`. It never posts `@claude` and does not use `gh pr review` (avoids re-triggering `claude.yml`).
|
||||
|
||||
**Manual fallback — if the PR is good and gate is skipped or opted out:**
|
||||
|
||||
```bash
|
||||
cd /Users/praison/praisonai-package && \
|
||||
@@ -147,6 +149,14 @@ gh pr review <NUMBER> --request-changes --body "Requesting changes: <specific fe
|
||||
|
||||
## Step 7 — Merge
|
||||
|
||||
**Primary:** Wait for merge gate or verify `mergedAt` after a gate run:
|
||||
|
||||
```bash
|
||||
cd /Users/praison/praisonai-package && gh pr view <NUMBER> --json state,mergedAt,labels
|
||||
```
|
||||
|
||||
**Manual fallback** (label `no-auto-merge` or gate disabled):
|
||||
|
||||
```bash
|
||||
cd /Users/praison/praisonai-package && \
|
||||
gh pr merge <NUMBER> --merge --subject "<PR title>"
|
||||
@@ -257,6 +267,21 @@ find $(python -c "import site; print(site.getsitepackages()[0])") -name "<suspic
|
||||
|
||||
---
|
||||
|
||||
## Fork PRs — contributor checklist and Claude push
|
||||
|
||||
For PRs from external forks (`head.repo != MervinPraison/PraisonAI`), ask the contributor:
|
||||
|
||||
1. **Allow edits from maintainers** on the PR (right sidebar).
|
||||
2. **Keep fork and branch** until the PR is merged or closed.
|
||||
|
||||
Optional: private fork → add maintainer as collaborator; fork with Actions → only enable “Allow edits and access to secrets by maintainers” if they accept the risk.
|
||||
|
||||
When `maintainerCanModify` is true, `claude.yml` pushes via `GH_TOKEN` to the fork remote and `merge-conflict-claude.yml` may auto-trigger `@claude` for `DIRTY` PRs (after FINAL architecture review).
|
||||
|
||||
Manual unblock: `gh pr checkout <N> && git fetch origin main && git rebase origin/main && git push --force-with-lease`
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference — Common Commands
|
||||
|
||||
| Action | Command |
|
||||
|
||||
@@ -50,7 +50,7 @@ The workflow uses the agent configuration at `.github/praisonai-reviewer.yaml` a
|
||||
- Copilot NEVER triggers before CodeRabbit/Qodo complete.
|
||||
- Claude NEVER triggers before Copilot completes.
|
||||
- Duplicate `@copilot` comments are prevented by checking existing comments.
|
||||
- PraisonAI PRs: NEVER merge automatically — user merges manually.
|
||||
- Default merge: **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`) after review chain + CI green. Opt out with label `no-auto-merge`. Manual merge only as fallback.
|
||||
|
||||
## Security & Credentials
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> --jq '{ghsa:
|
||||
3. For each advisory, validate the vulnerability in source code:
|
||||
- Read the file(s) mentioned in the advisory description
|
||||
- Confirm the vulnerability exists (or is already fixed)
|
||||
- Classify: `real` | `already-fixed` | `not-reproducible`
|
||||
- Classify using triage labels (see **Managing ongoing audits** below)
|
||||
- Reference the official repository for latest source: https://github.com/MervinPraison/PraisonAI
|
||||
|
||||
## PHASE 2 — VALIDATE & FIX
|
||||
@@ -99,9 +99,29 @@ pip index versions praisonaiagents | head -1
|
||||
pip index versions praisonai | head -1
|
||||
```
|
||||
|
||||
## PHASE 4 — UPDATE ADVISORIES (only after packages are on PyPI)
|
||||
13. Publish **praisonai-platform** (only when the advisory targets platform code):
|
||||
```bash
|
||||
cd /Users/praison/praisonai-package/src/praisonai-platform
|
||||
praisonai publish pypi
|
||||
# or: uv lock && uv build && uv publish --token $PYPI_TOKEN
|
||||
```
|
||||
Platform releases are **independent** of `praisonai` / `praisonaiagents` version numbers. A platform-only fix (e.g. `0.1.8`) does **not** require bumping the main SDK.
|
||||
|
||||
13. For each advisory, reopen to draft state and set patched versions + credits:
|
||||
14. Publish **praisonai (npm / TypeScript)** (only when the advisory targets `src/praisonai-ts`):
|
||||
```bash
|
||||
cd /Users/praison/praisonai-package/src/praisonai-ts
|
||||
npm version patch && npm publish
|
||||
```
|
||||
|
||||
15. Verify platform / npm when applicable:
|
||||
```bash
|
||||
pip index versions praisonai-platform | head -1
|
||||
npm view praisonai version
|
||||
```
|
||||
|
||||
## PHASE 4 — UPDATE ADVISORIES (only after packages are on PyPI/npm)
|
||||
|
||||
16. For each advisory, reopen to draft state and set patched versions + credits:
|
||||
```bash
|
||||
gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> \
|
||||
--method PATCH \
|
||||
@@ -119,34 +139,86 @@ gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> \
|
||||
}
|
||||
EOF
|
||||
```
|
||||
- `<PACKAGE_NAME>`: `praisonaiagents` for core SDK, `praisonai` for wrapper
|
||||
- `<PACKAGE_NAME>`: `praisonaiagents` (core SDK), `praisonai` (Python wrapper), `praisonai-platform` (platform layer), or `praisonai` (npm ecosystem for TypeScript)
|
||||
- `<LAST_VULNERABLE_VERSION>`: version before the fix
|
||||
- `<FIRST_PATCHED_VERSION>`: the version just published with the fix
|
||||
- Always include credits for the reporter
|
||||
|
||||
14. Publish each advisory:
|
||||
17. Publish each advisory:
|
||||
```bash
|
||||
gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> \
|
||||
--method PATCH --field state=published
|
||||
```
|
||||
|
||||
15. Request CVE for each published advisory:
|
||||
18. Request CVE for each published advisory:
|
||||
```bash
|
||||
gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID>/cve --method POST
|
||||
```
|
||||
GitHub reviews and assigns CVE IDs within 1-3 business days.
|
||||
|
||||
16. Verify final state:
|
||||
19. Verify final state:
|
||||
```bash
|
||||
gh api repos/MervinPraison/PraisonAI/security-advisories --jq '.[] | "\(.ghsa_id) \(.state) \(.cve_id // "pending") \(.severity) \([.credits[].login] | join(",") ) \(.summary | .[0:50])"'
|
||||
```
|
||||
|
||||
## MANAGING ONGOING AUDITS
|
||||
|
||||
Continuous security reports are **expected** for an agent framework. Treat them as a product signal, not a crisis. Use a consistent process so triage stays manageable.
|
||||
|
||||
### Triage labels
|
||||
|
||||
After validating each advisory in source code, assign **one** label:
|
||||
|
||||
| Label | Meaning | Action |
|
||||
|---|---|---|
|
||||
| `real` | Reproducible, valid security issue | Fix → publish → advisory |
|
||||
| `already-fixed` | Fixed in `main` but advisory still open | Set patched version to current release; publish advisory |
|
||||
| `duplicate` | Same root cause as another GHSA | Close or merge; link to primary advisory |
|
||||
| `not-reproducible` | Cannot reproduce with steps provided | Request more detail; hold in triage |
|
||||
| `theoretical` | Requires unlikely preconditions or no practical impact | Document reasoning; close as wontfix or low priority |
|
||||
| `documented-opt-out` | Behaviour is intentional with documented escape hatch | Close as wontfix; point to docs/env var |
|
||||
| `out-of-scope` | Wrong package, fork, or deployment the project does not ship | Close with explanation |
|
||||
|
||||
### Triage load
|
||||
|
||||
- Not every report is valid — filter with the labels above before writing code.
|
||||
- Batch related GHSAs (same file, same pattern) into one fix where possible.
|
||||
- Prefer regression tests per GHSA so fixes are not re-opened by the next audit round.
|
||||
|
||||
### Version churn and release boundaries
|
||||
|
||||
| Package | Version line | Notes |
|
||||
|---|---|---|
|
||||
| `praisonaiagents` + `praisonai` | Aligned patch (e.g. `1.6.62` / `4.6.62`) | Main SDK + wrapper; bump together via `bump_and_release.py` |
|
||||
| `praisonai-platform` | Independent semver (e.g. `0.1.8`) | Platform-only fixes; users need **not** upgrade the main SDK |
|
||||
| `praisonai` (npm) | Independent semver (e.g. `1.7.2`) | TypeScript SDK; publish from `src/praisonai-ts` |
|
||||
|
||||
Always set the advisory `patched_versions` to the **package that actually contains the fix**. Do not imply users must upgrade all packages when only one changed.
|
||||
|
||||
### Researcher incentives
|
||||
|
||||
- Some reports are bounty-driven or low-quality — still acknowledge and triage promptly.
|
||||
- Credit valid reporters in the advisory even for small fixes.
|
||||
- Close invalid reports with a clear label and short rationale (no exploit discussion in public issues).
|
||||
- Never fix “for the sake of it” — see Phase 2 validate checks.
|
||||
|
||||
### When to close as wontfix
|
||||
|
||||
Close (or publish advisory with no code change only if already mitigated) when:
|
||||
|
||||
- Documented opt-out exists and risk is accepted (e.g. localhost-only dev modes with explicit env vars).
|
||||
- Issue is theoretical with no realistic attack path in supported deployments.
|
||||
- Fix would remove existing features or materially bloat the SDK — escalate instead.
|
||||
- Duplicate of an existing published advisory.
|
||||
|
||||
## REFERENCE — Package-to-file mapping
|
||||
|
||||
| Package | Canonical Path | Key files |
|
||||
|---|---|---|
|
||||
| praisonaiagents | src/praisonai-agents/praisonaiagents/ | tools/python_tools.py, mcp/, memory/, agent/ |
|
||||
| praisonai | src/praisonai/praisonai/ | ui/sql_alchemy.py, capabilities/, mcp_server/, cli/ |
|
||||
| Package | Ecosystem | Canonical Path | Key files |
|
||||
|---|---|---|---|
|
||||
| praisonaiagents | pip | src/praisonai-agents/praisonaiagents/ | tools/, mcp/, memory/, agent/, sandbox/ |
|
||||
| praisonai | pip | src/praisonai/praisonai/ | cli/, capabilities/, mcp_server/, sandbox/, recipe/ |
|
||||
| praisonai-platform | pip | src/praisonai-platform/praisonai_platform/ | api/routes/, services/, auth |
|
||||
| praisonai | npm | src/praisonai-ts/src/ | agent/, mcp/, tools/, code-mode/ |
|
||||
|
||||
## RESOURCES & DOCUMENTATION
|
||||
|
||||
@@ -160,8 +232,10 @@ gh api repos/MervinPraison/PraisonAI/security-advisories --jq '.[] | "\(.ghsa_id
|
||||
- NEVER expose exploit details in public issues or PRs
|
||||
- ALWAYS credit the reporter in the advisory
|
||||
- ALWAYS set patched_versions before publishing advisory
|
||||
- ALWAYS publish to PyPI BEFORE publishing advisories (so Dependabot can point users to a fix) - to publish `praisonai publish pypi` from praisonai-agents folder, after that
|
||||
`cd /Users/praison/praisonai-package/src/praisonai && python scripts/bump_and_release.py 4.5.125 --agents 1.5.125 --wait`. 4.5.125 is praisonai version number and 1.5.125 is praisonaiagents version number. Last 2 numbers will be same 4/1.xx.xxx
|
||||
- ALWAYS publish to PyPI/npm BEFORE publishing advisories (so Dependabot can point users to a fix)
|
||||
- Python SDK: `praisonai publish pypi` from `src/praisonai-agents`, then `bump_and_release.py` for the wrapper
|
||||
- Platform-only: `praisonai publish pypi` from `src/praisonai-platform` — no SDK bump required
|
||||
- npm: `npm publish` from `src/praisonai-ts` when the TypeScript SDK is affected
|
||||
- ALWAYS request CVE AFTER publishing advisory
|
||||
- Use generic commit messages: "refactor: harden X" not "fix: SQL injection in Y"
|
||||
- Fix on main, push directly — no feature branches for security patches
|
||||
@@ -172,7 +246,6 @@ gh api repos/MervinPraison/PraisonAI/security-advisories --jq '.[] | "\(.ghsa_id
|
||||
|
||||
## REPORTING SECURITY ISSUES
|
||||
|
||||
If you discover a security vulnerability, please report it responsibly:
|
||||
1. Open a draft security advisory at https://github.com/MervinPraison/PraisonAI/security/advisories/new
|
||||
2. Include detailed reproduction steps and impact assessment
|
||||
3. Allow time for the maintainers to address the issue before public disclosure
|
||||
If you discover a security vulnerability, please report it responsibly. See [SECURITY.md](../../../../SECURITY.md) at the monorepo root for scope, supported packages, and the private advisory link.
|
||||
|
||||
Maintainers and agents: use [.cursor/skills/security-audit/SKILL.md](../../.cursor/skills/security-audit/SKILL.md) for Cursor skill entry point.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Documentation Parity Tracker (Python)
|
||||
|
||||
> **Categories:** 64 | **Documented:** 61 | **Parity:** 95.3%
|
||||
> **Categories:** 66 | **Documented:** 65 | **Parity:** 98.5%
|
||||
|
||||
This report compares **Python SDK feature categories** against **Python documentation** (docs/concepts, docs/features, etc.).
|
||||
|
||||
@@ -8,109 +8,86 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Feature Categories | 64 |
|
||||
| **Documented Categories** | **61** |
|
||||
| **Undocumented Categories** | **3** |
|
||||
| **Parity** | **95.3%** |
|
||||
| Feature Categories | 66 |
|
||||
| **Documented Categories** | **65** |
|
||||
| **Undocumented Categories** | **1** |
|
||||
| **Parity** | **98.5%** |
|
||||
|
||||
## Documented Categories
|
||||
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 21 | 34 | 9360 |
|
||||
| ✅ Agent | 21 | 42 | 11340 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2880 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 547 |
|
||||
| ✅ Approval | 1 | 3 | 1283 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 575 |
|
||||
| ✅ Approval | 1 | 3 | 1344 |
|
||||
| ✅ Audio | 2 | 10 | 649 |
|
||||
| ✅ Auto Generation | 4 | 6 | 1724 |
|
||||
| ✅ Auto Generation | 5 | 6 | 1637 |
|
||||
| ✅ Autonomy | 3 | 5 | 1919 |
|
||||
| ✅ Bots | 7 | 14 | 5228 |
|
||||
| ✅ Bots | 7 | 23 | 7714 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 98 | 24103 |
|
||||
| ✅ Chunking | 2 | 2 | 285 |
|
||||
| ✅ CLI | 5 | 108 | 26975 |
|
||||
| ✅ Chunking | 2 | 2 | 288 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 3 | 13 | 3551 |
|
||||
| ✅ Conditions | 1 | 3 | 1086 |
|
||||
| ✅ Configuration | 3 | 7 | 1812 |
|
||||
| ✅ Context Management | 16 | 31 | 9294 |
|
||||
| ✅ Database | 1 | 34 | 4339 |
|
||||
| ✅ 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 | 704 |
|
||||
| ✅ Execution | 3 | 3 | 1059 |
|
||||
| ✅ Files | 2 | 6 | 1100 |
|
||||
| ✅ Events | 1 | 2 | 764 |
|
||||
| ✅ Execution | 3 | 3 | 1099 |
|
||||
| ✅ Failover | 2 | 1 | 372 |
|
||||
| ✅ Files | 2 | 6 | 1632 |
|
||||
| ✅ Flow | 1 | 2 | 435 |
|
||||
| ✅ Gateway | 7 | 10 | 3924 |
|
||||
| ✅ Guardrails | 4 | 4 | 2008 |
|
||||
| ✅ Handoffs | 11 | 5 | 2155 |
|
||||
| ✅ Hooks | 2 | 6 | 1916 |
|
||||
| ✅ Gateway | 7 | 21 | 7477 |
|
||||
| ✅ Guardrails | 4 | 4 | 1619 |
|
||||
| ✅ Handoffs | 11 | 6 | 2499 |
|
||||
| ✅ Hooks | 2 | 8 | 3150 |
|
||||
| ✅ Image | 1 | 11 | 1096 |
|
||||
| ✅ Knowledge | 3 | 12 | 3466 |
|
||||
| ✅ LLM | 3 | 5 | 2251 |
|
||||
| ✅ Knowledge | 4 | 24 | 4525 |
|
||||
| ✅ LLM | 3 | 10 | 3674 |
|
||||
| ✅ Loops | 4 | 3 | 774 |
|
||||
| ✅ MCP | 2 | 51 | 10960 |
|
||||
| ✅ Memory | 6 | 16 | 5855 |
|
||||
| ✅ Observability | 2 | 23 | 2590 |
|
||||
| ✅ Output | 3 | 4 | 903 |
|
||||
| ✅ 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 | 1519 |
|
||||
| ✅ Planning | 6 | 5 | 1198 |
|
||||
| ✅ Plugins | 8 | 3 | 1502 |
|
||||
| ✅ Prompts | 2 | 7 | 1570 |
|
||||
| ✅ Providers | 2 | 51 | 5911 |
|
||||
| ✅ Prompts | 2 | 7 | 1569 |
|
||||
| ✅ Providers | 1 | 52 | 6214 |
|
||||
| ✅ Query | 1 | 2 | 616 |
|
||||
| ✅ RAG | 5 | 15 | 3190 |
|
||||
| ✅ RAG | 5 | 15 | 3193 |
|
||||
| ✅ Realtime | 2 | 5 | 584 |
|
||||
| ✅ Reflection | 3 | 2 | 469 |
|
||||
| ✅ Retrieval | 2 | 5 | 944 |
|
||||
| ✅ Reflection | 3 | 2 | 477 |
|
||||
| ✅ Retrieval | 2 | 5 | 945 |
|
||||
| ✅ Routing | 1 | 2 | 597 |
|
||||
| ✅ Sandbox | 5 | 6 | 2369 |
|
||||
| ✅ Security | 1 | 3 | 2518 |
|
||||
| ✅ Sessions | 4 | 9 | 3826 |
|
||||
| ✅ Skills | 5 | 9 | 3415 |
|
||||
| ✅ 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 | 113 | 23809 |
|
||||
| ✅ Tools | 12 | 118 | 25895 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Video | 2 | 6 | 510 |
|
||||
| ✅ Vision | 2 | 1 | 283 |
|
||||
| ✅ Web | 4 | 6 | 1297 |
|
||||
| ✅ Workflows | 4 | 11 | 4302 |
|
||||
|
||||
## Stub Documentation (Need Content)
|
||||
|
||||
These categories have documentation files but < 50 lines (stubs):
|
||||
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ⚠️ OCR | 2 | 1 | 49 |
|
||||
| ✅ Web | 3 | 7 | 1537 |
|
||||
| ✅ Workflows | 4 | 14 | 5893 |
|
||||
|
||||
## Undocumented Categories (Need Documentation)
|
||||
|
||||
| Category | Features |
|
||||
|----------|----------|
|
||||
| ❌ Failover | 2 |
|
||||
| ❌ Optimizer | 1 |
|
||||
|
||||
## Documentation Without Features
|
||||
|
||||
These docs exist but don't match any implemented feature category:
|
||||
|
||||
- ℹ️ AI SDK (2 docs, 527 lines)
|
||||
- ℹ️ Callbacks (1 docs, 1122 lines)
|
||||
- ℹ️ Chat (3 docs, 634 lines)
|
||||
- ℹ️ Deep Research (2 docs, 437 lines)
|
||||
- ℹ️ Deployment (29 docs, 6013 lines)
|
||||
- ℹ️ Jobs (2 docs, 961 lines)
|
||||
- ℹ️ Middleware (1 docs, 60 lines)
|
||||
- ℹ️ Process (1 docs, 545 lines)
|
||||
- ℹ️ Scheduler (2 docs, 1084 lines)
|
||||
- ℹ️ Storage (5 docs, 957 lines)
|
||||
- ℹ️ Streaming (4 docs, 1163 lines)
|
||||
- ℹ️ Token Management (4 docs, 1383 lines)
|
||||
- ℹ️ Vector Store (11 docs, 733 lines)
|
||||
| ❌ Vector Store | 1 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -178,6 +178,11 @@ _LAZY_IMPORTS = {
|
||||
'loop': ('praisonaiagents.workflows', 'loop'),
|
||||
'repeat': ('praisonaiagents.workflows', 'repeat'),
|
||||
'when': ('praisonaiagents.workflows', 'when'),
|
||||
'if_': ('praisonaiagents.workflows', 'if_'),
|
||||
'include': ('praisonaiagents.workflows', 'include'),
|
||||
'Include': ('praisonaiagents.workflows', 'Include'),
|
||||
'MAX_NESTING_DEPTH': ('praisonaiagents.workflows', 'MAX_NESTING_DEPTH'),
|
||||
'YAMLWorkflowParser': ('praisonaiagents.workflows', 'YAMLWorkflowParser'),
|
||||
|
||||
# Conditions (Protocol-driven condition evaluation)
|
||||
'ConditionProtocol': ('praisonaiagents.conditions.protocols', 'ConditionProtocol'),
|
||||
@@ -230,6 +235,12 @@ _LAZY_IMPORTS = {
|
||||
'CliBackendResult': ('praisonaiagents.cli_backend.protocols', 'CliBackendResult'),
|
||||
'CliBackendDelta': ('praisonaiagents.cli_backend.protocols', 'CliBackendDelta'),
|
||||
|
||||
# Runtime Selection (Auto harness selection and registry)
|
||||
'AgentRuntimeProtocol': ('praisonaiagents.runtime.protocols', 'AgentRuntimeProtocol'),
|
||||
'resolve_runtime': ('praisonaiagents.runtime.registry', 'resolve_runtime'),
|
||||
'register_runtime': ('praisonaiagents.runtime.registry', 'register_runtime'),
|
||||
'list_runtimes': ('praisonaiagents.runtime.registry', 'list_runtimes'),
|
||||
|
||||
# Agent classes
|
||||
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
|
||||
'RetryBackoffConfig': ('praisonaiagents.agent.retry_utils', 'RetryBackoffConfig'),
|
||||
@@ -302,6 +313,12 @@ _LAZY_IMPORTS = {
|
||||
# Knowledge
|
||||
'Knowledge': ('praisonaiagents.knowledge.knowledge', 'Knowledge'),
|
||||
'Chunking': ('praisonaiagents.knowledge.chunking', 'Chunking'),
|
||||
'KnowledgeStoreProtocol': ('praisonaiagents.knowledge.protocols', 'KnowledgeStoreProtocol'),
|
||||
'ScopeRequiredError': ('praisonaiagents.knowledge.protocols', 'ScopeRequiredError'),
|
||||
'BackendNotAvailableError': ('praisonaiagents.knowledge.protocols', 'BackendNotAvailableError'),
|
||||
'FileTracker': ('praisonaiagents.knowledge.indexing', 'FileTracker'),
|
||||
'IndexResult': ('praisonaiagents.knowledge.indexing', 'IndexResult'),
|
||||
'CorpusStats': ('praisonaiagents.knowledge.indexing', 'CorpusStats'),
|
||||
|
||||
# FastContext
|
||||
'FastContext': ('praisonaiagents.context.fast', 'FastContext'),
|
||||
@@ -325,9 +342,24 @@ _LAZY_IMPORTS = {
|
||||
'SkillProperties': ('praisonaiagents.skills', 'SkillProperties'),
|
||||
'SkillMetadata': ('praisonaiagents.skills', 'SkillMetadata'),
|
||||
'SkillLoader': ('praisonaiagents.skills', 'SkillLoader'),
|
||||
'validate': ('praisonaiagents.skills', 'validate'),
|
||||
'validate_metadata': ('praisonaiagents.skills', 'validate_metadata'),
|
||||
'discover_skills': ('praisonaiagents.skills', 'discover_skills'),
|
||||
'load_skill': ('praisonaiagents.skills', 'load_skill'),
|
||||
'EnforcementLevel': ('praisonaiagents.skills', 'EnforcementLevel'),
|
||||
'SkillState': ('praisonaiagents.skills', 'SkillState'),
|
||||
|
||||
# Memory
|
||||
'Memory': ('praisonaiagents.memory.memory', 'Memory'),
|
||||
'AutoMemory': ('praisonaiagents.memory', 'AutoMemory'),
|
||||
'ChromaMemory': ('praisonaiagents.memory.adapters.factories', 'create_chroma_memory_adapter'),
|
||||
'register_memory_adapter': ('praisonaiagents.memory.adapters', 'register_memory_adapter'),
|
||||
'register_memory_factory': ('praisonaiagents.memory.adapters', 'register_memory_factory'),
|
||||
'get_memory_adapter': ('praisonaiagents.memory.adapters', 'get_memory_adapter'),
|
||||
'list_memory_adapters': ('praisonaiagents.memory.adapters', 'list_memory_adapters'),
|
||||
'add_memory_adapter': ('praisonaiagents.memory.adapters.registry', 'add_memory_adapter'),
|
||||
'add_memory_factory': ('praisonaiagents.memory.adapters.registry', 'add_memory_factory'),
|
||||
'has_memory_adapter': ('praisonaiagents.memory.adapters', 'has_memory_adapter'),
|
||||
|
||||
# Planning
|
||||
'Plan': ('praisonaiagents.planning', 'Plan'),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Shared lazy-loaders for display utilities used across the Agent class and mixins.
|
||||
|
||||
These helpers defer importing rich and the heavy ``..main`` display module until
|
||||
first use, which keeps import time low for silent mode and avoids circular
|
||||
imports during package initialisation. Results are cached in module globals and
|
||||
protected by per-resource locks using double-checked locking, so each import is
|
||||
resolved at most once across threads. Independent resources use separate locks so
|
||||
that a slow first import of one does not stall initialisation of the others.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
# Lazy-loaded modules (populated on first use, each protected by its own lock)
|
||||
_console_lock = threading.Lock()
|
||||
_live_lock = threading.Lock()
|
||||
_main_module_lock = threading.Lock()
|
||||
_rich_console = None
|
||||
_rich_live = None
|
||||
_main_module = None
|
||||
|
||||
|
||||
def _get_console():
|
||||
"""Lazy load rich.console.Console (thread-safe)."""
|
||||
global _rich_console
|
||||
if _rich_console is None:
|
||||
with _console_lock:
|
||||
if _rich_console is None:
|
||||
from rich.console import Console
|
||||
_rich_console = Console
|
||||
return _rich_console
|
||||
|
||||
|
||||
def _get_live():
|
||||
"""Lazy load rich.live.Live (thread-safe)."""
|
||||
global _rich_live
|
||||
if _rich_live is None:
|
||||
with _live_lock:
|
||||
if _rich_live is None:
|
||||
from rich.live import Live
|
||||
_rich_live = Live
|
||||
return _rich_live
|
||||
|
||||
|
||||
def _get_display_functions():
|
||||
"""Lazy load display functions from main module (thread-safe)."""
|
||||
global _main_module
|
||||
if _main_module is None:
|
||||
with _main_module_lock:
|
||||
if _main_module is None:
|
||||
from ..main import (
|
||||
display_error,
|
||||
display_instruction,
|
||||
display_interaction,
|
||||
display_generating,
|
||||
display_self_reflection,
|
||||
ReflectionOutput,
|
||||
adisplay_instruction,
|
||||
execute_sync_callback
|
||||
)
|
||||
_main_module = {
|
||||
'display_error': display_error,
|
||||
'display_instruction': display_instruction,
|
||||
'display_interaction': display_interaction,
|
||||
'display_generating': display_generating,
|
||||
'display_self_reflection': display_self_reflection,
|
||||
'ReflectionOutput': ReflectionOutput,
|
||||
'adisplay_instruction': adisplay_instruction,
|
||||
'execute_sync_callback': execute_sync_callback,
|
||||
}
|
||||
return _main_module
|
||||
@@ -24,6 +24,7 @@ from .async_safety import AsyncSafeState
|
||||
from .unified_execution_mixin import UnifiedExecutionMixin
|
||||
from .sandbox_mixin import SandboxMixin
|
||||
from .message_steering import SteeringMixin
|
||||
from .skill_review import SkillReviewMixin
|
||||
|
||||
# Module-level logger for thread safety errors and debugging
|
||||
logger = get_logger(__name__)
|
||||
@@ -34,35 +35,15 @@ logger = get_logger(__name__)
|
||||
# This reduces import time from ~420ms to ~20ms for silent mode
|
||||
# ============================================================================
|
||||
|
||||
# Shared, cached, thread-safe display lazy-loaders (single source of truth)
|
||||
from ._lazy_display import _get_console, _get_live, _get_display_functions
|
||||
|
||||
# Lazy-loaded modules (populated on first use, protected by _lazy_import_lock)
|
||||
_lazy_import_lock = threading.Lock()
|
||||
_rich_console = None
|
||||
_rich_live = None
|
||||
_llm_module = None
|
||||
_main_module = None
|
||||
_hooks_module = None
|
||||
_stream_emitter_class = None
|
||||
|
||||
def _get_console():
|
||||
"""Lazy load rich.console.Console (thread-safe)."""
|
||||
global _rich_console
|
||||
if _rich_console is None:
|
||||
with _lazy_import_lock:
|
||||
if _rich_console is None:
|
||||
from rich.console import Console
|
||||
_rich_console = Console
|
||||
return _rich_console
|
||||
|
||||
def _get_live():
|
||||
"""Lazy load rich.live.Live (thread-safe)."""
|
||||
global _rich_live
|
||||
if _rich_live is None:
|
||||
with _lazy_import_lock:
|
||||
if _rich_live is None:
|
||||
from rich.live import Live
|
||||
_rich_live = Live
|
||||
return _rich_live
|
||||
|
||||
def _get_llm_functions():
|
||||
"""Lazy load LLM functions (thread-safe)."""
|
||||
global _llm_module
|
||||
@@ -76,34 +57,6 @@ def _get_llm_functions():
|
||||
}
|
||||
return _llm_module
|
||||
|
||||
def _get_display_functions():
|
||||
"""Lazy load display functions from main module (thread-safe)."""
|
||||
global _main_module
|
||||
if _main_module is None:
|
||||
with _lazy_import_lock:
|
||||
if _main_module is None:
|
||||
from ..main import (
|
||||
display_error,
|
||||
display_instruction,
|
||||
display_interaction,
|
||||
display_generating,
|
||||
display_self_reflection,
|
||||
ReflectionOutput,
|
||||
adisplay_instruction,
|
||||
execute_sync_callback
|
||||
)
|
||||
_main_module = {
|
||||
'display_error': display_error,
|
||||
'display_instruction': display_instruction,
|
||||
'display_interaction': display_interaction,
|
||||
'display_generating': display_generating,
|
||||
'display_self_reflection': display_self_reflection,
|
||||
'ReflectionOutput': ReflectionOutput,
|
||||
'adisplay_instruction': adisplay_instruction,
|
||||
'execute_sync_callback': execute_sync_callback,
|
||||
}
|
||||
return _main_module
|
||||
|
||||
def _get_hooks_module():
|
||||
"""Lazy load hooks module for HookRunner and HookRegistry (thread-safe)."""
|
||||
global _hooks_module
|
||||
@@ -255,6 +208,7 @@ if TYPE_CHECKING:
|
||||
from ..rag.models import RAGResult, ContextPack
|
||||
from ..eval.results import EvaluationLoopResult
|
||||
from ..tools.retry import RetryPolicy
|
||||
from ..skills.protocols import SkillReviewProtocol
|
||||
|
||||
# Import structured error from central errors module
|
||||
from ..errors import BudgetExceededError
|
||||
@@ -262,7 +216,7 @@ from ..errors import BudgetExceededError
|
||||
# Import retry configuration
|
||||
from .retry_utils import RetryBackoffConfig
|
||||
|
||||
class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
|
||||
class Agent(SteeringMixin, SandboxMixin, SkillReviewMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
|
||||
# Class-level counter for generating unique display names for nameless agents
|
||||
_agent_counter = 0
|
||||
_agent_counter_lock = threading.Lock()
|
||||
@@ -588,12 +542,13 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
caching: Optional[Union[bool, str, Dict[str, Any], 'CachingConfig']] = None,
|
||||
hooks: Optional[Union[List[Any], Dict[str, Any], 'HooksConfig']] = None,
|
||||
skills: Optional[Union[List[str], str, Dict[str, Any], 'SkillsConfig']] = None,
|
||||
self_improve: Optional[Union[bool, 'SkillReviewProtocol']] = False, # Autonomous skill self-improvement loop (off by default)
|
||||
approval: Optional[Union[bool, str, Dict[str, Any], 'ApprovalConfig', 'ApprovalProtocol']] = None,
|
||||
tool_config: Optional[Union[bool, 'ToolConfig']] = None, # Tool execution configuration (timeout, retry, parallel)
|
||||
learn: Optional[Union[bool, str, Dict[str, Any], 'LearnConfig']] = None, # Continuous learning (peer to memory)
|
||||
backend: Optional[Any] = None, # External managed agent backend (e.g., ManagedAgentIntegration)
|
||||
cli_backend: Optional[Union[str, Any]] = None, # CLI backend for delegating turns (e.g., "claude-code") - DEPRECATED
|
||||
runtime: Optional[Union[str, Dict[str, Any], 'AgentRuntimeConfig']] = None, # Model-scoped runtime configuration
|
||||
runtime: Optional[Union[bool, str, Dict[str, Any], 'AgentRuntimeConfig', 'RuntimeConfig']] = None, # Model-scoped runtime configuration with capability validation
|
||||
interrupt_controller: Optional['InterruptController'] = None, # G2: Cooperative cancellation
|
||||
tool_search: Optional[Union[bool, str, Dict[str, Any], 'ToolSearchConfig']] = False, # Progressive tool disclosure
|
||||
message_steering: Optional[Union[bool, 'MessageSteeringProtocol']] = False, # Real-time message steering during execution
|
||||
@@ -635,6 +590,14 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
reflection: Self-reflection. Accepts:
|
||||
- bool: True enables with defaults
|
||||
- ReflectionConfig: Custom configuration
|
||||
self_improve: Autonomous skill self-improvement loop (off by default).
|
||||
After each task, runs a guarded review pass restricted to the
|
||||
``skill_manage`` tool that asks the agent to capture a reusable
|
||||
technique as a new/patched skill. Accepts:
|
||||
- bool: True enables with the default review policy, False disables
|
||||
- SkillReviewProtocol: Custom review policy
|
||||
This is distinct from ``reflection`` (which is answer-quality
|
||||
retry) — it captures durable capability across runs.
|
||||
guardrails: Output validation. Accepts:
|
||||
- bool: True enables with defaults
|
||||
- Callable: Validation function
|
||||
@@ -699,12 +662,15 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
cli_backend: **DEPRECATED** CLI backend for delegating full turns. Use 'runtime' parameter instead.
|
||||
This parameter is deprecated in favor of model-scoped runtime configuration.
|
||||
Will emit deprecation warning when used.
|
||||
runtime: Model-scoped runtime configuration for turn delegation. Accepts:
|
||||
- str: Runtime ID ("claude-code", "praisonai")
|
||||
- Dict[str, Any]: Runtime config {"runtime": "claude-code", "config_overrides": {...}}
|
||||
- AgentRuntimeConfig: Pre-configured runtime instance
|
||||
runtime: Model-scoped runtime configuration with capability validation. Accepts:
|
||||
- bool: False=disabled, True=basic capability validation
|
||||
- str: Runtime ID ("claude-code", "praisonai", "native", "plugin-harness")
|
||||
- Dict[str, Any]: Runtime config with required_capabilities, preferred_runtime, config_overrides, etc.
|
||||
- AgentRuntimeConfig: Pre-configured runtime instance (model-scoped)
|
||||
- RuntimeConfig: Configuration with capability requirements and preferences
|
||||
- None: Use model-based resolution (default)
|
||||
Enables turn delegation with per-model runtime selection and fail-closed behavior.
|
||||
Combines turn delegation with per-model runtime selection and fail-fast capability
|
||||
validation at config time to catch incompatibilities early.
|
||||
tool_search: Progressive tool disclosure configuration. Accepts:
|
||||
- bool: False=disabled (default), True=auto mode
|
||||
- str: Mode ("auto", "on", "off")
|
||||
@@ -1514,6 +1480,9 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
"a dict of ToolSearchConfig fields, or ToolSearchConfig"
|
||||
)
|
||||
|
||||
# Process tool_config and artifact storage (moved from tool_output)
|
||||
self._artifact_store = None
|
||||
|
||||
# ============================================================
|
||||
# END CONSOLIDATED PARAMS EXTRACTION
|
||||
# ============================================================
|
||||
@@ -1562,6 +1531,14 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
tool_config=tool_config
|
||||
)
|
||||
|
||||
# Set up artifact store if enabled in tool_config
|
||||
if _tool_config and _tool_config.enable_artifacts:
|
||||
from ..context.artifact_store import FileSystemArtifactStore
|
||||
self._artifact_store = _tool_config.artifact_store or FileSystemArtifactStore(
|
||||
retention_days=_tool_config.artifact_retention_days,
|
||||
redact_secrets=_tool_config.redact_secrets
|
||||
)
|
||||
|
||||
# Gap 2: Store parallel tool calls setting for ToolCallExecutor selection
|
||||
self.parallel_tool_calls = _tool_config.parallel if _tool_config else parallel_tool_calls
|
||||
# G2: Store interrupt controller for cooperative cancellation
|
||||
@@ -1785,7 +1762,8 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
self._init_message_steering(message_steering)
|
||||
self.verbose = verbose
|
||||
self._has_explicit_output_config = _has_explicit_output # Track if user set output mode
|
||||
self.tool_output_limit = tool_output_limit # Configurable tool output limit
|
||||
# Use tool_config output_limit if configured, otherwise use parameter value
|
||||
self.tool_output_limit = _tool_config.output_limit if _tool_config else tool_output_limit
|
||||
self.allow_delegation = allow_delegation
|
||||
self.step_callback = step_callback
|
||||
# Token budget guard (zero overhead when _max_budget is None)
|
||||
@@ -2048,6 +2026,18 @@ Your Goal: {self.goal}
|
||||
self._skill_manager = None # Lazy loaded
|
||||
self._skills_initialized = False
|
||||
|
||||
# Autonomous skill self-improvement loop (issue #2231). Off by default;
|
||||
# when enabled, a guarded review pass restricted to skill_manage runs
|
||||
# after each task. A SkillReviewProtocol instance customises the policy.
|
||||
if self_improve is True or self_improve is False or self_improve is None:
|
||||
self._self_improve = bool(self_improve)
|
||||
self._self_improve_policy = None
|
||||
else:
|
||||
# Custom policy object implementing SkillReviewProtocol.
|
||||
self._self_improve = True
|
||||
self._self_improve_policy = self_improve
|
||||
self._in_skill_review = False
|
||||
|
||||
# Database persistence (lazy - no imports until used)
|
||||
self._db = db
|
||||
self._session_id = session_id
|
||||
@@ -2109,10 +2099,13 @@ Your Goal: {self.goal}
|
||||
)
|
||||
self._cli_backend = self._resolve_cli_backend(cli_backend)
|
||||
|
||||
# Runtime Configuration - model-scoped runtime selection
|
||||
# Runtime Configuration - model-scoped runtime selection with capability validation
|
||||
self._runtime_config = None
|
||||
if runtime is not None:
|
||||
self._runtime_config = self._resolve_runtime_config(runtime)
|
||||
# Perform capability validation if enabled
|
||||
if self._runtime_config and hasattr(self._runtime_config, 'validate_on_creation') and self._runtime_config.validate_on_creation:
|
||||
self._validate_runtime_capabilities()
|
||||
|
||||
# Runtime resolver for per-turn resolution
|
||||
self._runtime_resolver = None # Will be initialized on first use
|
||||
@@ -2212,8 +2205,13 @@ Your Goal: {self.goal}
|
||||
'skills': getattr(self, '_skills_config', None),
|
||||
'approval': getattr(self, '_approval_config', None),
|
||||
'learn': getattr(self, '_learn_config', None),
|
||||
# Autonomous skill self-improvement: forward the custom policy when
|
||||
# set, else the opt-in boolean, so clones keep the same behavior.
|
||||
'self_improve': (
|
||||
getattr(self, '_self_improve_policy', None)
|
||||
or getattr(self, '_self_improve', False)
|
||||
),
|
||||
'tool_search': getattr(self, '_tool_search_config', None),
|
||||
|
||||
# Tool configuration - use consolidated config when available
|
||||
'tool_config': getattr(self, '_tool_config', None),
|
||||
|
||||
@@ -4065,35 +4063,79 @@ Summary:"""
|
||||
)
|
||||
return await handoff_obj.execute_async(self, prompt, context)
|
||||
|
||||
def _create_subagent(
|
||||
self,
|
||||
profile: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
) -> 'Agent':
|
||||
"""Create a subagent with the specified profile.
|
||||
# -------------------------------------------------------------------------
|
||||
# Runtime Capability Management
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _resolve_runtime_config(self, runtime):
|
||||
"""Resolve runtime configuration parameter.
|
||||
|
||||
Args:
|
||||
profile: Agent profile name
|
||||
context: Additional context
|
||||
runtime: Runtime parameter (bool, str, dict, or RuntimeConfig)
|
||||
|
||||
Returns:
|
||||
Configured Agent instance
|
||||
RuntimeConfig instance or None
|
||||
"""
|
||||
from ..agents.profiles import get_profile, BUILTIN_PROFILES
|
||||
|
||||
# Get profile config
|
||||
profile_config = get_profile(profile) if profile in BUILTIN_PROFILES else None
|
||||
|
||||
if profile_config:
|
||||
return Agent(
|
||||
name=f"subagent_{profile}",
|
||||
instructions=profile_config.system_prompt,
|
||||
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."
|
||||
)
|
||||
else:
|
||||
# Default subagent
|
||||
return Agent(
|
||||
name=f"subagent_{profile}",
|
||||
instructions=f"You are a {profile} assistant.",
|
||||
|
||||
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]]:
|
||||
@@ -4151,6 +4193,129 @@ Summary:"""
|
||||
|
||||
return filtered_tools
|
||||
|
||||
def add_mcp_server(self, name: str, mcp: Any) -> Any:
|
||||
"""
|
||||
Attach an MCP server to a live agent at runtime.
|
||||
|
||||
Connects the MCP server (discovering its tools), tracks it by name, and
|
||||
appends it to ``self.tools`` so its tools become available on the next
|
||||
turn of the same run — no agent rebuild required. New MCP tools flow
|
||||
through the existing tool-execution and approval paths automatically.
|
||||
|
||||
Args:
|
||||
name: Unique name for this MCP server (used by ``remove_mcp_server``).
|
||||
mcp: An ``MCP`` instance (e.g. ``MCP("npx -y @notionhq/notion-mcp-server")``).
|
||||
|
||||
Returns:
|
||||
The attached ``MCP`` instance.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from praisonaiagents.mcp import MCP
|
||||
agent.add_mcp_server("notion", MCP("npx -y @notionhq/notion-mcp-server"))
|
||||
# tools live on the next turn
|
||||
```
|
||||
"""
|
||||
if not name:
|
||||
raise ValueError("add_mcp_server requires a non-empty name")
|
||||
|
||||
if not hasattr(self, "_mcp_servers"):
|
||||
self._mcp_servers = {}
|
||||
|
||||
if name in self._mcp_servers:
|
||||
raise ValueError(
|
||||
f"MCP server '{name}' is already attached. "
|
||||
"Call remove_mcp_server() first to replace it."
|
||||
)
|
||||
|
||||
# Ensure self.tools is a mutable list we can append to
|
||||
if not isinstance(self.tools, list):
|
||||
self.tools = list(self.tools) if self.tools else []
|
||||
|
||||
self._mcp_servers[name] = mcp
|
||||
self.tools.append(mcp)
|
||||
self.refresh_tools()
|
||||
logging.debug(f"Attached MCP server '{name}' with runtime tools")
|
||||
return mcp
|
||||
|
||||
def remove_mcp_server(self, name: str) -> bool:
|
||||
"""
|
||||
Disconnect and deregister a previously attached MCP server.
|
||||
|
||||
Removes the server's tools from ``self.tools`` and shuts the connection
|
||||
down cleanly (no leaked background loops). Tools disappear on the next
|
||||
turn.
|
||||
|
||||
Args:
|
||||
name: The name used in ``add_mcp_server``.
|
||||
|
||||
Returns:
|
||||
True if a server was removed, False if no server by that name existed.
|
||||
"""
|
||||
servers = getattr(self, "_mcp_servers", None)
|
||||
if not servers or name not in servers:
|
||||
return False
|
||||
|
||||
mcp = servers.pop(name)
|
||||
|
||||
if isinstance(self.tools, list):
|
||||
self.tools = [t for t in self.tools if t is not mcp]
|
||||
|
||||
# Best-effort clean shutdown of the MCP connection
|
||||
try:
|
||||
if hasattr(mcp, "shutdown"):
|
||||
mcp.shutdown()
|
||||
except Exception as e:
|
||||
logging.warning(f"MCP server '{name}' shutdown failed: {e}")
|
||||
|
||||
self.refresh_tools()
|
||||
logging.debug(f"Removed MCP server '{name}'")
|
||||
return True
|
||||
|
||||
def refresh_tools(self) -> List[Any]:
|
||||
"""
|
||||
Re-derive the live toolset from ``self.tools``.
|
||||
|
||||
Per-turn tool assembly already reads from ``self.tools`` directly, so
|
||||
tools added/removed via ``add_mcp_server``/``remove_mcp_server`` are
|
||||
picked up automatically on the next turn. This method clears any cached
|
||||
tool definitions so callers (or an MCP ``tools/list_changed`` event) can
|
||||
force an immediate refresh, and returns the current toolset.
|
||||
|
||||
Returns:
|
||||
The current list of tools/MCP instances on the agent.
|
||||
"""
|
||||
# Invalidate any cached tool definitions if such a cache exists
|
||||
for cache_attr in ("_cached_tool_definitions", "_tool_definitions_cache"):
|
||||
if hasattr(self, cache_attr):
|
||||
try:
|
||||
setattr(self, cache_attr, None)
|
||||
except Exception:
|
||||
pass
|
||||
return self.tools
|
||||
|
||||
def list_mcp_servers(self) -> List[str]:
|
||||
"""Return the names of MCP servers currently attached at runtime."""
|
||||
return list(getattr(self, "_mcp_servers", {}).keys())
|
||||
|
||||
def _shutdown_runtime_mcp_servers(self) -> None:
|
||||
"""Shut down all runtime-attached MCP servers, guarding each individually.
|
||||
|
||||
Used by both ``close()`` and ``aclose()`` so a single failing
|
||||
``shutdown()`` never aborts cleanup of the remaining servers. The
|
||||
registry is always cleared afterwards.
|
||||
"""
|
||||
servers = getattr(self, "_mcp_servers", None)
|
||||
if not servers:
|
||||
return
|
||||
for name, server in list(servers.items()):
|
||||
try:
|
||||
if hasattr(server, "shutdown"):
|
||||
server.shutdown()
|
||||
except Exception as e:
|
||||
logger.warning(f"Runtime MCP server '{name}' cleanup failed: {e}")
|
||||
servers.clear()
|
||||
|
||||
def _model_supports_web_search(self) -> bool:
|
||||
"""
|
||||
Check if the agent's model supports native web search via LiteLLM.
|
||||
@@ -5196,39 +5361,122 @@ Answer:"""
|
||||
raise TypeError(f"Invalid cli_backend type: {type(cli_backend)}. Expected CliBackendProtocol instance or factory callable.")
|
||||
|
||||
def _resolve_runtime_config(self, runtime):
|
||||
"""Resolve runtime parameter to AgentRuntimeConfig instance.
|
||||
"""Resolve runtime parameter to AgentRuntimeConfig or RuntimeConfig instance.
|
||||
|
||||
Args:
|
||||
runtime: Runtime configuration parameter
|
||||
|
||||
Returns:
|
||||
AgentRuntimeConfig instance
|
||||
AgentRuntimeConfig or RuntimeConfig instance
|
||||
|
||||
Raises:
|
||||
TypeError: If runtime type is invalid
|
||||
ValueError: If runtime configuration is invalid
|
||||
"""
|
||||
# Try to import AgentRuntimeConfig (from main's turn-based runtime)
|
||||
AgentRuntimeConfig = None
|
||||
try:
|
||||
from ..runtime import AgentRuntimeConfig
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Try to import RuntimeConfig (from capability validation system)
|
||||
RuntimeConfig = None
|
||||
try:
|
||||
from ..config.feature_configs import RuntimeConfig, resolve_runtime
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if not AgentRuntimeConfig and not RuntimeConfig:
|
||||
raise ImportError(
|
||||
"Runtime configuration features requested but not available. "
|
||||
"This should not happen in a properly installed praisonaiagents package."
|
||||
)
|
||||
|
||||
# If already an AgentRuntimeConfig instance, return as-is
|
||||
if isinstance(runtime, AgentRuntimeConfig):
|
||||
if AgentRuntimeConfig and isinstance(runtime, AgentRuntimeConfig):
|
||||
return runtime
|
||||
|
||||
# If string, create config with runtime ID
|
||||
if isinstance(runtime, str):
|
||||
return AgentRuntimeConfig.from_runtime_id(runtime)
|
||||
# If already a RuntimeConfig instance, return as-is
|
||||
if RuntimeConfig and hasattr(RuntimeConfig, '__name__') and isinstance(runtime, RuntimeConfig):
|
||||
return runtime
|
||||
|
||||
# If dictionary, create config from dict
|
||||
if isinstance(runtime, dict):
|
||||
return AgentRuntimeConfig.from_dict(runtime)
|
||||
# Handle capability validation style (bool, RuntimeConfig)
|
||||
if RuntimeConfig and resolve_runtime:
|
||||
try:
|
||||
result = resolve_runtime(runtime)
|
||||
if result is not None:
|
||||
return result
|
||||
except (TypeError, ValueError):
|
||||
pass # Try AgentRuntimeConfig path
|
||||
|
||||
raise TypeError(f"Invalid runtime type: {type(runtime)}. Expected str, dict, or AgentRuntimeConfig instance.")
|
||||
# Handle turn-based style (AgentRuntimeConfig)
|
||||
if AgentRuntimeConfig:
|
||||
# If string, create config with runtime ID
|
||||
if isinstance(runtime, str):
|
||||
if hasattr(AgentRuntimeConfig, 'from_runtime_id'):
|
||||
return AgentRuntimeConfig.from_runtime_id(runtime)
|
||||
|
||||
# If dictionary, create config from dict
|
||||
if isinstance(runtime, dict):
|
||||
if hasattr(AgentRuntimeConfig, 'from_dict'):
|
||||
return AgentRuntimeConfig.from_dict(runtime)
|
||||
|
||||
raise TypeError(f"Invalid runtime type: {type(runtime)}. Expected bool, str, dict, RuntimeConfig, or AgentRuntimeConfig instance.")
|
||||
|
||||
def _validate_runtime_capabilities(self):
|
||||
"""Validate runtime capabilities against requirements.
|
||||
|
||||
Raises:
|
||||
CapabilityValidationError: If runtime lacks required capabilities
|
||||
"""
|
||||
if not self._runtime_config:
|
||||
return
|
||||
|
||||
# Import capability validation components
|
||||
try:
|
||||
from ..runtime.capabilities import (
|
||||
RuntimeCapability,
|
||||
validate_capabilities,
|
||||
get_native_runtime_capabilities,
|
||||
get_reduced_harness_capabilities
|
||||
)
|
||||
except ImportError:
|
||||
# Capability validation not available, skip
|
||||
return
|
||||
|
||||
# Get required capabilities from config
|
||||
required_capabilities = getattr(self._runtime_config, 'required_capabilities', None)
|
||||
if not required_capabilities:
|
||||
return
|
||||
|
||||
# Convert string capabilities to enum
|
||||
required_set = set()
|
||||
for cap in required_capabilities:
|
||||
if isinstance(cap, str):
|
||||
try:
|
||||
required_set.add(RuntimeCapability[cap.upper()])
|
||||
except KeyError:
|
||||
raise ValueError(f"Unknown capability: {cap}")
|
||||
elif isinstance(cap, RuntimeCapability):
|
||||
required_set.add(cap)
|
||||
else:
|
||||
raise TypeError(f"Invalid capability type: {type(cap)}")
|
||||
|
||||
# Determine runtime name
|
||||
runtime_name = getattr(self._runtime_config, 'preferred_runtime', 'native')
|
||||
|
||||
# Get runtime capabilities based on runtime type
|
||||
if runtime_name == 'native':
|
||||
runtime_matrix = get_native_runtime_capabilities()
|
||||
elif runtime_name in ('plugin-harness', 'harness', 'plugin', 'reduced'):
|
||||
runtime_matrix = get_reduced_harness_capabilities()
|
||||
else:
|
||||
# Unknown runtime, use reduced capabilities as safe default
|
||||
runtime_matrix = get_reduced_harness_capabilities()
|
||||
|
||||
# Validate capabilities
|
||||
validate_capabilities(runtime_matrix, required_set, runtime_name)
|
||||
|
||||
def _get_runtime_resolver(self):
|
||||
"""Get or create runtime resolver instance (lazy initialization)."""
|
||||
@@ -5488,6 +5736,9 @@ Answer:"""
|
||||
except Exception as e:
|
||||
logger.warning(f"MCP cleanup failed: {e}")
|
||||
|
||||
# Runtime-attached MCP servers cleanup (each guarded individually)
|
||||
self._shutdown_runtime_mcp_servers()
|
||||
|
||||
# Server registry cleanup
|
||||
try:
|
||||
self._cleanup_server_registrations()
|
||||
@@ -5536,7 +5787,10 @@ Answer:"""
|
||||
elif hasattr(client, 'close'):
|
||||
client.close()
|
||||
self._mcp_clients.clear()
|
||||
|
||||
|
||||
# Runtime-attached MCP servers cleanup (each guarded individually)
|
||||
self._shutdown_runtime_mcp_servers()
|
||||
|
||||
# Clean up server registrations and tasks
|
||||
self._cleanup_server_registrations()
|
||||
|
||||
@@ -5612,6 +5866,18 @@ Answer:"""
|
||||
memory = getattr(self, "_memory_instance", None)
|
||||
if memory and hasattr(memory, 'close_connections'):
|
||||
memory.close_connections()
|
||||
|
||||
# Clean up old artifacts if artifact store is configured
|
||||
artifact_store = getattr(self, "_artifact_store", None)
|
||||
if artifact_store and hasattr(artifact_store, 'cleanup_old_artifacts'):
|
||||
try:
|
||||
artifact_store.cleanup_old_artifacts()
|
||||
except Exception as e:
|
||||
# Log the error for debugging but don't fail cleanup
|
||||
import logging
|
||||
logging.debug(
|
||||
f"Failed to cleanup artifacts for agent {self.name}: {e}"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - finalizers must not raise
|
||||
import contextlib
|
||||
with contextlib.suppress(Exception):
|
||||
|
||||
@@ -13,31 +13,8 @@ import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from ..errors import BudgetExceededError
|
||||
|
||||
# Fallback helpers to avoid circular imports
|
||||
def _get_console():
|
||||
from rich.console import Console
|
||||
return Console
|
||||
|
||||
def _get_live():
|
||||
from rich.live import Live
|
||||
return Live
|
||||
|
||||
def _get_display_functions():
|
||||
from ..main import (
|
||||
display_error, display_instruction, display_interaction,
|
||||
display_generating, display_self_reflection, ReflectionOutput,
|
||||
adisplay_instruction, execute_sync_callback
|
||||
)
|
||||
return {
|
||||
'display_error': display_error,
|
||||
'display_instruction': display_instruction,
|
||||
'display_interaction': display_interaction,
|
||||
'display_generating': display_generating,
|
||||
'display_self_reflection': display_self_reflection,
|
||||
'ReflectionOutput': ReflectionOutput,
|
||||
'adisplay_instruction': adisplay_instruction,
|
||||
'execute_sync_callback': execute_sync_callback,
|
||||
}
|
||||
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
|
||||
from ._lazy_display import _get_console, _get_live, _get_display_functions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -637,24 +614,6 @@ Your Goal: {self.goal}"""
|
||||
# Fallback to str(response) is still fine, but now it's visible
|
||||
return str(response)
|
||||
|
||||
def _process_stream_response(self, messages, temperature, start_time, formatted_tools=None, reasoning_steps=False):
|
||||
"""Internal helper for streaming response processing with real-time events."""
|
||||
if self._openai_client is None:
|
||||
return None
|
||||
|
||||
return self._openai_client.process_stream_response(
|
||||
messages=messages,
|
||||
model=self.llm,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
start_time=start_time,
|
||||
console=self.console,
|
||||
display_fn=_get_display_functions()['display_generating'] if self.verbose else None,
|
||||
reasoning_steps=reasoning_steps,
|
||||
stream_callback=self.stream_emitter.emit,
|
||||
emit_events=True
|
||||
)
|
||||
|
||||
def _get_compaction_policy(self):
|
||||
"""
|
||||
Get the active compaction policy for this agent.
|
||||
@@ -809,7 +768,7 @@ Your Goal: {self.goal}"""
|
||||
from ..context.policy import CompactionRoute
|
||||
return CompactionRoute.COMPACT_NEEDED, compacted_msgs
|
||||
|
||||
def _apply_tool_truncation(self, messages, compactor, policy):
|
||||
def _apply_tool_truncation(self, messages, compactor, policy, log_tag="tool-truncation"):
|
||||
"""Apply targeted tool output truncation."""
|
||||
from ..context.policy import CompactionRoute
|
||||
import logging
|
||||
@@ -835,7 +794,7 @@ Your Goal: {self.goal}"""
|
||||
new_tokens = compactor.count_total_tokens(truncated_msgs)
|
||||
|
||||
logging.info(
|
||||
f"[tool-truncation] {self.name}: {original_tokens}→{new_tokens} tokens "
|
||||
f"[{log_tag}] {self.name}: {original_tokens}→{new_tokens} tokens "
|
||||
f"(truncated large tool outputs)"
|
||||
)
|
||||
|
||||
@@ -929,36 +888,8 @@ Your Goal: {self.goal}"""
|
||||
return CompactionRoute.COMPACT_NEEDED, compacted_msgs
|
||||
|
||||
async def _apply_tool_truncation_async(self, messages, compactor, policy):
|
||||
"""Async version of _apply_tool_truncation."""
|
||||
from ..context.policy import CompactionRoute
|
||||
import logging
|
||||
|
||||
# Create a copy to avoid modifying original
|
||||
truncated_msgs = []
|
||||
|
||||
for msg in messages:
|
||||
if msg.get("role") == "tool" or msg.get("tool_call_id"):
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and len(content) > 1000:
|
||||
# Truncate large tool outputs
|
||||
truncated_msg = msg.copy()
|
||||
head = content[:300]
|
||||
tail = content[-200:] if len(content) > 500 else ""
|
||||
truncated_msg["content"] = f"{head}\n...[truncated {len(content):,} chars for context budget]...\n{tail}"
|
||||
truncated_msgs.append(truncated_msg)
|
||||
continue
|
||||
|
||||
truncated_msgs.append(msg)
|
||||
|
||||
original_tokens = compactor.count_total_tokens(messages)
|
||||
new_tokens = compactor.count_total_tokens(truncated_msgs)
|
||||
|
||||
logging.info(
|
||||
f"[tool-truncation-async] {self.name}: {original_tokens}→{new_tokens} tokens "
|
||||
f"(truncated large tool outputs)"
|
||||
)
|
||||
|
||||
return CompactionRoute.TRUNCATE_TOOLS, truncated_msgs
|
||||
"""Async wrapper around _apply_tool_truncation (no awaitable work involved)."""
|
||||
return self._apply_tool_truncation(messages, compactor, policy, log_tag="tool-truncation-async")
|
||||
|
||||
def _get_next_fallback_model(self, fallback_index):
|
||||
"""Get the next fallback model from the chain, if available.
|
||||
@@ -1017,6 +948,29 @@ Your Goal: {self.goal}"""
|
||||
# before_llm_input.messages; adopt that value for the actual LLM call.
|
||||
messages = before_llm_input.messages
|
||||
|
||||
# Pre-call budget guard (zero overhead when _max_budget is None).
|
||||
# Estimate this call's minimum cost from the known input size plus the
|
||||
# configured max_tokens output reservation and refuse to dispatch when
|
||||
# it would breach the ceiling. This turns max_budget into a genuine hard
|
||||
# cap instead of one that can be overshot by a whole LLM call.
|
||||
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
|
||||
)
|
||||
|
||||
logging.debug(f"{self.name} sending messages to LLM: {messages}")
|
||||
|
||||
# Emit LLM request trace event (zero overhead when not set)
|
||||
@@ -1695,151 +1649,6 @@ Your Goal: {self.goal}"""
|
||||
task_name, task_description, task_id, response_format, stream_callback, emit_events
|
||||
)
|
||||
|
||||
async def _finalize_unified_achat_response(
|
||||
self,
|
||||
response,
|
||||
original_prompt,
|
||||
temperature,
|
||||
tools,
|
||||
output_json,
|
||||
output_pydantic,
|
||||
reasoning_steps,
|
||||
stream,
|
||||
messages,
|
||||
chat_history_length,
|
||||
start_time,
|
||||
task_name=None,
|
||||
task_description=None,
|
||||
task_id=None,
|
||||
):
|
||||
"""Post-process unified achat completion (parity with sync _chat_impl)."""
|
||||
if not response:
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
if hasattr(response, "choices") and response.choices:
|
||||
msg = response.choices[0].message
|
||||
content = getattr(msg, "content", None)
|
||||
response_text = content.strip() if content else ""
|
||||
if (
|
||||
reasoning_steps
|
||||
and getattr(msg, "reasoning_content", None)
|
||||
):
|
||||
response_text = msg.reasoning_content.strip()
|
||||
else:
|
||||
extracted = self._extract_llm_response_content(response)
|
||||
response_text = (extracted or "").strip() if isinstance(extracted, str) else str(extracted or "")
|
||||
|
||||
if output_json or output_pydantic:
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
self._persist_message("assistant", response_text)
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(
|
||||
response_text, original_prompt, temperature, tools,
|
||||
task_name, task_description, task_id,
|
||||
)
|
||||
self._execute_callback_and_display(
|
||||
original_prompt, validated_response, time.time() - start_time,
|
||||
task_name, task_description, task_id,
|
||||
)
|
||||
return await self._atrigger_after_agent_hook(
|
||||
original_prompt, validated_response, start_time,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
if not self.self_reflect:
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
self._persist_message("assistant", response_text)
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(
|
||||
response_text, original_prompt, temperature, tools,
|
||||
task_name, task_description, task_id,
|
||||
)
|
||||
self._execute_callback_and_display(
|
||||
original_prompt, validated_response, time.time() - start_time,
|
||||
task_name, task_description, task_id,
|
||||
)
|
||||
return await self._atrigger_after_agent_hook(
|
||||
original_prompt, validated_response, start_time,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
# Self-reflection (legacy OpenAI path)
|
||||
reflection_count = 0
|
||||
while True:
|
||||
reflection_prompt = f"""
|
||||
Reflect on your previous response: '{response_text}'.
|
||||
{self.reflect_prompt if self.reflect_prompt else "Identify any flaws, improvements, or actions."}
|
||||
Provide a "satisfactory" status ('yes' or 'no').
|
||||
Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
"""
|
||||
reflection_messages = messages + [
|
||||
{"role": "assistant", "content": response_text},
|
||||
{"role": "user", "content": reflection_prompt},
|
||||
]
|
||||
try:
|
||||
if self._openai_client is None:
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
return await self._atrigger_after_agent_hook(
|
||||
original_prompt, response_text, start_time,
|
||||
)
|
||||
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
|
||||
model=self.reflect_llm if self.reflect_llm else self.llm,
|
||||
messages=reflection_messages,
|
||||
temperature=temperature,
|
||||
response_format=_get_display_functions()['ReflectionOutput'],
|
||||
)
|
||||
reflection_output = reflection_response.choices[0].message.parsed
|
||||
if reflection_output.satisfactory == "yes" and reflection_count >= self.min_reflect - 1:
|
||||
break
|
||||
if reflection_count >= self.max_reflect - 1:
|
||||
break
|
||||
regenerate_messages = reflection_messages + [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
f"Self Reflection: {reflection_output.reflection} "
|
||||
f"Satisfactory?: {reflection_output.satisfactory}"
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": "Now regenerate your response using the reflection you made"},
|
||||
]
|
||||
new_response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
messages=regenerate_messages,
|
||||
temperature=temperature,
|
||||
)
|
||||
response_text = new_response.choices[0].message.content
|
||||
reflection_count += 1
|
||||
except Exception as e:
|
||||
logging.error("Reflection parsing failed.", exc_info=True)
|
||||
reflection_count += 1
|
||||
if reflection_count >= self.max_reflect:
|
||||
break
|
||||
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(
|
||||
response_text, original_prompt, temperature, tools,
|
||||
task_name, task_description, task_id,
|
||||
)
|
||||
self._execute_callback_and_display(
|
||||
original_prompt, validated_response, time.time() - start_time,
|
||||
task_name, task_description, task_id,
|
||||
)
|
||||
return await self._atrigger_after_agent_hook(
|
||||
original_prompt, validated_response, start_time,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for OpenAI client: {e}")
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
def _execute_callback_and_display(self, prompt: str, response: str, generation_time: float, task_name=None, task_description=None, task_id=None):
|
||||
"""Helper method to execute callbacks and display interaction.
|
||||
|
||||
|
||||
@@ -252,67 +252,28 @@ Follow best practices and coding standards."""
|
||||
|
||||
def _execute_python(self, code: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Execute Python code."""
|
||||
import subprocess
|
||||
import time
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
from praisonaiagents.tools.python_tools import execute_code
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Write code to temp file
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
|
||||
f.write(code)
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# Execute in subprocess (basic sandboxing)
|
||||
env = os.environ.copy()
|
||||
env.update(self._code_config.environment)
|
||||
|
||||
result = subprocess.run(
|
||||
["python", temp_file],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self._code_config.timeout,
|
||||
cwd=self._code_config.working_directory,
|
||||
env=env
|
||||
)
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
stdout = result.stdout[:self._code_config.max_output_length]
|
||||
stderr = result.stderr[:self._code_config.max_output_length]
|
||||
|
||||
mode = "sandbox" if self._code_config.sandbox else "direct"
|
||||
result = execute_code(
|
||||
code,
|
||||
timeout=self._code_config.timeout,
|
||||
max_output_size=self._code_config.max_output_length,
|
||||
sandbox_mode=mode,
|
||||
)
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
if result.get("success"):
|
||||
self._log(f"Execution completed in {execution_time:.2f}s", "green")
|
||||
|
||||
return {
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"return_code": result.returncode,
|
||||
"execution_time": execution_time
|
||||
}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": f"Execution timed out after {self._code_config.timeout}s",
|
||||
"return_code": -1,
|
||||
"execution_time": self._code_config.timeout
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": str(e),
|
||||
"return_code": -1,
|
||||
"execution_time": time.time() - start_time
|
||||
}
|
||||
finally:
|
||||
# Clean up temp file
|
||||
try:
|
||||
os.unlink(temp_file)
|
||||
except Exception as e:
|
||||
# Temp file cleanup failed - not critical
|
||||
get_logger(__name__).debug(f"Failed to clean up temp file {temp_file}: {e}")
|
||||
|
||||
return {
|
||||
"stdout": (result.get("stdout") or "")[: self._code_config.max_output_length],
|
||||
"stderr": (result.get("stderr") or "")[: self._code_config.max_output_length],
|
||||
"return_code": 0 if result.get("success") else 1,
|
||||
"execution_time": execution_time,
|
||||
}
|
||||
|
||||
async def aexecute(self, code: str, language: str = "python", **kwargs) -> Dict[str, Any]:
|
||||
"""Execute code asynchronously.
|
||||
|
||||
@@ -15,31 +15,8 @@ import asyncio
|
||||
import threading
|
||||
|
||||
|
||||
# Fallback helpers to avoid circular imports
|
||||
def _get_console():
|
||||
from rich.console import Console
|
||||
return Console
|
||||
|
||||
def _get_live():
|
||||
from rich.live import Live
|
||||
return Live
|
||||
|
||||
def _get_display_functions():
|
||||
from ..main import (
|
||||
display_error, display_instruction, display_interaction,
|
||||
display_generating, display_self_reflection, ReflectionOutput,
|
||||
adisplay_instruction, execute_sync_callback
|
||||
)
|
||||
return {
|
||||
'display_error': display_error,
|
||||
'display_instruction': display_instruction,
|
||||
'display_interaction': display_interaction,
|
||||
'display_generating': display_generating,
|
||||
'display_self_reflection': display_self_reflection,
|
||||
'ReflectionOutput': ReflectionOutput,
|
||||
'adisplay_instruction': adisplay_instruction,
|
||||
'execute_sync_callback': execute_sync_callback,
|
||||
}
|
||||
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
|
||||
from ._lazy_display import _get_console, _get_live, _get_display_functions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -58,10 +35,6 @@ class ExecutionMixin:
|
||||
"""Synchronous sleep - safe for sync contexts only."""
|
||||
time.sleep(duration)
|
||||
|
||||
async def _safe_sleep_async(self, duration: float) -> None:
|
||||
"""Async sleep - use this in async contexts."""
|
||||
await asyncio.sleep(duration)
|
||||
|
||||
def generate_task(self) -> 'Task':
|
||||
"""Generate a Task object from the agent's instructions"""
|
||||
from ..task.task import Task
|
||||
@@ -941,6 +914,113 @@ Write the complete compiled report:"""
|
||||
self.verbose = original_verbose
|
||||
self.markdown = original_markdown
|
||||
|
||||
def learn_skill(self, request: str, **kwargs: Any) -> Union[str, Generator[str, None, None], None]:
|
||||
"""Author a grounded, reusable skill from sources you describe.
|
||||
|
||||
Point the agent at real source material — a directory of code, API docs,
|
||||
PDFs/manuals, configs, pasted notes, or "what we just did in this chat" —
|
||||
and it gathers them with the tools it already has (file read, fetch, PDF
|
||||
read) and authors **one grounded SKILL.md** via the ``skill_manage`` tool.
|
||||
|
||||
House-style rules keep self-written skills trustworthy: only flags, paths,
|
||||
and APIs that appear verbatim in the source are used (never invented), the
|
||||
SKILL.md is kept tight (~100-200 lines), and long material is moved to
|
||||
supporting files referenced by relative path. The result is a permanent,
|
||||
reusable skill, immediately invocable like any other skill.
|
||||
|
||||
This is the **skill authoring** path and is intentionally distinct from
|
||||
the memory ``learn=`` constructor param (conversation→memory extraction)
|
||||
and the automatic ``self_improve`` loop. It is a prompt + entry point on
|
||||
top of existing tools, not a new distillation engine: it ensures
|
||||
``skill_manage`` is available, then runs a single turn with :meth:`start`.
|
||||
|
||||
Args:
|
||||
request: Natural-language description of the sources to learn from and
|
||||
the skill to produce, e.g.
|
||||
``"Read ./my-repo and the docs in ./docs/*.pdf and make a
|
||||
'deploy-flow' skill"``.
|
||||
**kwargs: Additional arguments forwarded to :meth:`start`.
|
||||
|
||||
Returns:
|
||||
The agent's response (the authored skill summary), as returned by
|
||||
:meth:`start`.
|
||||
|
||||
Example::
|
||||
|
||||
agent = Agent()
|
||||
agent.learn_skill("Read ./my-repo and make a 'deploy-flow' skill")
|
||||
"""
|
||||
from praisonaiagents.skills import build_learn_prompt
|
||||
|
||||
self._ensure_skill_management_tools()
|
||||
return self.start(build_learn_prompt(request), **kwargs)
|
||||
|
||||
async def alearn_skill(self, request: str, **kwargs: Any) -> Union[str, Generator[str, None, None], None]:
|
||||
"""Async variant of :meth:`learn_skill` (parity with ``start``/``astart``).
|
||||
|
||||
Ensures ``skill_manage`` is available, then awaits a single turn via
|
||||
:meth:`astart` so async callers never block the event loop.
|
||||
|
||||
Args:
|
||||
request: See :meth:`learn_skill`.
|
||||
**kwargs: Additional arguments forwarded to :meth:`astart`.
|
||||
|
||||
Returns:
|
||||
The agent's response (the authored skill summary).
|
||||
"""
|
||||
from praisonaiagents.skills import build_learn_prompt
|
||||
|
||||
self._ensure_skill_management_tools()
|
||||
return await self.astart(build_learn_prompt(request), **kwargs)
|
||||
|
||||
def learn(self, request: str, **kwargs: Any) -> Union[str, Generator[str, None, None], None]:
|
||||
"""Backward-compatible alias for :meth:`learn_skill`.
|
||||
|
||||
Retained so existing ``agent.learn("...")`` calls keep working. Prefer
|
||||
:meth:`learn_skill` to avoid confusion with the memory ``learn=`` param.
|
||||
"""
|
||||
return self.learn_skill(request, **kwargs)
|
||||
|
||||
async def alearn(self, request: str, **kwargs: Any) -> Union[str, Generator[str, None, None], None]:
|
||||
"""Backward-compatible async alias for :meth:`alearn_skill`."""
|
||||
return await self.alearn_skill(request, **kwargs)
|
||||
|
||||
def _ensure_skill_management_tools(self) -> None:
|
||||
"""Ensure the agent has the ``skill_manage`` tool for authoring skills.
|
||||
|
||||
Adds the skill-management tool (and lightweight file readers) to
|
||||
``self.tools`` if they are not already present, so :meth:`learn` can
|
||||
author a skill without the caller wiring tools manually. Idempotent.
|
||||
"""
|
||||
if not hasattr(self, 'tools') or self.tools is None:
|
||||
self.tools = []
|
||||
|
||||
def _tool_id(tool: Any) -> str:
|
||||
# Mirror tool_execution.py: handle callables, dict/JSON specs, and
|
||||
# anything else via a stable string fallback so dedup never lets a
|
||||
# dict-form spec slip past the callable form (or vice versa).
|
||||
name = getattr(tool, '__name__', None)
|
||||
if isinstance(tool, dict):
|
||||
name = name or tool.get('name') or (tool.get('function') or {}).get('name')
|
||||
return name or str(tool)
|
||||
|
||||
existing_names = {_tool_id(t) for t in self.tools}
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools.skill_tools import (
|
||||
skill_manage,
|
||||
read_skill_file,
|
||||
list_skill_scripts,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug(f"Could not import skill tools for learn_skill(): {exc}")
|
||||
return
|
||||
|
||||
for tool in (skill_manage, read_skill_file, list_skill_scripts):
|
||||
if _tool_id(tool) not in existing_names:
|
||||
self.tools.append(tool)
|
||||
existing_names.add(_tool_id(tool))
|
||||
|
||||
def execute(self, task: Any, context: Optional[Any] = None) -> Optional[str]:
|
||||
"""Execute a task synchronously - backward compatibility method"""
|
||||
if hasattr(task, 'description'):
|
||||
|
||||
@@ -405,6 +405,59 @@ class Handoff:
|
||||
# In intersect mode, empty list means no tools allowed - this is the security boundary
|
||||
return effective_tools
|
||||
|
||||
@staticmethod
|
||||
def _extract_model_ref(agent: 'Agent') -> str:
|
||||
"""Extract a model reference string from an agent configuration."""
|
||||
llm = getattr(agent, 'llm', None) or getattr(agent, 'model', None)
|
||||
if isinstance(llm, str):
|
||||
return llm
|
||||
if llm is not None:
|
||||
for attr in ('model', 'model_name', 'model_ref'):
|
||||
value = getattr(llm, attr, None)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return 'gpt-4o-mini'
|
||||
|
||||
def _execute_with_runtime_resolution(
|
||||
self,
|
||||
source_agent: 'Agent',
|
||||
prompt: str,
|
||||
effective_tools: Optional[List[Any]],
|
||||
context: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Execute handoff through the target agent's full chat pipeline."""
|
||||
target_model_ref = self._extract_model_ref(self.agent)
|
||||
logger.debug(
|
||||
"Handoff to %s via agent.chat (model=%s, depth=%s)",
|
||||
getattr(self.agent, 'name', 'unknown'),
|
||||
target_model_ref,
|
||||
_get_handoff_depth(),
|
||||
)
|
||||
return self.agent.chat(prompt, tools=effective_tools)
|
||||
|
||||
async def _execute_with_runtime_resolution_async(
|
||||
self,
|
||||
source_agent: 'Agent',
|
||||
prompt: str,
|
||||
effective_tools: Optional[List[Any]],
|
||||
context: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Execute handoff through the target agent's full async chat pipeline."""
|
||||
target_model_ref = self._extract_model_ref(self.agent)
|
||||
logger.debug(
|
||||
"Async handoff to %s via agent chat (model=%s, depth=%s)",
|
||||
getattr(self.agent, 'name', 'unknown'),
|
||||
target_model_ref,
|
||||
_get_handoff_depth(),
|
||||
)
|
||||
async_chat = getattr(self.agent, 'achat', None)
|
||||
if callable(async_chat) and inspect.iscoroutinefunction(async_chat):
|
||||
return await async_chat(prompt, tools=effective_tools)
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, lambda: self.agent.chat(prompt, tools=effective_tools)
|
||||
)
|
||||
|
||||
def _check_safety(self, source_agent: 'Agent') -> None:
|
||||
"""
|
||||
Check safety constraints before handoff.
|
||||
@@ -569,8 +622,10 @@ class Handoff:
|
||||
# Compute effective tools based on tool policy
|
||||
effective_tools = self._compute_effective_tools(source_agent)
|
||||
|
||||
# Execute with tool boundary enforcement
|
||||
response = self.agent.chat(full_prompt, tools=effective_tools)
|
||||
# Resolve runtime at turn-time instead of using construction-time pin
|
||||
response = self._execute_with_runtime_resolution(
|
||||
source_agent, full_prompt, effective_tools, kwargs
|
||||
)
|
||||
|
||||
result = HandoffResult(
|
||||
success=True,
|
||||
@@ -675,13 +730,10 @@ class Handoff:
|
||||
# Compute effective tools based on tool policy
|
||||
effective_tools = self._compute_effective_tools(source_agent)
|
||||
|
||||
# Execute with tool boundary enforcement - check for async chat method
|
||||
if hasattr(self.agent, 'achat'):
|
||||
response = await self.agent.achat(full_prompt, tools=effective_tools)
|
||||
else:
|
||||
# Run sync chat in executor with tool constraint
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(None, lambda: self.agent.chat(full_prompt, tools=effective_tools))
|
||||
# Resolve runtime at turn-time for async execution
|
||||
response = await self._execute_with_runtime_resolution_async(
|
||||
source_agent, full_prompt, effective_tools, kwargs
|
||||
)
|
||||
|
||||
result = HandoffResult(
|
||||
success=True,
|
||||
@@ -817,7 +869,10 @@ class Handoff:
|
||||
# Compute effective tools based on tool policy
|
||||
effective_tools = self._compute_effective_tools(source_agent)
|
||||
|
||||
response = self.agent.chat(prompt, tools=effective_tools)
|
||||
# Resolve runtime at turn-time for tool function execution
|
||||
response = self._execute_with_runtime_resolution(
|
||||
source_agent, prompt, effective_tools, kwargs
|
||||
)
|
||||
|
||||
result = HandoffResult(
|
||||
success=True,
|
||||
|
||||
@@ -10,31 +10,8 @@ import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
# Fallback helpers to avoid circular imports
|
||||
def _get_console():
|
||||
from rich.console import Console
|
||||
return Console
|
||||
|
||||
def _get_live():
|
||||
from rich.live import Live
|
||||
return Live
|
||||
|
||||
def _get_display_functions():
|
||||
from ..main import (
|
||||
display_error, display_instruction, display_interaction,
|
||||
display_generating, display_self_reflection, ReflectionOutput,
|
||||
adisplay_instruction, execute_sync_callback
|
||||
)
|
||||
return {
|
||||
'display_error': display_error,
|
||||
'display_instruction': display_instruction,
|
||||
'display_interaction': display_interaction,
|
||||
'display_generating': display_generating,
|
||||
'display_self_reflection': display_self_reflection,
|
||||
'ReflectionOutput': ReflectionOutput,
|
||||
'adisplay_instruction': adisplay_instruction,
|
||||
'execute_sync_callback': execute_sync_callback,
|
||||
}
|
||||
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
|
||||
from ._lazy_display import _get_console, _get_live, _get_display_functions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -649,10 +626,18 @@ class MemoryMixin:
|
||||
|
||||
try:
|
||||
import os
|
||||
|
||||
# Expand user home directory and resolve path
|
||||
file_path = os.path.expanduser(self._output_file)
|
||||
file_path = os.path.abspath(file_path)
|
||||
from praisonaiagents.tools.path_safety import resolve_within_root
|
||||
|
||||
project_root = os.environ.get("PRAISONAI_PROJECT_ROOT") or os.getcwd()
|
||||
file_path = resolve_within_root(self._output_file, project_root)
|
||||
if file_path is None:
|
||||
logging.warning(
|
||||
"Output file %r is outside project root %r; skipping save",
|
||||
self._output_file,
|
||||
project_root,
|
||||
)
|
||||
print(f"⚠️ Output path outside project root: {self._output_file}")
|
||||
return False
|
||||
|
||||
# Create parent directories if they don't exist
|
||||
parent_dir = os.path.dirname(file_path)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Autonomous skill self-improvement loop.
|
||||
|
||||
Closes the loop on the existing skill subsystem: after a task finishes, an
|
||||
opt-in *guarded* review pass runs a short, isolated turn restricted to the
|
||||
``skill_manage`` tool and asks the agent whether the session revealed a
|
||||
reusable technique worth persisting as a skill.
|
||||
|
||||
Design guarantees (see issue #2231):
|
||||
- Off by default — only runs when ``self_improve`` is enabled.
|
||||
- Never runs with the full toolset — the review turn is restricted to the
|
||||
single skill-mutation tool.
|
||||
- Re-entrancy guarded — the review turn itself cannot trigger another review.
|
||||
- Best-effort — any failure is swallowed so it never breaks the main task.
|
||||
|
||||
This logic lives in its own mixin so ``agent.py`` stays lean.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SkillReviewMixin:
|
||||
"""Mixin providing the guarded skill-review pass for ``Agent``."""
|
||||
|
||||
def _skill_review_policy(self):
|
||||
"""Return the active review policy, building the default lazily."""
|
||||
policy = getattr(self, "_self_improve_policy", None)
|
||||
if policy is not None:
|
||||
return policy
|
||||
from ..skills import DefaultSkillReviewPolicy
|
||||
policy = DefaultSkillReviewPolicy()
|
||||
self._self_improve_policy = policy
|
||||
return policy
|
||||
|
||||
def _build_skill_review_tool(self):
|
||||
"""Return the restricted toolset for the review turn (skill_manage only)."""
|
||||
try:
|
||||
from ..tools.skill_tools import create_skill_tools
|
||||
skill_tools = create_skill_tools()
|
||||
return [skill_tools.skill_manage]
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.debug("Could not build skill review tool: %s", e, exc_info=True)
|
||||
return []
|
||||
|
||||
def _skill_review_log_context(self):
|
||||
"""Return (agent_name, session_id) for swallowed-failure warnings."""
|
||||
return (
|
||||
getattr(self, "name", None),
|
||||
getattr(self, "_session_id", None),
|
||||
)
|
||||
|
||||
def _prepare_skill_review(self, prompt, response, tools_used=None):
|
||||
"""Gate + build the review turn. Returns (review_prompt, review_tools) or None.
|
||||
|
||||
Shared by the sync and async paths so they stay in lock-step. Applies
|
||||
the opt-in switch, the re-entrancy guard, the policy decision, and the
|
||||
restricted-toolset build.
|
||||
"""
|
||||
# Opt-in only.
|
||||
if not getattr(self, "_self_improve", False):
|
||||
return None
|
||||
# Re-entrancy guard: never review a review.
|
||||
if getattr(self, "_in_skill_review", False):
|
||||
return None
|
||||
|
||||
trajectory = {
|
||||
"prompt": prompt if isinstance(prompt, str) else str(prompt),
|
||||
"response": response or "",
|
||||
"tools_used": list(tools_used or []),
|
||||
}
|
||||
|
||||
try:
|
||||
policy = self._skill_review_policy()
|
||||
if not policy.should_review(trajectory):
|
||||
return None
|
||||
review_prompt = policy.review_prompt(trajectory)
|
||||
except Exception as e:
|
||||
name, session_id = self._skill_review_log_context()
|
||||
logger.warning(
|
||||
"Skill review policy failed for agent=%s session_id=%s: %s",
|
||||
name, session_id, e, exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
review_tools = self._build_skill_review_tool()
|
||||
if not review_tools:
|
||||
logger.debug("Skill review skipped: skill_manage tool unavailable")
|
||||
return None
|
||||
|
||||
return review_prompt, review_tools
|
||||
|
||||
def _snapshot_chat_history_len(self):
|
||||
"""Return the current chat_history length, or None if unavailable."""
|
||||
history = getattr(self, "chat_history", None)
|
||||
return len(history) if isinstance(history, list) else None
|
||||
|
||||
def _restore_chat_history(self, snapshot_len):
|
||||
"""Trim chat_history back to ``snapshot_len`` so the review turn is isolated.
|
||||
|
||||
The review turn is internal: any user/assistant messages it appended to
|
||||
the shared, persistent chat_history must not leak into subsequent turns
|
||||
of a reused agent (chatbot, REPL, agentic loop).
|
||||
"""
|
||||
if snapshot_len is None:
|
||||
return
|
||||
history = getattr(self, "chat_history", None)
|
||||
if isinstance(history, list) and len(history) > snapshot_len:
|
||||
del history[snapshot_len:]
|
||||
|
||||
def _run_skill_review(self, prompt, response, tools_used=None):
|
||||
"""Run the guarded skill-review pass.
|
||||
|
||||
Args:
|
||||
prompt: The original user prompt for the finished task.
|
||||
response: The final response produced for the task.
|
||||
tools_used: Names of tools used during the task (if known).
|
||||
"""
|
||||
prepared = self._prepare_skill_review(prompt, response, tools_used)
|
||||
if prepared is None:
|
||||
return
|
||||
review_prompt, review_tools = prepared
|
||||
|
||||
self._in_skill_review = True
|
||||
history_len = self._snapshot_chat_history_len()
|
||||
try:
|
||||
if getattr(self, "verbose", False):
|
||||
logger.info("Agent %s running guarded skill-review pass", self.name)
|
||||
# Restricted turn: only skill_manage is exposed via the tools= arg.
|
||||
self.chat(review_prompt, tools=review_tools)
|
||||
except Exception as e:
|
||||
name, session_id = self._skill_review_log_context()
|
||||
logger.warning(
|
||||
"Skill review pass failed for agent=%s session_id=%s: %s",
|
||||
name, session_id, e, exc_info=True,
|
||||
)
|
||||
finally:
|
||||
self._restore_chat_history(history_len)
|
||||
self._in_skill_review = False
|
||||
|
||||
async def _arun_skill_review(self, prompt, response, tools_used=None):
|
||||
"""Async variant of :meth:`_run_skill_review`."""
|
||||
prepared = self._prepare_skill_review(prompt, response, tools_used)
|
||||
if prepared is None:
|
||||
return
|
||||
review_prompt, review_tools = prepared
|
||||
|
||||
self._in_skill_review = True
|
||||
history_len = self._snapshot_chat_history_len()
|
||||
try:
|
||||
if getattr(self, "verbose", False):
|
||||
logger.info("Agent %s running guarded skill-review pass", self.name)
|
||||
await self.achat(review_prompt, tools=review_tools)
|
||||
except Exception as e:
|
||||
name, session_id = self._skill_review_log_context()
|
||||
logger.warning(
|
||||
"Skill review pass failed for agent=%s session_id=%s: %s",
|
||||
name, session_id, e, exc_info=True,
|
||||
)
|
||||
finally:
|
||||
self._restore_chat_history(history_len)
|
||||
self._in_skill_review = False
|
||||
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
Integration tests for handoff runtime resolution.
|
||||
|
||||
This test suite validates that handoffs now use turn-time runtime resolution
|
||||
instead of construction-time pins, addressing issue #1938.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
from unittest.mock import Mock, patch, MagicMock, AsyncMock
|
||||
import asyncio
|
||||
|
||||
from .handoff import Handoff, HandoffConfig, ContextPolicy, HandoffResult
|
||||
|
||||
|
||||
class MockAgent:
|
||||
"""Mock agent for testing handoff scenarios."""
|
||||
|
||||
def __init__(self, name, model="gpt-4o", agent_id=None, session_id="test"):
|
||||
self.name = name
|
||||
self.llm = model
|
||||
self.model = model
|
||||
self.agent_id = agent_id or name
|
||||
self._session_id = session_id
|
||||
self.chat_history = []
|
||||
|
||||
def chat(self, prompt, **kwargs):
|
||||
"""Mock chat method that returns a simple response."""
|
||||
response = f"Response from {self.name} using {self.model}: {prompt[:50]}..."
|
||||
self.chat_history.append({"role": "user", "content": prompt})
|
||||
self.chat_history.append({"role": "assistant", "content": response})
|
||||
return response
|
||||
|
||||
async def achat(self, prompt, **kwargs):
|
||||
"""Mock async chat method."""
|
||||
return self.chat(prompt, **kwargs)
|
||||
|
||||
|
||||
class TestHandoffRuntimeResolution:
|
||||
"""Test handoff runtime resolution functionality."""
|
||||
|
||||
def test_handoff_uses_turn_time_resolution(self):
|
||||
"""Test that handoff uses turn-time runtime resolution instead of construction-time."""
|
||||
# Create agents with different models
|
||||
source_agent = MockAgent("SourceAgent", "gpt-3.5-turbo")
|
||||
target_agent = MockAgent("TargetAgent", "gpt-4o")
|
||||
|
||||
# Create handoff
|
||||
handoff = Handoff(
|
||||
agent=target_agent,
|
||||
config=HandoffConfig(context_policy=ContextPolicy.NONE)
|
||||
)
|
||||
|
||||
# Mock the runtime resolution to verify it's called
|
||||
with patch.object(handoff, '_execute_with_runtime_resolution') as mock_execute:
|
||||
mock_execute.return_value = "Resolved response"
|
||||
|
||||
# Execute programmatic handoff
|
||||
result = handoff.execute_programmatic(
|
||||
source_agent=source_agent,
|
||||
prompt="Test handoff",
|
||||
context={}
|
||||
)
|
||||
|
||||
# Verify runtime resolution was called instead of direct agent.chat
|
||||
mock_execute.assert_called_once()
|
||||
args = mock_execute.call_args[0]
|
||||
assert args[0] == source_agent # source_agent
|
||||
assert "Test handoff" in args[1] # full_prompt with context
|
||||
assert args[3] == {} # context kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_handoff_uses_turn_time_resolution(self):
|
||||
"""Test that async handoff uses turn-time runtime resolution."""
|
||||
source_agent = MockAgent("SourceAgent", "gpt-3.5-turbo")
|
||||
target_agent = MockAgent("TargetAgent", "claude-3-sonnet")
|
||||
|
||||
handoff = Handoff(
|
||||
agent=target_agent,
|
||||
config=HandoffConfig(context_policy=ContextPolicy.NONE)
|
||||
)
|
||||
|
||||
# Mock the async runtime resolution
|
||||
with patch.object(handoff, '_execute_with_runtime_resolution_async') as mock_execute_async:
|
||||
mock_execute_async.return_value = "Async resolved response"
|
||||
|
||||
# Execute async handoff
|
||||
result = await handoff.execute_async(
|
||||
source_agent=source_agent,
|
||||
prompt="Test async handoff",
|
||||
context={}
|
||||
)
|
||||
|
||||
# Verify async runtime resolution was called
|
||||
mock_execute_async.assert_called_once()
|
||||
args = mock_execute_async.call_args[0]
|
||||
assert args[0] == source_agent
|
||||
assert "Test async handoff" in args[1]
|
||||
|
||||
def test_handoff_tool_function_uses_runtime_resolution(self):
|
||||
"""Test that handoff tool function uses runtime resolution."""
|
||||
source_agent = MockAgent("SourceAgent", "gpt-4")
|
||||
source_agent.chat_history = [{"role": "user", "content": "Complete this task"}]
|
||||
target_agent = MockAgent("TargetAgent", "gpt-4o")
|
||||
|
||||
handoff = Handoff(
|
||||
agent=target_agent,
|
||||
config=HandoffConfig(context_policy=ContextPolicy.SUMMARY)
|
||||
)
|
||||
|
||||
# Get tool function
|
||||
tool_func = handoff.to_tool_function(source_agent)
|
||||
|
||||
# Mock the runtime resolution
|
||||
with patch.object(handoff, '_execute_with_runtime_resolution') as mock_execute:
|
||||
mock_execute.return_value = "Tool resolved response"
|
||||
|
||||
# Execute tool function
|
||||
result = tool_func(task="Complete this task")
|
||||
|
||||
# Verify runtime resolution was called
|
||||
mock_execute.assert_called_once()
|
||||
args = mock_execute.call_args[0]
|
||||
assert args[0] == source_agent
|
||||
|
||||
@patch.object(MockAgent, 'chat')
|
||||
def test_runtime_resolution_uses_agent_chat(self, mock_chat):
|
||||
"""Handoff must execute via agent.chat, not a bare LLM runtime."""
|
||||
mock_chat.return_value = "Agent chat response"
|
||||
source_agent = MockAgent("SourceAgent", "gpt-3.5-turbo")
|
||||
target_agent = MockAgent("TargetAgent", "gpt-4o")
|
||||
|
||||
handoff = Handoff(agent=target_agent)
|
||||
|
||||
response = handoff._execute_with_runtime_resolution(
|
||||
source_agent=source_agent,
|
||||
prompt="Test prompt",
|
||||
effective_tools=[],
|
||||
context={}
|
||||
)
|
||||
|
||||
mock_chat.assert_called_once_with("Test prompt", tools=[])
|
||||
assert response == "Agent chat response"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_runtime_resolution_uses_agent_achat(self):
|
||||
"""Async handoff must execute via agent.achat, not a bare LLM runtime."""
|
||||
source_agent = MockAgent("SourceAgent", "gpt-4")
|
||||
target_agent = MockAgent("TargetAgent", "claude-3-sonnet")
|
||||
|
||||
with patch.object(target_agent, 'achat', new_callable=AsyncMock) as mock_achat:
|
||||
mock_achat.return_value = "Async agent chat response"
|
||||
handoff = Handoff(agent=target_agent)
|
||||
|
||||
response = await handoff._execute_with_runtime_resolution_async(
|
||||
source_agent=source_agent,
|
||||
prompt="Test async prompt",
|
||||
effective_tools=[],
|
||||
context={}
|
||||
)
|
||||
|
||||
mock_achat.assert_called_once_with("Test async prompt", tools=[])
|
||||
assert response == "Async agent chat response"
|
||||
|
||||
def test_runtime_resolution_uses_agent_chat_even_when_resolve_available(self):
|
||||
"""Regression: bare resolve_runtime must not bypass agent instructions."""
|
||||
source_agent = MockAgent("SourceAgent", "gpt-4")
|
||||
target_agent = MockAgent("TargetAgent", "gpt-4o")
|
||||
|
||||
handoff = Handoff(agent=target_agent)
|
||||
|
||||
with patch('praisonaiagents.runtime.resolve.resolve_runtime') as mock_resolve:
|
||||
mock_runtime = Mock()
|
||||
mock_runtime.execute.return_value = "Bare runtime response"
|
||||
mock_resolve.return_value = mock_runtime
|
||||
|
||||
with patch.object(target_agent, 'chat', wraps=target_agent.chat) as mock_chat:
|
||||
response = handoff._execute_with_runtime_resolution(
|
||||
source_agent=source_agent,
|
||||
prompt="Test fallback",
|
||||
effective_tools=[],
|
||||
context={}
|
||||
)
|
||||
|
||||
mock_resolve.assert_not_called()
|
||||
mock_runtime.execute.assert_not_called()
|
||||
mock_chat.assert_called_once()
|
||||
assert "Response from TargetAgent using gpt-4o" in response
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_runtime_resolution_uses_agent_chat(self):
|
||||
"""Async handoff should route through agent chat, not bare runtime."""
|
||||
source_agent = MockAgent("SourceAgent", "gpt-4")
|
||||
target_agent = MockAgent("TargetAgent", "claude-3-haiku")
|
||||
|
||||
handoff = Handoff(agent=target_agent)
|
||||
|
||||
with patch('praisonaiagents.runtime.resolve.resolve_runtime') as mock_resolve:
|
||||
mock_resolve.side_effect = Exception("Should not be called")
|
||||
|
||||
response = await handoff._execute_with_runtime_resolution_async(
|
||||
source_agent=source_agent,
|
||||
prompt="Test async fallback",
|
||||
effective_tools=[],
|
||||
context={}
|
||||
)
|
||||
|
||||
mock_resolve.assert_not_called()
|
||||
assert "Response from TargetAgent using claude-3-haiku" in response
|
||||
|
||||
def test_extract_model_ref_from_llm_object(self):
|
||||
"""Model extraction should handle LLM objects, not pass them to resolvers."""
|
||||
source_agent = MockAgent("SourceAgent", "gpt-4")
|
||||
target_agent = MockAgent("TargetAgent", "gpt-4o")
|
||||
llm_object = Mock()
|
||||
llm_object.model = "claude-3-opus"
|
||||
target_agent.llm = llm_object
|
||||
|
||||
handoff = Handoff(agent=target_agent)
|
||||
assert handoff._extract_model_ref(target_agent) == "claude-3-opus"
|
||||
|
||||
|
||||
class TestConstructionVsTurnTimeRegression:
|
||||
"""
|
||||
Regression tests for construction-time vs turn-time resolution.
|
||||
|
||||
These tests specifically validate that the fix for issue #1938 works correctly.
|
||||
"""
|
||||
|
||||
def test_model_change_after_handoff_creation(self):
|
||||
"""
|
||||
Test the core regression case: model change after handoff creation.
|
||||
|
||||
Before fix: handoff would use construction-time model
|
||||
After fix: handoff should use turn-time model
|
||||
"""
|
||||
source_agent = MockAgent("SourceAgent", "gpt-3.5-turbo")
|
||||
target_agent = MockAgent("TargetAgent", "gpt-4")
|
||||
|
||||
# Create handoff with initial target model
|
||||
handoff = Handoff(agent=target_agent)
|
||||
|
||||
# Target agent changes model (router decision, user preference, etc.)
|
||||
target_agent.llm = "gpt-4o"
|
||||
target_agent.model = "gpt-4o"
|
||||
|
||||
with patch.object(target_agent, 'chat', wraps=target_agent.chat) as mock_chat:
|
||||
handoff._execute_with_runtime_resolution(
|
||||
source_agent=source_agent,
|
||||
prompt="Test with new model",
|
||||
effective_tools=[],
|
||||
context={}
|
||||
)
|
||||
|
||||
mock_chat.assert_called_once()
|
||||
assert handoff._extract_model_ref(target_agent) == "gpt-4o"
|
||||
|
||||
def test_sub_agent_tool_respects_current_model(self):
|
||||
"""Test that as_tool() handoffs route through the target agent chat path."""
|
||||
parent_agent = MockAgent("ParentAgent", "gpt-4")
|
||||
parent_agent.chat_history = [{"role": "user", "content": "Complete subtask"}]
|
||||
sub_agent = MockAgent("SubAgent", "claude-3-sonnet")
|
||||
|
||||
handoff = Handoff(agent=sub_agent)
|
||||
sub_agent.llm = "claude-3-opus"
|
||||
sub_agent.model = "claude-3-opus"
|
||||
|
||||
with patch.object(sub_agent, 'chat', wraps=sub_agent.chat) as mock_chat:
|
||||
tool_func = handoff.to_tool_function(parent_agent)
|
||||
result = tool_func(task="Complete subtask")
|
||||
|
||||
mock_chat.assert_called_once()
|
||||
assert "claude-3-opus" in result
|
||||
assert handoff._extract_model_ref(sub_agent) == "claude-3-opus"
|
||||
|
||||
def test_multiple_handoffs_different_models(self):
|
||||
"""Test multiple handoffs with different models work correctly."""
|
||||
source_agent = MockAgent("SourceAgent", "gpt-3.5-turbo")
|
||||
target_a = MockAgent("TargetA", "gpt-4o")
|
||||
target_b = MockAgent("TargetB", "claude-3-sonnet")
|
||||
|
||||
handoff_a = Handoff(agent=target_a)
|
||||
handoff_b = Handoff(agent=target_b)
|
||||
|
||||
response_a = handoff_a._execute_with_runtime_resolution(
|
||||
source_agent, "Task A", [], {}
|
||||
)
|
||||
response_b = handoff_b._execute_with_runtime_resolution(
|
||||
source_agent, "Task B", [], {}
|
||||
)
|
||||
|
||||
assert "gpt-4o" in response_a
|
||||
assert "claude-3-sonnet" in response_b
|
||||
assert handoff_a._extract_model_ref(target_a) == "gpt-4o"
|
||||
assert handoff_b._extract_model_ref(target_b) == "claude-3-sonnet"
|
||||
|
||||
|
||||
# Run tests
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -51,6 +51,35 @@ class BackoffPolicy:
|
||||
|
||||
class ToolExecutionMixin:
|
||||
"""Mixin providing toolexecution methods for the Agent class."""
|
||||
|
||||
def _register_artifact_tools(self):
|
||||
"""Register artifact retrieval tools when artifacts are first created."""
|
||||
try:
|
||||
from ..tools import artifact_tools
|
||||
|
||||
# Set the store reference for the tools
|
||||
artifact_tools.set_artifact_store(self._artifact_store)
|
||||
|
||||
# Add the retrieval tools
|
||||
tools_to_add = [
|
||||
artifact_tools.artifact_head,
|
||||
artifact_tools.artifact_tail,
|
||||
artifact_tools.artifact_grep,
|
||||
artifact_tools.artifact_chunk,
|
||||
artifact_tools.artifact_load,
|
||||
artifact_tools.artifact_list,
|
||||
]
|
||||
|
||||
# Only add if not already present
|
||||
existing_tool_names = {getattr(t, '__name__', str(t)) for t in self.tools}
|
||||
for tool in tools_to_add:
|
||||
tool_name = getattr(tool, '__name__', str(tool))
|
||||
if tool_name not in existing_tool_names:
|
||||
self.tools.append(tool)
|
||||
|
||||
logging.debug("Registered artifact retrieval tools")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to register artifact tools: {e}")
|
||||
|
||||
def _get_existing_stream_emitter(self):
|
||||
"""Return an already-initialized stream emitter without creating one."""
|
||||
@@ -364,10 +393,28 @@ class ToolExecutionMixin:
|
||||
agent_id=self.name,
|
||||
is_retryable=True,
|
||||
)
|
||||
# For other error dicts, treat as non-retryable unless specified
|
||||
else:
|
||||
# Success path - return the result
|
||||
# For other error dicts: approval/permission denials are legitimate
|
||||
# non-retryable outcomes; everything else represents a tool failure
|
||||
# that should engage the outer retry/backoff loop.
|
||||
elif result.get("approval_denied") or result.get("permission_denied") or result.get("approval_error"):
|
||||
break
|
||||
else:
|
||||
# Avoid compounding with the inner retry loop in
|
||||
# _execute_tool_with_circuit_breaker: error types it already
|
||||
# retries (e.g. timeout/rate_limit/connection_error) are
|
||||
# exhausted by the time they reach here, so escalating them as
|
||||
# retryable would re-run the entire inner loop from scratch on
|
||||
# every outer attempt. Only escalate error types the inner loop
|
||||
# does NOT retry (e.g. "unknown") so they get one outer pass.
|
||||
error_type = self._classify_error_type(result, None)
|
||||
inner_policy = self._get_tool_retry_policy(function_name)
|
||||
is_retryable = error_type not in inner_policy.retry_on
|
||||
raise ToolExecutionError(
|
||||
result.get("error", f"Tool '{function_name}' failed"),
|
||||
tool_name=function_name,
|
||||
agent_id=self.name,
|
||||
is_retryable=is_retryable,
|
||||
)
|
||||
else:
|
||||
# Success path - return the result
|
||||
break
|
||||
@@ -462,36 +509,68 @@ class ToolExecutionMixin:
|
||||
try:
|
||||
result_str = str(result)
|
||||
|
||||
if self.context_manager:
|
||||
# Use context-aware truncation with configured budget
|
||||
truncated = self._truncate_tool_output(function_name, result_str, tool_call_id)
|
||||
else:
|
||||
# Apply default limit even without context management
|
||||
# This prevents runaway tool outputs from causing overflow
|
||||
limit = getattr(self, 'tool_output_limit', 16000)
|
||||
if len(result_str) > limit:
|
||||
# Use smart truncation format that judge recognizes as OK
|
||||
tail_size = min(limit // 5, 2000)
|
||||
head = result_str[:limit - tail_size]
|
||||
tail = result_str[-tail_size:] if tail_size > 0 else ""
|
||||
truncated = f"{head}\n...[{len(result_str):,} chars, showing first/last portions]...\n{tail}"
|
||||
|
||||
# Store full output for later retrieval
|
||||
# Get configured limit
|
||||
limit = getattr(self, 'tool_output_limit', 16000)
|
||||
|
||||
# Check if we need to spill to artifact store
|
||||
if len(result_str) > limit:
|
||||
# Try to use artifact store if available
|
||||
artifact_ref = None
|
||||
if hasattr(self, '_artifact_store') and self._artifact_store is not None:
|
||||
try:
|
||||
from ..runtime.tool_output_store import get_tool_output_store
|
||||
store = get_tool_output_store(getattr(self, '_run_id', None))
|
||||
metadata = store.store(function_name, result_str, call_id=tool_call_id)
|
||||
if metadata:
|
||||
# Add reference to stored output in the truncated preview
|
||||
truncated = store.format_reference(metadata, truncated)
|
||||
logging.debug(f"Stored full {function_name} output ({len(result_str)} bytes) at {metadata.get('path')}")
|
||||
except ImportError:
|
||||
# Fallback if store not available
|
||||
pass
|
||||
from ..context.artifacts import ArtifactMetadata
|
||||
|
||||
# Create metadata for this artifact
|
||||
metadata = ArtifactMetadata(
|
||||
agent_id=self.name,
|
||||
run_id=getattr(self, '_current_run_id', 'unknown'),
|
||||
tool_name=function_name,
|
||||
turn_id=getattr(self, '_turn_counter', 0),
|
||||
)
|
||||
|
||||
# Store the full output
|
||||
artifact_ref = self._artifact_store.store(result_str, metadata)
|
||||
logging.debug(f"Stored {function_name} output ({len(result_str)} bytes) as artifact {artifact_ref.artifact_id}")
|
||||
|
||||
# Register artifact retrieval tools if not already registered
|
||||
if not hasattr(self, '_artifact_tools_registered'):
|
||||
self._register_artifact_tools()
|
||||
self._artifact_tools_registered = True
|
||||
except Exception as e:
|
||||
logging.debug(f"Failed to store tool output: {e}")
|
||||
logging.debug(f"Failed to store artifact: {e}")
|
||||
|
||||
# Generate truncated preview
|
||||
tail_size = min(limit // 5, 2000)
|
||||
head = result_str[:limit - tail_size]
|
||||
tail = result_str[-tail_size:] if tail_size > 0 else ""
|
||||
|
||||
# If we stored an artifact, include reference in the output
|
||||
if artifact_ref:
|
||||
truncated = (
|
||||
f"{head}\n"
|
||||
f"...[{len(result_str):,} chars total, showing first/last portions]...\n"
|
||||
f"{tail}\n\n"
|
||||
f"{artifact_ref.to_inline()}"
|
||||
)
|
||||
else:
|
||||
truncated = result_str
|
||||
# Fallback to simple truncation
|
||||
truncated = f"{head}\n...[{len(result_str):,} chars, showing first/last portions]...\n{tail}"
|
||||
else:
|
||||
truncated = result_str
|
||||
|
||||
if self.context_manager and hasattr(self, '_truncate_tool_output'):
|
||||
# Use context-aware truncation if available, but preserve artifact reference
|
||||
if 'artifact_ref' in locals() and artifact_ref:
|
||||
# Extract the artifact reference from the truncated string
|
||||
artifact_inline = artifact_ref.to_inline()
|
||||
# Remove the artifact reference before context truncation
|
||||
truncated_without_ref = truncated.replace(artifact_inline, "").rstrip()
|
||||
# Apply context truncation
|
||||
truncated_without_ref = self._truncate_tool_output(function_name, truncated_without_ref, tool_call_id)
|
||||
# Re-append the artifact reference
|
||||
truncated = f"{truncated_without_ref}\n\n{artifact_inline}"
|
||||
else:
|
||||
truncated = self._truncate_tool_output(function_name, truncated, tool_call_id)
|
||||
|
||||
if len(truncated) < len(result_str):
|
||||
logging.debug(f"Truncated {function_name} output from {len(result_str)} to {len(truncated)} chars")
|
||||
@@ -499,6 +578,9 @@ class ToolExecutionMixin:
|
||||
if isinstance(result, dict):
|
||||
max_field_chars = getattr(self, 'tool_output_limit', 16000) if not self.context_manager else None
|
||||
result = self._truncate_dict_fields(result, function_name, max_field_chars, tool_call_id)
|
||||
# Add artifact reference to dict result if available
|
||||
if 'artifact_ref' in locals() and artifact_ref:
|
||||
result["_artifact_ref"] = artifact_ref.to_dict()
|
||||
else:
|
||||
result = truncated
|
||||
except Exception as e:
|
||||
@@ -612,6 +694,12 @@ class ToolExecutionMixin:
|
||||
|
||||
def _trigger_after_agent_hook(self, prompt, response, start_time, tools_used=None):
|
||||
"""Trigger AFTER_AGENT hook and return response."""
|
||||
# During a guarded skill-review turn, skip the entire after-agent
|
||||
# side-effect pipeline (hooks, auto-memory, auto-learning, nudge,
|
||||
# skill-review). The review turn is internal and must not re-fire
|
||||
# these effects or recurse into another review.
|
||||
if getattr(self, "_in_skill_review", False):
|
||||
return response
|
||||
from ..hooks import HookEvent, AfterAgentInput
|
||||
after_agent_input = AfterAgentInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
@@ -647,10 +735,31 @@ class ToolExecutionMixin:
|
||||
# Log learning nudge failures for debugging
|
||||
logger.warning("Learning nudge generation failed: %s", e, exc_info=True)
|
||||
|
||||
# Autonomous skill self-improvement loop (opt-in via self_improve=True).
|
||||
# Runs a guarded review pass restricted to skill_manage. No-op when
|
||||
# disabled or already inside a review (re-entrancy guarded).
|
||||
try:
|
||||
run_review = getattr(self, "_run_skill_review", None)
|
||||
if run_review is not None:
|
||||
run_review(prompt, response, tools_used)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Skill self-improvement review failed for agent=%s session_id=%s; "
|
||||
"set self_improve=False to disable this best-effort pass. error=%s",
|
||||
getattr(self, "name", None),
|
||||
getattr(self, "_session_id", None),
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
async def _atrigger_after_agent_hook(self, prompt, response, start_time, tools_used=None):
|
||||
"""Async version: Trigger AFTER_AGENT hook and return response."""
|
||||
# During a guarded skill-review turn, skip the entire after-agent
|
||||
# side-effect pipeline (see sync variant for rationale).
|
||||
if getattr(self, "_in_skill_review", False):
|
||||
return response
|
||||
from ..hooks import HookEvent, AfterAgentInput
|
||||
after_agent_input = AfterAgentInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
@@ -683,6 +792,21 @@ class ToolExecutionMixin:
|
||||
# Log learning nudge failures for debugging
|
||||
logger.warning("Learning nudge generation failed: %s", e, exc_info=True)
|
||||
|
||||
# Autonomous skill self-improvement loop (opt-in via self_improve=True).
|
||||
try:
|
||||
arun_review = getattr(self, "_arun_skill_review", None)
|
||||
if arun_review is not None:
|
||||
await arun_review(prompt, response, tools_used)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Skill self-improvement review failed for agent=%s session_id=%s; "
|
||||
"set self_improve=False to disable this best-effort pass. error=%s",
|
||||
getattr(self, "name", None),
|
||||
getattr(self, "_session_id", None),
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def _calculate_llm_cost(self, prompt_tokens: int, completion_tokens: int, response: any = None) -> float:
|
||||
@@ -707,6 +831,56 @@ class ToolExecutionMixin:
|
||||
response=response,
|
||||
)
|
||||
|
||||
DEFAULT_OUTPUT_RESERVATION_TOKENS = 1024
|
||||
|
||||
def _estimate_min_call_cost(self, messages, max_tokens=None) -> float:
|
||||
"""Estimate the minimum cost of an upcoming LLM call before dispatch.
|
||||
|
||||
Uses the known input size (messages) plus an output-token reservation
|
||||
so the budget can be enforced as a hard pre-call ceiling. When the agent
|
||||
has no ``max_tokens`` configured, a conservative default reservation
|
||||
(``DEFAULT_OUTPUT_RESERVATION_TOKENS``) is used so a small prompt with a
|
||||
large provider-default response cannot trivially bypass the guard and
|
||||
then overshoot the cap. Input tokens are approximated as ~4 characters
|
||||
per token, matching the rest of the codebase.
|
||||
|
||||
Note: This is intentionally a *minimum* estimate. It counts message
|
||||
content only and does not include tool-schema tokens, so a tool-heavy
|
||||
request may still cost more than estimated; the reactive post-call
|
||||
accounting remains the backstop for that case.
|
||||
|
||||
Args:
|
||||
messages: The messages that will be sent to the LLM.
|
||||
max_tokens: Optional output-token reservation. Falls back to
|
||||
``DEFAULT_OUTPUT_RESERVATION_TOKENS`` when not provided.
|
||||
|
||||
Returns:
|
||||
Estimated minimum cost in USD for the call.
|
||||
"""
|
||||
prompt_chars = 0
|
||||
if messages:
|
||||
for _msg in messages:
|
||||
_content = _msg.get("content") if isinstance(_msg, dict) else None
|
||||
if isinstance(_content, str):
|
||||
prompt_chars += len(_content)
|
||||
elif isinstance(_content, list):
|
||||
for _part in _content:
|
||||
if isinstance(_part, dict):
|
||||
_text = _part.get("text")
|
||||
if isinstance(_text, str):
|
||||
prompt_chars += len(_text)
|
||||
elif _content is not None:
|
||||
prompt_chars += len(str(_content))
|
||||
|
||||
prompt_tokens = prompt_chars // 4
|
||||
if max_tokens:
|
||||
completion_tokens = int(max_tokens)
|
||||
elif prompt_chars > 0:
|
||||
completion_tokens = self.DEFAULT_OUTPUT_RESERVATION_TOKENS
|
||||
else:
|
||||
completion_tokens = 0
|
||||
return self._calculate_llm_cost(prompt_tokens, completion_tokens)
|
||||
|
||||
def _truncate_dict_fields(self, data: dict, tool_name: str, max_field_chars: int = None, tool_call_id: str = None) -> dict:
|
||||
"""Truncate large string fields in a dict to prevent context overflow."""
|
||||
if max_field_chars is None:
|
||||
|
||||
@@ -34,7 +34,7 @@ class UnifiedExecutionMixin:
|
||||
This replaces the duplicated logic between chat/achat and execute_tool/execute_tool_async
|
||||
with a single async-first implementation plus sync bridge.
|
||||
"""
|
||||
|
||||
|
||||
async def _unified_chat_impl(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -108,7 +108,8 @@ class UnifiedExecutionMixin:
|
||||
tool_choice=tool_choice
|
||||
)
|
||||
elif hasattr(self, '_cli_backend') and self._cli_backend is not None:
|
||||
# Legacy CLI backend with deprecation warning (emitted in Agent.__init__)
|
||||
# CLI Backend routing - delegate entire turn if configured
|
||||
# Note: Turn-time resolution placeholder is in place for future backend switching
|
||||
return await self._chat_via_cli_backend(
|
||||
prompt=prompt,
|
||||
temperature=temperature,
|
||||
|
||||
@@ -42,7 +42,7 @@ from typing import Callable, Dict, List, Literal, Optional, Set
|
||||
# ── New protocol-driven exports ──────────────────────────────────────────────
|
||||
|
||||
from .protocols import ApprovalProtocol, ApprovalRequest, ApprovalConfig # noqa: F401
|
||||
from .protocols import ApprovalDecision # noqa: F401
|
||||
from .protocols import ApprovalDecision, ApprovalStoreProtocol # noqa: F401
|
||||
from .backends import AutoApproveBackend, ConsoleBackend, CallbackBackend, AgentApproval # noqa: F401
|
||||
from .registry import ApprovalRegistry, DEFAULT_DANGEROUS_TOOLS # noqa: F401
|
||||
|
||||
|
||||
@@ -8,8 +8,14 @@ explicit inheritance (structural subtyping).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional, Protocol, runtime_checkable
|
||||
from typing import Any, Dict, List, Optional, Protocol, Tuple, runtime_checkable
|
||||
|
||||
|
||||
def _new_approval_id() -> str:
|
||||
"""Generate a stable correlation id for an approval request."""
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -17,12 +23,15 @@ class ApprovalRequest:
|
||||
"""Immutable request for tool-execution approval.
|
||||
|
||||
Attributes:
|
||||
tool_name: Name of the tool requesting approval.
|
||||
arguments: Arguments the tool will be called with.
|
||||
risk_level: Risk classification (critical / high / medium / low).
|
||||
agent_name: Name of the agent that triggered the call (optional).
|
||||
session_id: Session identifier for tracking (optional).
|
||||
context: Arbitrary context dict for backend-specific data.
|
||||
tool_name: Name of the tool requesting approval.
|
||||
arguments: Arguments the tool will be called with.
|
||||
risk_level: Risk classification (critical / high / medium / low).
|
||||
agent_name: Name of the agent that triggered the call (optional).
|
||||
session_id: Session identifier for tracking (optional).
|
||||
context: Arbitrary context dict for backend-specific data.
|
||||
approval_id: Stable correlation id that survives a process restart
|
||||
and can be matched to an inbound channel callback.
|
||||
Auto-generated when not supplied.
|
||||
"""
|
||||
|
||||
tool_name: str
|
||||
@@ -31,6 +40,7 @@ class ApprovalRequest:
|
||||
agent_name: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
context: Dict[str, Any] = field(default_factory=dict)
|
||||
approval_id: str = field(default_factory=_new_approval_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -114,3 +124,52 @@ class ApprovalProtocol(Protocol):
|
||||
ApprovalDecision indicating whether execution is approved.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ApprovalStoreProtocol(Protocol):
|
||||
"""Protocol for durable persistence of pending approvals.
|
||||
|
||||
A store lets pending approvals survive a process restart: requests are
|
||||
persisted with a stable ``approval_id``, outstanding ones are re-hydrated
|
||||
on startup, and decisions are recorded as a durable audit trail.
|
||||
|
||||
Behaviour is unchanged when no store is configured — in-memory remains the
|
||||
zero-dependency default. Heavy backends (e.g. SQLite) live in the wrapper
|
||||
package; this contract lives in core so any backend and any channel can
|
||||
interoperate.
|
||||
|
||||
Example::
|
||||
|
||||
class MyStore:
|
||||
async def persist(self, approval_id, request, *, expires_at):
|
||||
...
|
||||
async def list_pending(self):
|
||||
return []
|
||||
async def resolve(self, approval_id, decision):
|
||||
...
|
||||
"""
|
||||
|
||||
async def persist(
|
||||
self,
|
||||
approval_id: str,
|
||||
request: ApprovalRequest,
|
||||
*,
|
||||
expires_at: float,
|
||||
) -> None:
|
||||
"""Durably store a pending approval keyed by ``approval_id``."""
|
||||
...
|
||||
|
||||
async def list_pending(self) -> List[Tuple[str, ApprovalRequest]]:
|
||||
"""Return outstanding (un-resolved, un-expired) pending approvals."""
|
||||
...
|
||||
|
||||
async def resolve(self, approval_id: str, decision: ApprovalDecision) -> None:
|
||||
"""Record a final decision for ``approval_id`` as an audit trail.
|
||||
|
||||
``decision.metadata['terminal']`` may carry an explicit terminal state
|
||||
(``"approved"``, ``"denied"`` or ``"expired"``) so the timeout path can
|
||||
be recorded distinctly from a user denial. When absent the state is
|
||||
derived from ``decision.approved``.
|
||||
"""
|
||||
...
|
||||
@@ -18,6 +18,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import os
|
||||
@@ -173,15 +175,21 @@ class ApprovalRegistry:
|
||||
|
||||
# ── Context helpers ──────────────────────────────────────────────────
|
||||
|
||||
def mark_approved(self, tool_name: str) -> None:
|
||||
@staticmethod
|
||||
def _approval_cache_key(tool_name: str, arguments: Dict) -> str:
|
||||
payload = json.dumps(arguments or {}, sort_keys=True, default=str)
|
||||
digest = hashlib.sha256(payload.encode()).hexdigest()[:16]
|
||||
return f"{tool_name}:{digest}"
|
||||
|
||||
def mark_approved(self, tool_name: str, arguments: Optional[Dict] = None) -> None:
|
||||
approved = self._approved_context.get(set())
|
||||
approved.add(tool_name)
|
||||
approved.add(self._approval_cache_key(tool_name, arguments or {}))
|
||||
self._approved_context.set(approved)
|
||||
|
||||
def is_already_approved(self, tool_name: str) -> bool:
|
||||
def is_already_approved(self, tool_name: str, arguments: Optional[Dict] = None) -> bool:
|
||||
if self.get_risk_level(tool_name) == "critical":
|
||||
return False
|
||||
return tool_name in self._approved_context.get(set())
|
||||
return self._approval_cache_key(tool_name, arguments or {}) in self._approved_context.get(set())
|
||||
|
||||
def clear_approved(self) -> None:
|
||||
self._approved_context.set(set())
|
||||
@@ -222,22 +230,22 @@ class ApprovalRegistry:
|
||||
return ApprovalDecision(approved=True, reason="No approval required")
|
||||
|
||||
# Already approved in this context
|
||||
if self.is_already_approved(tool_name):
|
||||
if self.is_already_approved(tool_name, arguments):
|
||||
return ApprovalDecision(approved=True, reason="Already approved in context")
|
||||
|
||||
# Check per-tool auto-approval (G-A fix)
|
||||
if self.is_auto_approved(tool_name, agent_name):
|
||||
self.mark_approved(tool_name)
|
||||
self.mark_approved(tool_name, arguments)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (skill)", approver="skill")
|
||||
|
||||
# Env auto-approve
|
||||
if self.is_env_auto_approve():
|
||||
self.mark_approved(tool_name)
|
||||
self.mark_approved(tool_name, arguments)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (env)", approver="env")
|
||||
|
||||
# YAML auto-approve
|
||||
if self.is_yaml_approved(tool_name):
|
||||
self.mark_approved(tool_name)
|
||||
self.mark_approved(tool_name, arguments)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (yaml)", approver="yaml")
|
||||
|
||||
# Delegate to backend
|
||||
@@ -261,7 +269,7 @@ class ApprovalRegistry:
|
||||
)
|
||||
|
||||
if decision.approved:
|
||||
self.mark_approved(tool_name)
|
||||
self.mark_approved(tool_name, arguments)
|
||||
return decision
|
||||
|
||||
async def approve_async(
|
||||
@@ -275,20 +283,20 @@ class ApprovalRegistry:
|
||||
if not self.is_required(tool_name):
|
||||
return ApprovalDecision(approved=True, reason="No approval required")
|
||||
|
||||
if self.is_already_approved(tool_name):
|
||||
if self.is_already_approved(tool_name, arguments):
|
||||
return ApprovalDecision(approved=True, reason="Already approved in context")
|
||||
|
||||
# Check per-tool auto-approval (G-A fix)
|
||||
if self.is_auto_approved(tool_name, agent_name):
|
||||
self.mark_approved(tool_name)
|
||||
self.mark_approved(tool_name, arguments)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (skill)", approver="skill")
|
||||
|
||||
if self.is_env_auto_approve():
|
||||
self.mark_approved(tool_name)
|
||||
self.mark_approved(tool_name, arguments)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (env)", approver="env")
|
||||
|
||||
if self.is_yaml_approved(tool_name):
|
||||
self.mark_approved(tool_name)
|
||||
self.mark_approved(tool_name, arguments)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (yaml)", approver="yaml")
|
||||
|
||||
backend = self.get_backend(agent_name)
|
||||
@@ -308,5 +316,5 @@ class ApprovalRegistry:
|
||||
decision = ApprovalDecision(approved=False, reason="Approval timed out")
|
||||
|
||||
if decision.approved:
|
||||
self.mark_approved(tool_name)
|
||||
self.mark_approved(tool_name, arguments)
|
||||
return decision
|
||||
@@ -27,6 +27,7 @@ from .protocols import (
|
||||
EmailInbox,
|
||||
SupportsPresentation,
|
||||
PlatformCapabilities,
|
||||
WebhookVerifierProtocol,
|
||||
)
|
||||
from .presentation import (
|
||||
MessagePresentation,
|
||||
@@ -38,17 +39,25 @@ from .presentation import (
|
||||
ActionType,
|
||||
ButtonStyle,
|
||||
BlockType,
|
||||
adapt_presentation,
|
||||
)
|
||||
from .interactive import (
|
||||
InteractiveContext,
|
||||
InteractiveRegistry,
|
||||
InteractiveHandler,
|
||||
InteractiveAuthorizer,
|
||||
encode_action,
|
||||
decode_callback,
|
||||
create_registry,
|
||||
get_registry,
|
||||
register_handler,
|
||||
unregister_handler,
|
||||
make_reply_handler,
|
||||
REPLY_NAMESPACE,
|
||||
)
|
||||
from .agent_reply import (
|
||||
AgentReply,
|
||||
extract_presentation,
|
||||
)
|
||||
from .config import BotConfig, BotOSConfig
|
||||
|
||||
@@ -80,14 +89,21 @@ __all__ = [
|
||||
"ActionType",
|
||||
"ButtonStyle",
|
||||
"BlockType",
|
||||
"adapt_presentation",
|
||||
"PlatformCapabilities",
|
||||
"WebhookVerifierProtocol",
|
||||
"InteractiveContext",
|
||||
"InteractiveRegistry",
|
||||
"InteractiveHandler",
|
||||
"InteractiveAuthorizer",
|
||||
"encode_action",
|
||||
"decode_callback",
|
||||
"create_registry",
|
||||
"get_registry",
|
||||
"register_handler",
|
||||
"unregister_handler",
|
||||
"make_reply_handler",
|
||||
"REPLY_NAMESPACE",
|
||||
"AgentReply",
|
||||
"extract_presentation",
|
||||
]
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Agent-authored reply contract for interactive bot UI.
|
||||
|
||||
Defines the seam that lets an agent (or a hook) emit a portable
|
||||
``MessagePresentation`` as part of a normal reply, alongside optional text.
|
||||
Channels render the presentation via the existing per-platform renderers and
|
||||
fall back to text where rich UI is unsupported.
|
||||
|
||||
This is a core protocol with no heavy implementations: channel rendering and
|
||||
session wiring live in the wrapper (praisonai). Producers return one of:
|
||||
|
||||
* ``str`` -> plain text (unchanged, fully backward compatible)
|
||||
* ``MessagePresentation`` -> interactive UI (text fallback derived from text blocks)
|
||||
* ``AgentReply`` -> explicit text + presentation pairing
|
||||
* a dict ``{"text": ..., "presentation": ...}`` -> serialised form
|
||||
|
||||
``extract_presentation`` normalises any of these into ``(text, presentation)``
|
||||
so the bot session can stay agnostic about which form the agent used.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from .presentation import MessagePresentation, BlockType
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentReply:
|
||||
"""An agent reply that may carry interactive UI alongside text.
|
||||
|
||||
Attributes:
|
||||
text: Plain-text answer (used as the text fallback for channels that
|
||||
cannot render rich UI, and as the spoken/preview content otherwise).
|
||||
presentation: Optional portable presentation with buttons/selects.
|
||||
"""
|
||||
|
||||
text: str = ""
|
||||
presentation: Optional[MessagePresentation] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialise to a plain dict."""
|
||||
data: dict = {"text": self.text}
|
||||
if self.presentation is not None:
|
||||
data["presentation"] = self.presentation.to_dict()
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "AgentReply":
|
||||
"""Create from a plain dict."""
|
||||
pres = data.get("presentation")
|
||||
return cls(
|
||||
text=data.get("text", "") or "",
|
||||
presentation=(
|
||||
MessagePresentation.from_dict(pres)
|
||||
if isinstance(pres, dict)
|
||||
else pres
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _text_from_presentation(presentation: MessagePresentation) -> str:
|
||||
"""Derive a plain-text fallback from a presentation's text/context blocks."""
|
||||
parts = []
|
||||
for block in presentation.blocks:
|
||||
btype = block.type.value if isinstance(block.type, BlockType) else block.type
|
||||
if btype in (BlockType.TEXT.value, BlockType.CONTEXT.value) and block.text:
|
||||
parts.append(block.text)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def extract_presentation(
|
||||
result: Any,
|
||||
) -> Tuple[str, Optional[MessagePresentation]]:
|
||||
"""Normalise an agent result into ``(text, presentation)``.
|
||||
|
||||
Accepts the plain ``str`` reply path (returns ``(result, None)``), an
|
||||
``AgentReply``, a ``MessagePresentation`` (text fallback derived from its
|
||||
text blocks), a serialised dict, or any object exposing a ``presentation``
|
||||
attribute. This keeps the bot session backward compatible: existing
|
||||
string-returning agents are unaffected, while presentation-aware agents get
|
||||
their UI rendered.
|
||||
|
||||
Args:
|
||||
result: The value returned/emitted by an agent turn.
|
||||
|
||||
Returns:
|
||||
A ``(text, presentation)`` tuple. ``presentation`` is ``None`` when the
|
||||
result carried no interactive UI.
|
||||
"""
|
||||
if result is None:
|
||||
return ("", None)
|
||||
|
||||
if isinstance(result, str):
|
||||
return (result, None)
|
||||
|
||||
if isinstance(result, AgentReply):
|
||||
text = result.text
|
||||
if not text and result.presentation is not None:
|
||||
text = _text_from_presentation(result.presentation)
|
||||
return (text, result.presentation)
|
||||
|
||||
if isinstance(result, MessagePresentation):
|
||||
return (_text_from_presentation(result), result)
|
||||
|
||||
if isinstance(result, dict) and (
|
||||
"presentation" in result or "blocks" in result
|
||||
):
|
||||
if "blocks" in result and "presentation" not in result:
|
||||
pres = MessagePresentation.from_dict(result)
|
||||
return (_text_from_presentation(pres), pres)
|
||||
reply = AgentReply.from_dict(result)
|
||||
text = reply.text
|
||||
if not text and reply.presentation is not None:
|
||||
text = _text_from_presentation(reply.presentation)
|
||||
return (text, reply.presentation)
|
||||
|
||||
# Duck-typed: any object exposing a ``presentation`` attribute.
|
||||
pres = getattr(result, "presentation", None)
|
||||
if isinstance(pres, MessagePresentation):
|
||||
text = getattr(result, "text", None)
|
||||
if not text:
|
||||
text = getattr(result, "output", None) or _text_from_presentation(pres)
|
||||
return (str(text), pres)
|
||||
|
||||
# Unknown shape: stringify so the text path stays robust.
|
||||
return (str(result), None)
|
||||
@@ -45,6 +45,66 @@ class InteractiveContext:
|
||||
|
||||
InteractiveHandler = Callable[[InteractiveContext], Awaitable[Optional[str]]]
|
||||
|
||||
# An authorizer decides whether the resolving actor (context.user_id) is
|
||||
# permitted to act on a given interactive callback. Returning False rejects
|
||||
# the callback before the handler runs, so unauthorized clicks never resolve
|
||||
# privileged actions (e.g. tool approvals).
|
||||
InteractiveAuthorizer = Callable[[InteractiveContext], bool]
|
||||
|
||||
REPLY_NAMESPACE = "reply"
|
||||
|
||||
# Marker prefixing a hashed (non-routable) reply value. Mirrors
|
||||
# ``presentation.REPLY_HASH_MARKER``; kept local so the inbound handler does not
|
||||
# import the (heavier) render module. When an agent reply value is too long for
|
||||
# the channel callback byte-cap, the encoder emits ``reply:#<digest>`` instead
|
||||
# of a lossy prefix; the handler detects this marker and declines to route a
|
||||
# value the agent never authored.
|
||||
_REPLY_HASH_MARKER = "#"
|
||||
|
||||
|
||||
def make_reply_handler(
|
||||
continue_turn: Callable[[str, "InteractiveContext"], Awaitable[Optional[str]]],
|
||||
) -> InteractiveHandler:
|
||||
"""Build a handler for agent-authored ``reply`` button/select callbacks.
|
||||
|
||||
A ``reply`` action (see ``PresentationAction.reply``) carries the value the
|
||||
user chose. When clicked, the channel dispatches the ``reply:<value>``
|
||||
callback to this handler, which extracts ``<value>`` and feeds it back into
|
||||
the agent turn via *continue_turn* — without channels inferring intent from
|
||||
``/``-prefixed strings.
|
||||
|
||||
Args:
|
||||
continue_turn: Async callable ``(value, context) -> Optional[str]`` that
|
||||
continues the agent loop with the chosen value as the next input and
|
||||
returns the agent's response (or ``None``).
|
||||
|
||||
Returns:
|
||||
An ``InteractiveHandler`` to register under the ``reply`` namespace.
|
||||
"""
|
||||
|
||||
async def _handler(context: "InteractiveContext") -> Optional[str]:
|
||||
payload = context.platform_data.get("decoded_payload") or {}
|
||||
value = payload.get("value")
|
||||
if value is None:
|
||||
# Fall back to parsing the raw callback when dispatch didn't decode.
|
||||
data = context.callback_data or ""
|
||||
if data.startswith(f"{REPLY_NAMESPACE}:"):
|
||||
value = data[len(REPLY_NAMESPACE) + 1:]
|
||||
if value is None:
|
||||
return None
|
||||
if value.startswith(_REPLY_HASH_MARKER):
|
||||
# The original value was too long for the channel callback byte-cap
|
||||
# and was hashed for transport. Feeding the digest into the turn
|
||||
# would route a value the agent never authored, so decline instead.
|
||||
logger.warning(
|
||||
"Reply value exceeded the channel callback limit and could not "
|
||||
"be routed; keep quick-reply values short enough to fit the cap."
|
||||
)
|
||||
return None
|
||||
return await continue_turn(value, context)
|
||||
|
||||
return _handler
|
||||
|
||||
|
||||
def encode_action(namespace: str, action: "PresentationAction") -> str:
|
||||
"""Encode an action with namespace for callback data.
|
||||
@@ -58,7 +118,15 @@ def encode_action(namespace: str, action: "PresentationAction") -> str:
|
||||
"""
|
||||
from .presentation import ActionType
|
||||
|
||||
if action.type == ActionType.CALLBACK:
|
||||
if action.type == ActionType.REPLY:
|
||||
# Reply actions route the chosen value back into the next agent turn.
|
||||
# The ``reply:`` namespace is reserved for this; the value is the input.
|
||||
# Only ``None`` falls back to the caller namespace — an explicit empty
|
||||
# string is a valid reply value (``make_reply_handler`` accepts it).
|
||||
if action.value is not None:
|
||||
return f"reply:{action.value}"
|
||||
return namespace
|
||||
elif action.type == ActionType.CALLBACK:
|
||||
# For callback type, encode namespace with the value
|
||||
if action.value:
|
||||
return f"{namespace}:{action.value}"
|
||||
@@ -111,22 +179,35 @@ class InteractiveRegistry:
|
||||
def __init__(self):
|
||||
"""Initialize the registry."""
|
||||
self._handlers: Dict[str, InteractiveHandler] = {}
|
||||
self._authorizers: Dict[str, "InteractiveAuthorizer"] = {}
|
||||
self._fallback_handler: Optional[InteractiveHandler] = None
|
||||
|
||||
def register(
|
||||
self,
|
||||
namespace: str,
|
||||
handler: InteractiveHandler
|
||||
handler: InteractiveHandler,
|
||||
authorize: Optional["InteractiveAuthorizer"] = None,
|
||||
) -> None:
|
||||
"""Register a handler for a namespace.
|
||||
|
||||
Args:
|
||||
namespace: The namespace to handle (e.g., "approval", "menu")
|
||||
handler: Async function to handle callbacks in this namespace
|
||||
authorize: Optional callable that receives the InteractiveContext
|
||||
and returns True if the resolving actor (``context.user_id``)
|
||||
is permitted to act on this callback. When provided, an
|
||||
unauthorized click is rejected before the handler runs, so it
|
||||
never resolves the underlying action. When omitted, behaviour
|
||||
is unchanged (any clicker is allowed) for backward
|
||||
compatibility.
|
||||
"""
|
||||
if namespace in self._handlers:
|
||||
logger.warning(f"Overwriting existing handler for namespace: {namespace}")
|
||||
self._handlers[namespace] = handler
|
||||
if authorize is not None:
|
||||
self._authorizers[namespace] = authorize
|
||||
else:
|
||||
self._authorizers.pop(namespace, None)
|
||||
logger.debug(f"Registered handler for namespace: {namespace}")
|
||||
|
||||
def unregister(self, namespace: str) -> None:
|
||||
@@ -137,6 +218,7 @@ class InteractiveRegistry:
|
||||
"""
|
||||
if namespace in self._handlers:
|
||||
del self._handlers[namespace]
|
||||
self._authorizers.pop(namespace, None)
|
||||
logger.debug(f"Unregistered handler for namespace: {namespace}")
|
||||
|
||||
def set_fallback(self, handler: InteractiveHandler) -> None:
|
||||
@@ -162,6 +244,28 @@ class InteractiveRegistry:
|
||||
handler = self._handlers.get(namespace)
|
||||
|
||||
if handler:
|
||||
# Enforce actor authorization before resolving the action.
|
||||
# This is the security boundary: an unauthorized clicker must
|
||||
# never resolve a privileged callback (e.g. a tool approval).
|
||||
authorizer = self._authorizers.get(namespace)
|
||||
if authorizer is not None:
|
||||
try:
|
||||
allowed = authorizer(context)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Authorizer for namespace '{namespace}' raised; "
|
||||
f"denying callback: {e}"
|
||||
)
|
||||
allowed = False
|
||||
if not allowed:
|
||||
logger.warning(
|
||||
f"Unauthorized interactive callback: actor "
|
||||
f"'{context.user_id}' rejected for namespace "
|
||||
f"'{namespace}'"
|
||||
)
|
||||
context.platform_data["authorized"] = False
|
||||
return False
|
||||
|
||||
try:
|
||||
# Add decoded payload to context
|
||||
context.platform_data["decoded_namespace"] = namespace
|
||||
|
||||
@@ -11,10 +11,15 @@ rendering belongs in the wrapper (praisonai).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Literal, Optional, Union
|
||||
|
||||
# Most channels (e.g. Telegram) hard-cap inline callback payloads at 64 bytes.
|
||||
# Keep degraded select callbacks within this bound while preserving uniqueness.
|
||||
_MAX_CALLBACK_LEN = 64
|
||||
|
||||
|
||||
class ActionType(str, Enum):
|
||||
"""Types of actions that can be triggered by interactive elements."""
|
||||
@@ -23,6 +28,7 @@ class ActionType(str, Enum):
|
||||
CALLBACK = "callback" # Opaque callback data for the plugin
|
||||
URL = "url" # Open a URL
|
||||
WEB_APP = "web_app" # Open a web app (Telegram mini apps, etc)
|
||||
REPLY = "reply" # Feed value back into the agent turn as next input
|
||||
|
||||
|
||||
class ButtonStyle(str, Enum):
|
||||
@@ -87,6 +93,33 @@ class PresentationAction:
|
||||
web_app_url=data.get("web_app_url"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def reply(cls, value: str) -> "PresentationAction":
|
||||
"""Create a ``reply`` action that feeds *value* back into the agent turn.
|
||||
|
||||
When a user clicks a button (or picks a select option) carrying a reply
|
||||
action, the chosen *value* is routed back through the interactive
|
||||
registry as the next agent input — no ``/``-prefixed command parsing by
|
||||
channels required. See ``ActionType.REPLY`` and the ``reply`` namespace
|
||||
handler in ``interactive.py``.
|
||||
"""
|
||||
return cls(type=ActionType.REPLY, value=value)
|
||||
|
||||
@classmethod
|
||||
def callback(cls, value: str) -> "PresentationAction":
|
||||
"""Create an opaque ``callback`` action carrying *value*."""
|
||||
return cls(type=ActionType.CALLBACK, value=value)
|
||||
|
||||
@classmethod
|
||||
def command(cls, command: str) -> "PresentationAction":
|
||||
"""Create a ``command`` action that runs a slash *command*."""
|
||||
return cls(type=ActionType.COMMAND, command=command)
|
||||
|
||||
@classmethod
|
||||
def open_url(cls, url: str) -> "PresentationAction":
|
||||
"""Create a ``url`` action that opens *url*."""
|
||||
return cls(type=ActionType.URL, url=url)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PresentationButton:
|
||||
@@ -219,6 +252,33 @@ class PresentationBlock:
|
||||
def make_context(content: str) -> "PresentationBlock":
|
||||
"""Create a context block (smaller text)."""
|
||||
return PresentationBlock(type=BlockType.CONTEXT, text=content)
|
||||
|
||||
@staticmethod
|
||||
def quick_replies(
|
||||
choices: List[Any],
|
||||
priority_base: int = 0,
|
||||
) -> "PresentationBlock":
|
||||
"""Create a row of quick-reply buttons from ``(label, value)`` pairs.
|
||||
|
||||
Each choice may be a ``(label, value)`` tuple or a plain string (used as
|
||||
both label and value). Every button carries a ``reply`` action so a
|
||||
click feeds ``value`` back into the next agent turn. This is the
|
||||
agent-facing shortcut for the common "pick one" interaction.
|
||||
"""
|
||||
items: List["PresentationButton"] = []
|
||||
for choice in choices:
|
||||
if isinstance(choice, (tuple, list)) and len(choice) >= 2:
|
||||
label, value = choice[0], choice[1]
|
||||
else:
|
||||
label = value = choice
|
||||
items.append(
|
||||
PresentationButton(
|
||||
label=str(label),
|
||||
action=PresentationAction.reply(str(value)),
|
||||
priority=priority_base,
|
||||
)
|
||||
)
|
||||
return PresentationBlock(type=BlockType.BUTTONS, buttons=items)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
@@ -442,7 +502,7 @@ class PresentationLimits:
|
||||
def discord() -> "PresentationLimits":
|
||||
"""Get Discord-specific limits."""
|
||||
return PresentationLimits(
|
||||
max_buttons=25,
|
||||
max_buttons=5, # per-row capacity; total cap = max_buttons * max_button_rows = 25
|
||||
max_button_rows=5,
|
||||
max_button_label=80,
|
||||
max_options=25,
|
||||
@@ -454,3 +514,240 @@ class PresentationLimits:
|
||||
)
|
||||
|
||||
|
||||
def _adapt_button(button: PresentationButton, limits: PresentationLimits) -> PresentationButton:
|
||||
"""Return a copy of a button adapted to the given limits.
|
||||
|
||||
Truncates the label and, when the channel does not support web apps,
|
||||
degrades a ``web_app`` action to a plain URL so the button still works.
|
||||
"""
|
||||
label = button.label or ""
|
||||
if limits.max_button_label and len(label) > limits.max_button_label:
|
||||
label = label[: limits.max_button_label]
|
||||
|
||||
action = button.action
|
||||
url = button.url
|
||||
if action is not None:
|
||||
action_type = action.type.value if isinstance(action.type, ActionType) else action.type
|
||||
# Degrade reply -> callback so existing per-channel renderers (which only
|
||||
# map command/callback/url/web_app) can carry the payload. The
|
||||
# ``reply:`` prefix lets the inbound registry route the chosen value
|
||||
# back into the next agent turn (see interactive.py).
|
||||
if action_type == ActionType.REPLY.value and action.value is not None:
|
||||
action = PresentationAction(
|
||||
type=ActionType.CALLBACK,
|
||||
value=_encode_reply_callback(action.value),
|
||||
)
|
||||
elif (
|
||||
not limits.supports_web_apps
|
||||
and action_type == ActionType.WEB_APP.value
|
||||
and action.web_app_url
|
||||
):
|
||||
# Degrade web_app -> url when unsupported
|
||||
if url is None:
|
||||
url = action.web_app_url
|
||||
action = PresentationAction(type=ActionType.URL, url=action.web_app_url)
|
||||
|
||||
return PresentationButton(
|
||||
label=label,
|
||||
action=action,
|
||||
url=url,
|
||||
priority=button.priority,
|
||||
style=button.style,
|
||||
disabled=button.disabled,
|
||||
)
|
||||
|
||||
|
||||
REPLY_CALLBACK_PREFIX = "reply:"
|
||||
|
||||
# Marker prefixing a hashed (non-routable) reply payload. A reply value that
|
||||
# does not fit the channel callback byte-cap is encoded as
|
||||
# ``reply:#<digest>``. The ``#`` marker lets the inbound reply handler detect a
|
||||
# lossy payload and refuse to feed a corrupted/colliding value into the next
|
||||
# agent turn (see ``make_reply_handler``), rather than silently routing a
|
||||
# truncated prefix that two distinct choices could share.
|
||||
REPLY_HASH_MARKER = "#"
|
||||
|
||||
|
||||
def _encode_reply_callback(value: str) -> str:
|
||||
"""Build a channel-safe callback payload for a ``reply`` action.
|
||||
|
||||
Produces ``reply:<value>`` so the inbound interactive registry can route
|
||||
the chosen value back into the next agent turn. The size check is measured
|
||||
in UTF-8 bytes because channel callback caps (e.g. Telegram's 64-byte cap)
|
||||
are byte limits, not character limits.
|
||||
|
||||
When the raw form exceeds ``_MAX_CALLBACK_LEN`` the value is replaced with a
|
||||
short, collision-resistant hash marked with ``#`` (``reply:#<digest>``).
|
||||
Truncating to a prefix was unsafe: two long choices sharing a prefix would
|
||||
collapse to the same payload, and the agent would receive a value it never
|
||||
authored. The hash keeps distinct choices distinct; the marker lets the
|
||||
reply handler recognise that the original value could not be carried inline
|
||||
and avoid routing a lossy value into the turn.
|
||||
"""
|
||||
raw = f"{REPLY_CALLBACK_PREFIX}{value}"
|
||||
if len(raw.encode("utf-8")) <= _MAX_CALLBACK_LEN:
|
||||
return raw
|
||||
digest = hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]
|
||||
return f"{REPLY_CALLBACK_PREFIX}{REPLY_HASH_MARKER}{digest}"
|
||||
|
||||
|
||||
def _encode_select_callback(action_id: str, value: str) -> str:
|
||||
"""Build a channel-safe callback payload for a degraded select option.
|
||||
|
||||
The raw ``select:<action_id>:<value>`` form can exceed channel callback
|
||||
limits (e.g. Telegram's 64-byte cap) and collide when distinct options
|
||||
share a long prefix. When the raw payload fits within ``_MAX_CALLBACK_LEN``
|
||||
it is returned unchanged; otherwise the value is replaced with a short,
|
||||
collision-resistant hash so distinct options stay distinct after truncation.
|
||||
"""
|
||||
raw = f"select:{action_id}:{value}"
|
||||
if len(raw.encode("utf-8")) <= _MAX_CALLBACK_LEN:
|
||||
return raw
|
||||
digest = hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]
|
||||
prefix = f"select:{action_id}:"
|
||||
# Reserve room for the digest; trim the action_id prefix if needed.
|
||||
budget = _MAX_CALLBACK_LEN - len(digest)
|
||||
if budget < len("select::"):
|
||||
# action_id itself is too long; hash it too to guarantee the bound.
|
||||
aid_digest = hashlib.sha1((action_id or "").encode("utf-8")).hexdigest()[:8]
|
||||
prefix = f"select:{aid_digest}:"
|
||||
return f"{prefix[:_MAX_CALLBACK_LEN - len(digest)]}{digest}"
|
||||
|
||||
|
||||
def _select_to_buttons(block: PresentationBlock) -> PresentationBlock:
|
||||
"""Convert a SELECT block into an equivalent BUTTONS block.
|
||||
|
||||
Used when a channel does not support native select menus. Each option
|
||||
becomes a callback button carrying a bounded, channel-safe identifier
|
||||
derived from ``select:<action_id>:<value>``.
|
||||
"""
|
||||
buttons: List[PresentationButton] = []
|
||||
action_id = block.action_id or ""
|
||||
for option in (block.options or []):
|
||||
label = option.label
|
||||
if option.emoji:
|
||||
label = f"{option.emoji} {label}"
|
||||
buttons.append(
|
||||
PresentationButton(
|
||||
label=label,
|
||||
action=PresentationAction(
|
||||
type=ActionType.CALLBACK,
|
||||
value=_encode_select_callback(action_id, option.value),
|
||||
),
|
||||
)
|
||||
)
|
||||
return PresentationBlock(type=BlockType.BUTTONS, buttons=buttons)
|
||||
|
||||
|
||||
def adapt_presentation(
|
||||
presentation: MessagePresentation,
|
||||
limits: PresentationLimits,
|
||||
) -> MessagePresentation:
|
||||
"""Return a copy of ``presentation`` guaranteed to satisfy ``limits``.
|
||||
|
||||
This is the single, channel-agnostic adaptation pass that channel
|
||||
renderers should run before mapping a presentation to native widgets.
|
||||
It performs:
|
||||
|
||||
1. Priority-aware button truncation: when a buttons block exceeds
|
||||
``max_buttons`` (or the implied ``max_buttons * max_button_rows``
|
||||
cap), the lowest-``priority`` buttons are dropped first (highest
|
||||
survives), preserving original order among kept buttons.
|
||||
2. Label truncation to ``max_button_label`` / ``max_option_label``.
|
||||
3. Option truncation to ``max_options``.
|
||||
4. Capability degradation: ``select`` blocks become button rows when
|
||||
``supports_select`` is False; ``web_app`` actions become URLs when
|
||||
``supports_web_apps`` is False.
|
||||
|
||||
The input presentation is never mutated.
|
||||
|
||||
Args:
|
||||
presentation: The portable presentation to adapt.
|
||||
limits: The target channel's capability limits.
|
||||
|
||||
Returns:
|
||||
A new ``MessagePresentation`` that is safe to render natively.
|
||||
"""
|
||||
adapted_blocks: List[PresentationBlock] = []
|
||||
|
||||
for block in presentation.blocks:
|
||||
block_type = block.type.value if isinstance(block.type, BlockType) else block.type
|
||||
|
||||
if block_type == BlockType.SELECT.value and not limits.supports_select:
|
||||
# Degrade select -> buttons, then adapt the resulting buttons block
|
||||
block = _select_to_buttons(block)
|
||||
block_type = BlockType.BUTTONS.value
|
||||
|
||||
if block_type == BlockType.BUTTONS.value and block.buttons:
|
||||
# Cap total buttons by max_buttons * max_button_rows (interpreting
|
||||
# max_buttons as per-row capacity), falling back to max_buttons.
|
||||
rows = limits.max_button_rows if limits.max_button_rows else 1
|
||||
total_cap = limits.max_buttons * rows if limits.max_buttons else len(block.buttons)
|
||||
if total_cap <= 0:
|
||||
total_cap = len(block.buttons)
|
||||
|
||||
buttons = list(block.buttons)
|
||||
if len(buttons) > total_cap:
|
||||
# Keep highest-priority buttons; preserve original order among kept.
|
||||
indexed = list(enumerate(buttons))
|
||||
kept = sorted(
|
||||
indexed,
|
||||
key=lambda iv: (iv[1].priority, -iv[0]),
|
||||
reverse=True,
|
||||
)[:total_cap]
|
||||
kept.sort(key=lambda iv: iv[0])
|
||||
buttons = [b for _, b in kept]
|
||||
|
||||
adapted_buttons = [_adapt_button(b, limits) for b in buttons]
|
||||
adapted_blocks.append(
|
||||
PresentationBlock(type=BlockType.BUTTONS, buttons=adapted_buttons)
|
||||
)
|
||||
continue
|
||||
|
||||
if block_type == BlockType.SELECT.value and block.options:
|
||||
options = block.options
|
||||
if limits.max_options and len(options) > limits.max_options:
|
||||
options = options[: limits.max_options]
|
||||
new_options: List[SelectOption] = []
|
||||
for option in options:
|
||||
label = option.label
|
||||
if limits.max_option_label and len(label) > limits.max_option_label:
|
||||
label = label[: limits.max_option_label]
|
||||
new_options.append(
|
||||
SelectOption(
|
||||
label=label,
|
||||
value=option.value,
|
||||
description=option.description,
|
||||
emoji=option.emoji,
|
||||
default=option.default,
|
||||
)
|
||||
)
|
||||
adapted_blocks.append(
|
||||
PresentationBlock(
|
||||
type=BlockType.SELECT,
|
||||
options=new_options,
|
||||
placeholder=block.placeholder,
|
||||
action_id=block.action_id,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if block_type == BlockType.TEXT.value or block_type == BlockType.CONTEXT.value:
|
||||
text = block.text
|
||||
if text is not None and limits.max_text_length and len(text) > limits.max_text_length:
|
||||
text = text[: limits.max_text_length]
|
||||
adapted_blocks.append(
|
||||
PresentationBlock(type=block.type, text=text)
|
||||
)
|
||||
continue
|
||||
|
||||
adapted_blocks.append(block)
|
||||
|
||||
return MessagePresentation(
|
||||
blocks=adapted_blocks,
|
||||
tone=presentation.tone,
|
||||
ephemeral=presentation.ephemeral,
|
||||
replace_message_id=presentation.replace_message_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from typing import (
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Mapping,
|
||||
Optional,
|
||||
Protocol,
|
||||
TypedDict,
|
||||
@@ -85,6 +86,24 @@ class PlatformCapabilities:
|
||||
max_files_per_message: Maximum number of file attachments per message
|
||||
max_file_size_mb: Maximum file size in megabytes
|
||||
supported_file_types: List of supported file extensions/mime types
|
||||
accepts_webhooks: Whether the platform delivers inbound messages via webhook
|
||||
verifies_webhook_signature: Whether the adapter verifies inbound webhook
|
||||
authenticity. Adapters that accept webhooks MUST set this True and
|
||||
expose a ``webhook_verifier`` so central ingress can enforce it
|
||||
fail-closed.
|
||||
reconciles_unknown_send: Whether the adapter can confirm whether a prior
|
||||
send attempt actually landed (e.g. via a provider message-status
|
||||
lookup keyed on the idempotency key). When True the durable outbox
|
||||
reconciles an in-flight (``sending``) entry on restart before
|
||||
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_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
|
||||
descriptor only; the durable outbox does not forward a token on
|
||||
resend, so adapters relying on provider-side dedupe should also set
|
||||
``reconciles_unknown_send`` to get effectively-once delivery.
|
||||
"""
|
||||
|
||||
max_message_length: int = 4096
|
||||
@@ -97,6 +116,10 @@ class PlatformCapabilities:
|
||||
max_files_per_message: int = 1
|
||||
max_file_size_mb: int = 10
|
||||
supported_file_types: List[str] = field(default_factory=lambda: ["*"])
|
||||
accepts_webhooks: bool = False
|
||||
verifies_webhook_signature: bool = False
|
||||
reconciles_unknown_send: bool = False
|
||||
supports_idempotency_token: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -111,6 +134,10 @@ class PlatformCapabilities:
|
||||
"max_files_per_message": self.max_files_per_message,
|
||||
"max_file_size_mb": self.max_file_size_mb,
|
||||
"supported_file_types": self.supported_file_types,
|
||||
"accepts_webhooks": self.accepts_webhooks,
|
||||
"verifies_webhook_signature": self.verifies_webhook_signature,
|
||||
"reconciles_unknown_send": self.reconciles_unknown_send,
|
||||
"supports_idempotency_token": self.supports_idempotency_token,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -127,9 +154,46 @@ class PlatformCapabilities:
|
||||
max_files_per_message=data.get("max_files_per_message", 1),
|
||||
max_file_size_mb=data.get("max_file_size_mb", 10),
|
||||
supported_file_types=data.get("supported_file_types", ["*"]),
|
||||
accepts_webhooks=data.get("accepts_webhooks", False),
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class WebhookVerifierProtocol(Protocol):
|
||||
"""Protocol for verifying inbound webhook authenticity.
|
||||
|
||||
Internet-facing bot adapters (Slack, WhatsApp, Linear, AgentMail, …)
|
||||
receive webhooks that must be authenticated before they become an agent
|
||||
run. This protocol declares the contract so the wrapper ingress can
|
||||
enforce verification centrally and fail-closed: an adapter that declares
|
||||
``PlatformCapabilities.accepts_webhooks`` must expose a verifier and that
|
||||
verifier must pass before dispatch.
|
||||
|
||||
Implementations live in the ``praisonai`` wrapper package and typically
|
||||
wrap an HMAC comparison over the raw request body.
|
||||
|
||||
Example:
|
||||
class SlackVerifier:
|
||||
def verify(self, *, headers, raw_body):
|
||||
...
|
||||
"""
|
||||
|
||||
def verify(self, *, headers: Mapping[str, str], raw_body: bytes) -> bool:
|
||||
"""Return True if the inbound webhook is authentic.
|
||||
|
||||
Args:
|
||||
headers: Inbound request headers (case-insensitive mapping).
|
||||
raw_body: The exact raw request body bytes (pre-parse).
|
||||
|
||||
Returns:
|
||||
True if the signature/secret verifies, False otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class MessageType(str, Enum):
|
||||
"""Types of bot messages."""
|
||||
|
||||
|
||||
@@ -92,9 +92,25 @@ class CliBackendProtocol(Protocol):
|
||||
|
||||
Any object implementing these methods can serve as an Agent backend.
|
||||
Follows typing.Protocol pattern from AGENTS.md.
|
||||
|
||||
Note: Starting with runtime capability system, all CLI backends must
|
||||
declare their capabilities via the capabilities() method for compatibility
|
||||
validation at config/selection time.
|
||||
"""
|
||||
config: CliBackendConfig
|
||||
|
||||
def capabilities(self) -> "RuntimeCapabilityMatrix": # type: ignore
|
||||
"""Report capabilities supported by this CLI backend.
|
||||
|
||||
Required for integration with the runtime capability validation system.
|
||||
CLI backends should honestly declare their capabilities to enable
|
||||
fail-fast validation when agents require specific features.
|
||||
|
||||
Returns:
|
||||
RuntimeCapabilityMatrix with supported capabilities
|
||||
"""
|
||||
...
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
prompt: str,
|
||||
|
||||
@@ -41,6 +41,7 @@ __all__ = [
|
||||
"HooksConfig",
|
||||
"SkillsConfig",
|
||||
"ToolSearchConfig",
|
||||
"RuntimeConfig",
|
||||
# Type aliases
|
||||
"MemoryParam",
|
||||
"KnowledgeParam",
|
||||
@@ -48,6 +49,7 @@ __all__ = [
|
||||
"ReflectionParam",
|
||||
"GuardrailParam",
|
||||
"WebParam",
|
||||
"RuntimeParam",
|
||||
# Resolver
|
||||
"resolve",
|
||||
"ArrayMode",
|
||||
@@ -65,6 +67,7 @@ __all__ = [
|
||||
"resolve_hooks",
|
||||
"resolve_skills",
|
||||
"resolve_routing",
|
||||
"resolve_runtime",
|
||||
# Parse utilities
|
||||
"detect_url_scheme",
|
||||
"is_path_like",
|
||||
@@ -126,12 +129,14 @@ _MODULE_MAP = {
|
||||
"HooksConfig": "feature_configs",
|
||||
"SkillsConfig": "feature_configs",
|
||||
"ToolSearchConfig": "feature_configs",
|
||||
"RuntimeConfig": "feature_configs",
|
||||
"MemoryParam": "feature_configs",
|
||||
"KnowledgeParam": "feature_configs",
|
||||
"PlanningParam": "feature_configs",
|
||||
"ReflectionParam": "feature_configs",
|
||||
"GuardrailParam": "feature_configs",
|
||||
"WebParam": "feature_configs",
|
||||
"RuntimeParam": "feature_configs",
|
||||
# param_resolver
|
||||
"resolve": "param_resolver",
|
||||
"ArrayMode": "param_resolver",
|
||||
@@ -148,6 +153,7 @@ _MODULE_MAP = {
|
||||
"resolve_hooks": "param_resolver",
|
||||
"resolve_skills": "param_resolver",
|
||||
"resolve_routing": "param_resolver",
|
||||
"resolve_runtime": "feature_configs",
|
||||
# parse_utils
|
||||
"detect_url_scheme": "parse_utils",
|
||||
"is_path_like": "parse_utils",
|
||||
|
||||
@@ -42,6 +42,7 @@ from ..agent.autonomy import AutonomyConfig
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from ..compaction.strategy import CompactionStrategy
|
||||
from ..context.artifact_store import FileSystemArtifactStore
|
||||
|
||||
|
||||
class MemoryBackend(str, Enum):
|
||||
@@ -144,6 +145,10 @@ class LearnConfig:
|
||||
db_url: Optional[str] = None # Database connection URL (for non-file backends)
|
||||
store_path: Optional[str] = None # Custom storage path (for file backend)
|
||||
|
||||
# Retention / lifecycle governance (0 = unbounded; backward-compatible default)
|
||||
max_entries: int = 0 # Per-store cap; least-used/oldest archived beyond it
|
||||
retention_days: int = 0 # Archive entries unused for this many days (staleness)
|
||||
|
||||
# LLM for learning extraction
|
||||
llm: Optional[str] = None # LLM model for extracting learnings (defaults to agent's LLM)
|
||||
|
||||
@@ -165,6 +170,8 @@ class LearnConfig:
|
||||
"backend": self.backend.value if isinstance(self.backend, LearnBackend) else self.backend,
|
||||
"db_url": self.db_url,
|
||||
"store_path": self.store_path,
|
||||
"max_entries": self.max_entries,
|
||||
"retention_days": self.retention_days,
|
||||
"llm": self.llm,
|
||||
}
|
||||
|
||||
@@ -738,6 +745,16 @@ class ExecutionConfig:
|
||||
# Code execution sandbox mode - "sandbox" (default) uses subprocess isolation
|
||||
# "direct" runs in current process (legacy, less secure)
|
||||
code_sandbox_mode: str = "sandbox" # "sandbox" or "direct"
|
||||
|
||||
# Code-execution-with-tools (code mode): when True, model-generated code may
|
||||
# call the agent's registered tools directly via injected proxies, enabling
|
||||
# multi-step tool pipelines in a single turn (intermediate results stay out
|
||||
# of context). Opt-in and gated by code_tools_allow + the approval framework.
|
||||
code_tools: bool = False
|
||||
|
||||
# Explicit per-run allow-list of tool names callable from code when
|
||||
# code_tools is enabled. None/empty = no tools exposed (safe default).
|
||||
code_tools_allow: Optional[List[str]] = None
|
||||
|
||||
# Rate limiter instance (consolidated from standalone rate_limiter param)
|
||||
rate_limiter: Optional[Any] = None
|
||||
@@ -832,6 +849,8 @@ class ExecutionConfig:
|
||||
"code_execution": self.code_execution,
|
||||
"code_mode": self.code_mode,
|
||||
"code_sandbox_mode": self.code_sandbox_mode,
|
||||
"code_tools": self.code_tools,
|
||||
"code_tools_allow": self.code_tools_allow,
|
||||
"context_compaction": (
|
||||
self.context_compaction.to_dict()
|
||||
if hasattr(self.context_compaction, 'to_dict')
|
||||
@@ -860,6 +879,8 @@ class ExecutionConfig:
|
||||
code_execution=data.get("code_execution", False),
|
||||
code_mode=data.get("code_mode", "safe"),
|
||||
code_sandbox_mode=data.get("code_sandbox_mode", "docker"),
|
||||
code_tools=data.get("code_tools", False),
|
||||
code_tools_allow=data.get("code_tools_allow", None),
|
||||
context_compaction=context_compaction,
|
||||
max_context_tokens=data.get("max_context_tokens", None),
|
||||
max_budget=data.get("max_budget", None),
|
||||
@@ -935,7 +956,8 @@ class ToolConfig:
|
||||
"""
|
||||
Configuration for tool execution behavior.
|
||||
|
||||
Configuration for tool execution behavior including timeout, retry policy, and parallel execution.
|
||||
Configuration for tool execution behavior including timeout, retry policy, parallel execution,
|
||||
and artifact storage for large outputs.
|
||||
|
||||
Usage:
|
||||
# Simple enable with defaults
|
||||
@@ -948,8 +970,12 @@ class ToolConfig:
|
||||
parallel=True,
|
||||
))
|
||||
|
||||
# With timeout only
|
||||
Agent(tool_config=ToolConfig(timeout=30))
|
||||
# With artifact storage for large outputs
|
||||
Agent(tool_config=ToolConfig(
|
||||
output_limit=32000,
|
||||
enable_artifacts=True,
|
||||
artifact_retention_days=14,
|
||||
))
|
||||
"""
|
||||
# Tool execution timeout in seconds
|
||||
timeout: Optional[int] = None
|
||||
@@ -960,6 +986,26 @@ class ToolConfig:
|
||||
# Enable parallel execution of batched LLM tool calls
|
||||
parallel: bool = False
|
||||
|
||||
# Tool output handling and artifact storage
|
||||
output_limit: int = 16000 # Maximum bytes before spilling to artifact store
|
||||
output_max_lines: Optional[int] = None # Maximum lines before spilling
|
||||
output_direction: str = "both" # Truncation direction: "head", "tail", or "both"
|
||||
enable_artifacts: bool = False # Whether to enable artifact storage (default False for backward compat)
|
||||
artifact_retention_days: int = 7 # Days to retain artifacts before garbage collection
|
||||
artifact_store: Optional[Any] = None # Custom artifact store instance
|
||||
redact_secrets: bool = True # Whether to redact secrets from artifacts
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate configuration after initialization."""
|
||||
if self.output_limit <= 0:
|
||||
raise ValueError("tool_config.output_limit must be > 0")
|
||||
if self.output_max_lines is not None and self.output_max_lines <= 0:
|
||||
raise ValueError("tool_config.output_max_lines must be > 0 when provided")
|
||||
if self.output_direction not in {"head", "tail", "both"}:
|
||||
raise ValueError("tool_config.output_direction must be one of: 'head', 'tail', 'both'")
|
||||
if self.artifact_retention_days < 0:
|
||||
raise ValueError("tool_config.artifact_retention_days must be >= 0")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
@@ -970,6 +1016,13 @@ class ToolConfig:
|
||||
else self.retry_policy
|
||||
),
|
||||
"parallel": self.parallel,
|
||||
"output_limit": self.output_limit,
|
||||
"output_max_lines": self.output_max_lines,
|
||||
"output_direction": self.output_direction,
|
||||
"enable_artifacts": self.enable_artifacts,
|
||||
"artifact_retention_days": self.artifact_retention_days,
|
||||
"artifact_store": self.artifact_store,
|
||||
"redact_secrets": self.redact_secrets,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -988,6 +1041,13 @@ class ToolConfig:
|
||||
timeout=data.get("timeout"),
|
||||
retry_policy=retry_policy,
|
||||
parallel=data.get("parallel", False),
|
||||
output_limit=data.get("output_limit", 16000),
|
||||
output_max_lines=data.get("output_max_lines"),
|
||||
output_direction=data.get("output_direction", "both"),
|
||||
enable_artifacts=data.get("enable_artifacts", False),
|
||||
artifact_retention_days=data.get("artifact_retention_days", 7),
|
||||
artifact_store=data.get("artifact_store"),
|
||||
redact_secrets=data.get("redact_secrets", True),
|
||||
)
|
||||
|
||||
|
||||
@@ -1513,6 +1573,99 @@ def resolve_tools(value: ToolParam) -> Optional[ToolConfig]:
|
||||
return value
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeConfig:
|
||||
"""
|
||||
Configuration for agent runtime capabilities and requirements.
|
||||
|
||||
Used to declare what capabilities an agent requires and validate
|
||||
against runtime implementations at config/selection time.
|
||||
|
||||
Usage:
|
||||
# Agent that requires native hooks and streaming
|
||||
Agent(
|
||||
instructions="...",
|
||||
runtime=RuntimeConfig(
|
||||
required_capabilities={"native_hooks", "streaming_deltas"}
|
||||
)
|
||||
)
|
||||
|
||||
# Agent with runtime preference
|
||||
Agent(
|
||||
runtime=RuntimeConfig(
|
||||
preferred_runtime="native",
|
||||
required_capabilities={"tool_loop", "mcp_tools"},
|
||||
fallback_allowed=True
|
||||
)
|
||||
)
|
||||
"""
|
||||
# Required capabilities for this agent (capability names or enum values)
|
||||
required_capabilities: Optional[Union[List[str], FrozenSet[str], List[Any], FrozenSet[Any]]] = None
|
||||
|
||||
# Preferred runtime implementation name
|
||||
preferred_runtime: Optional[str] = None
|
||||
|
||||
# Whether to allow fallback to other runtimes if preferred is unavailable
|
||||
fallback_allowed: bool = True
|
||||
|
||||
# Fail fast validation (validate at agent creation vs first execution)
|
||||
validate_on_creation: bool = True
|
||||
|
||||
# Additional runtime metadata/hints
|
||||
metadata: Optional[Dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Normalize required_capabilities to a list."""
|
||||
if self.required_capabilities is not None:
|
||||
if isinstance(self.required_capabilities, str):
|
||||
self.required_capabilities = [self.required_capabilities]
|
||||
else:
|
||||
try:
|
||||
self.required_capabilities = list(self.required_capabilities)
|
||||
except TypeError:
|
||||
self.required_capabilities = [self.required_capabilities]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
req_caps = None
|
||||
if self.required_capabilities:
|
||||
req_caps = [
|
||||
cap.name.lower() if hasattr(cap, "name") else str(cap)
|
||||
for cap in self.required_capabilities
|
||||
]
|
||||
return {
|
||||
"required_capabilities": req_caps,
|
||||
"preferred_runtime": self.preferred_runtime,
|
||||
"fallback_allowed": self.fallback_allowed,
|
||||
"validate_on_creation": self.validate_on_creation,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
# Type aliases for runtime parameters
|
||||
RuntimeParam = Union[bool, str, Dict[str, Any], RuntimeConfig, None]
|
||||
|
||||
|
||||
def resolve_runtime(value: RuntimeParam) -> Optional[RuntimeConfig]:
|
||||
"""Resolve runtime= parameter following precedence ladder."""
|
||||
if value is None or value is False:
|
||||
return None
|
||||
if value is True:
|
||||
return RuntimeConfig()
|
||||
if isinstance(value, str):
|
||||
return RuntimeConfig(preferred_runtime=value)
|
||||
if isinstance(value, (list, set, tuple, frozenset)):
|
||||
return RuntimeConfig(required_capabilities=list(value))
|
||||
if isinstance(value, dict):
|
||||
return RuntimeConfig(**value)
|
||||
if isinstance(value, RuntimeConfig):
|
||||
return value
|
||||
raise TypeError(
|
||||
f"Invalid runtime parameter type: {type(value).__name__}. "
|
||||
"Expected None/False, True, str, list, set, dict, or RuntimeConfig."
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Enums
|
||||
"MemoryBackend",
|
||||
@@ -1540,6 +1693,7 @@ __all__ = [
|
||||
"SkillsConfig",
|
||||
"AutonomyConfig",
|
||||
"ToolSearchConfig",
|
||||
"RuntimeConfig",
|
||||
# Config classes (Multi-Agent)
|
||||
"MultiAgentHooksConfig",
|
||||
"MultiAgentOutputConfig",
|
||||
@@ -1562,6 +1716,7 @@ __all__ = [
|
||||
"AutonomyParam",
|
||||
"ToolSearchParam",
|
||||
"ToolParam",
|
||||
"RuntimeParam",
|
||||
# Precedence ladder resolvers
|
||||
"resolve_memory",
|
||||
"resolve_knowledge",
|
||||
@@ -1573,4 +1728,5 @@ __all__ = [
|
||||
"resolve_autonomy",
|
||||
"resolve_tool_search",
|
||||
"resolve_tools",
|
||||
"resolve_runtime",
|
||||
]
|
||||
@@ -107,12 +107,26 @@ def is_path_like(value: str) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
|
||||
# Check for path indicators
|
||||
if value.startswith(("./", "../", "/", "~/")):
|
||||
# Check for path indicators (POSIX and Windows-style prefixes)
|
||||
if value.startswith(("./", "../", "/", "~/", ".\\", "..\\", "\\\\", "\\", "~\\")):
|
||||
return True
|
||||
|
||||
|
||||
# Check for Windows drive paths like C:\data or C:/data
|
||||
if (
|
||||
len(value) >= 3
|
||||
and value[1] == ":"
|
||||
and value[0].isalpha()
|
||||
and value[2] in ("\\", "/")
|
||||
):
|
||||
return True
|
||||
|
||||
# Check for directory indicator
|
||||
if value.endswith("/"):
|
||||
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
|
||||
|
||||
# Check for file extension (common ones)
|
||||
|
||||
@@ -180,6 +180,7 @@ __all__ = [
|
||||
"TerminalLoggerProtocol",
|
||||
"compute_checksum",
|
||||
"generate_summary",
|
||||
"FileSystemArtifactStore",
|
||||
# Context Compaction Policy
|
||||
"ContextCompactionPolicy",
|
||||
"ContextCompactionPolicyProtocol",
|
||||
@@ -295,6 +296,11 @@ def __getattr__(name: str):
|
||||
from . import artifacts
|
||||
return getattr(artifacts, name)
|
||||
|
||||
# FileSystemArtifactStore
|
||||
if name == "FileSystemArtifactStore":
|
||||
from .artifact_store import FileSystemArtifactStore
|
||||
return FileSystemArtifactStore
|
||||
|
||||
# Session Context Tracking (Agno pattern)
|
||||
if name in ("SessionContextTracker", "SessionState"):
|
||||
from . import session_tracker
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""
|
||||
Concrete implementation of ArtifactStoreProtocol for filesystem storage.
|
||||
|
||||
This module provides a filesystem-based artifact store that persists
|
||||
tool outputs to disk when they exceed configured size limits.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from .artifacts import (
|
||||
ArtifactRef,
|
||||
ArtifactMetadata,
|
||||
ArtifactStoreProtocol,
|
||||
GrepMatch,
|
||||
compute_checksum,
|
||||
generate_summary,
|
||||
)
|
||||
|
||||
|
||||
class FileSystemArtifactStore:
|
||||
"""
|
||||
Filesystem-based artifact storage implementation.
|
||||
|
||||
Stores artifacts under ~/.praisonai/artifacts/{agent_id}/{run_id}/
|
||||
with content-addressed naming using SHA256 hashes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_dir: Optional[str] = None,
|
||||
retention_days: int = 7,
|
||||
redact_secrets: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize the filesystem artifact store.
|
||||
|
||||
Args:
|
||||
base_dir: Base directory for storage (default: ~/.praisonai/artifacts)
|
||||
retention_days: Days to retain artifacts before GC
|
||||
redact_secrets: Whether to redact secrets from stored content
|
||||
"""
|
||||
if base_dir:
|
||||
self.base_dir = Path(base_dir)
|
||||
else:
|
||||
# Use standard PraisonAI data directory
|
||||
home = Path.home()
|
||||
self.base_dir = home / ".praisonai" / "artifacts"
|
||||
|
||||
self.retention_days = retention_days
|
||||
self.redact_secrets = redact_secrets
|
||||
self._secret_patterns = [
|
||||
r'(?i)(api[_-]?key|apikey)["\']?\s*[:=]\s*["\']?[\w\-]+',
|
||||
r'(?i)(secret|password|passwd|pwd)["\']?\s*[:=]\s*["\']?[\w\-]+',
|
||||
r'(?i)(token|bearer)["\']?\s*[:=]\s*["\']?[\w\-]+',
|
||||
r'sk-[a-zA-Z0-9]{20,}', # OpenAI keys
|
||||
r'ghp_[a-zA-Z0-9]{36}', # GitHub tokens
|
||||
]
|
||||
|
||||
# Ensure base directory exists
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def store(
|
||||
self,
|
||||
content: Any,
|
||||
metadata: ArtifactMetadata,
|
||||
) -> ArtifactRef:
|
||||
"""
|
||||
Store content as an artifact.
|
||||
|
||||
Args:
|
||||
content: The content to store (will be serialized)
|
||||
metadata: Metadata about the artifact
|
||||
|
||||
Returns:
|
||||
ArtifactRef pointing to the stored artifact
|
||||
"""
|
||||
# Serialize content and prepare for redaction
|
||||
if isinstance(content, (str, bytes)):
|
||||
content_str = content if isinstance(content, str) else None
|
||||
content_bytes = content.encode("utf-8") if isinstance(content, str) else content
|
||||
mime_type = "text/plain"
|
||||
else:
|
||||
# JSON serialize structured data
|
||||
content_str = json.dumps(content, indent=2, default=str)
|
||||
content_bytes = content_str.encode("utf-8")
|
||||
mime_type = "application/json"
|
||||
|
||||
# Redact secrets if enabled (for text and JSON content)
|
||||
if self.redact_secrets and content_str:
|
||||
for pattern in self._secret_patterns:
|
||||
content_str = re.sub(pattern, "[REDACTED]", content_str)
|
||||
content_bytes = content_str.encode("utf-8")
|
||||
|
||||
# Compute checksum
|
||||
checksum = compute_checksum(content_bytes)
|
||||
|
||||
# Generate artifact ID
|
||||
artifact_id = f"{checksum[:12]}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create directory structure
|
||||
artifact_dir = self.base_dir / metadata.agent_id / metadata.run_id
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write content to file
|
||||
artifact_path = artifact_dir / f"{artifact_id}.artifact"
|
||||
artifact_path.write_bytes(content_bytes)
|
||||
|
||||
# Write metadata
|
||||
meta_path = artifact_dir / f"{artifact_id}.meta.json"
|
||||
meta_data = metadata.to_dict()
|
||||
meta_data["checksum"] = checksum
|
||||
meta_data["size_bytes"] = len(content_bytes)
|
||||
meta_data["mime_type"] = mime_type
|
||||
meta_data["created_at"] = time.time()
|
||||
meta_path.write_text(json.dumps(meta_data, indent=2))
|
||||
|
||||
# Generate summary (use redacted content if available)
|
||||
summary_source = content_str if content_str else str(content)
|
||||
summary = generate_summary(summary_source, max_chars=200)
|
||||
|
||||
# Create and return reference
|
||||
ref = ArtifactRef(
|
||||
path=str(artifact_path),
|
||||
summary=summary,
|
||||
size_bytes=len(content_bytes),
|
||||
mime_type=mime_type,
|
||||
checksum=checksum,
|
||||
created_at=meta_data["created_at"],
|
||||
artifact_id=artifact_id,
|
||||
agent_id=metadata.agent_id,
|
||||
run_id=metadata.run_id,
|
||||
tool_name=metadata.tool_name,
|
||||
turn_id=metadata.turn_id,
|
||||
)
|
||||
|
||||
logging.debug(f"Stored artifact {artifact_id} ({len(content_bytes)} bytes) at {artifact_path}")
|
||||
|
||||
return ref
|
||||
|
||||
def _validate_artifact_path(self, path_or_ref: Union[Path, ArtifactRef]) -> Path:
|
||||
"""Validate that path is within the artifact store's base directory."""
|
||||
if isinstance(path_or_ref, ArtifactRef):
|
||||
path = Path(path_or_ref.path)
|
||||
else:
|
||||
path = path_or_ref
|
||||
|
||||
resolved = path.expanduser().resolve()
|
||||
base_resolved = self.base_dir.expanduser().resolve()
|
||||
|
||||
# Check if path is within base directory
|
||||
try:
|
||||
resolved.relative_to(base_resolved)
|
||||
except ValueError:
|
||||
raise PermissionError(f"Artifact path is outside configured store: {path}")
|
||||
|
||||
# Validate extension
|
||||
if resolved.suffix != ".artifact":
|
||||
raise ValueError(f"Invalid artifact file extension: {path}")
|
||||
|
||||
return resolved
|
||||
|
||||
def load(self, ref: ArtifactRef) -> Any:
|
||||
"""
|
||||
Load full content from an artifact.
|
||||
|
||||
Args:
|
||||
ref: Reference to the artifact
|
||||
|
||||
Returns:
|
||||
The deserialized content
|
||||
"""
|
||||
path = self._validate_artifact_path(Path(ref.path))
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Artifact not found: {ref.path}")
|
||||
|
||||
content_bytes = path.read_bytes()
|
||||
|
||||
# Verify checksum if provided
|
||||
if ref.checksum:
|
||||
actual_checksum = compute_checksum(content_bytes)
|
||||
if actual_checksum != ref.checksum:
|
||||
raise ValueError(f"Checksum mismatch for artifact {ref.artifact_id}")
|
||||
|
||||
# Deserialize based on mime type
|
||||
if ref.mime_type == "application/json":
|
||||
return json.loads(content_bytes.decode("utf-8"))
|
||||
else:
|
||||
return content_bytes.decode("utf-8")
|
||||
|
||||
def tail(self, ref: ArtifactRef, lines: int = 50) -> str:
|
||||
"""
|
||||
Get the last N lines of an artifact.
|
||||
|
||||
Args:
|
||||
ref: Reference to the artifact
|
||||
lines: Number of lines to return
|
||||
|
||||
Returns:
|
||||
String containing the last N lines
|
||||
"""
|
||||
path = self._validate_artifact_path(Path(ref.path))
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Artifact not found: {ref.path}")
|
||||
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
content_lines = content.splitlines()
|
||||
|
||||
if len(content_lines) <= lines:
|
||||
return content
|
||||
|
||||
return "\n".join(content_lines[-lines:])
|
||||
|
||||
def head(self, ref: ArtifactRef, lines: int = 50) -> str:
|
||||
"""
|
||||
Get the first N lines of an artifact.
|
||||
|
||||
Args:
|
||||
ref: Reference to the artifact
|
||||
lines: Number of lines to return
|
||||
|
||||
Returns:
|
||||
String containing the first N lines
|
||||
"""
|
||||
path = self._validate_artifact_path(Path(ref.path))
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Artifact not found: {ref.path}")
|
||||
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
content_lines = content.splitlines()
|
||||
|
||||
if len(content_lines) <= lines:
|
||||
return content
|
||||
|
||||
return "\n".join(content_lines[:lines])
|
||||
|
||||
def grep(
|
||||
self,
|
||||
ref: ArtifactRef,
|
||||
pattern: str,
|
||||
context_lines: int = 2,
|
||||
max_matches: int = 50,
|
||||
) -> List[GrepMatch]:
|
||||
"""
|
||||
Search for pattern in artifact content.
|
||||
|
||||
Args:
|
||||
ref: Reference to the artifact
|
||||
pattern: Regex pattern to search for
|
||||
context_lines: Number of context lines before/after match
|
||||
max_matches: Maximum number of matches to return
|
||||
|
||||
Returns:
|
||||
List of GrepMatch objects
|
||||
"""
|
||||
path = self._validate_artifact_path(Path(ref.path))
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Artifact not found: {ref.path}")
|
||||
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
lines = content.splitlines()
|
||||
|
||||
matches = []
|
||||
pattern_re = re.compile(pattern, re.IGNORECASE)
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if pattern_re.search(line):
|
||||
# Get context lines
|
||||
start_idx = max(0, i - context_lines)
|
||||
end_idx = min(len(lines), i + context_lines + 1)
|
||||
|
||||
match = GrepMatch(
|
||||
line_number=i + 1, # 1-indexed
|
||||
line_content=line,
|
||||
context_before=lines[start_idx:i],
|
||||
context_after=lines[i + 1:end_idx],
|
||||
)
|
||||
matches.append(match)
|
||||
|
||||
if len(matches) >= max_matches:
|
||||
break
|
||||
|
||||
return matches
|
||||
|
||||
def chunk(
|
||||
self,
|
||||
ref: ArtifactRef,
|
||||
start_line: int = 1,
|
||||
end_line: Optional[int] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Get a chunk of lines from an artifact.
|
||||
|
||||
Args:
|
||||
ref: Reference to the artifact
|
||||
start_line: Starting line number (1-indexed)
|
||||
end_line: Ending line number (inclusive), None for end of file
|
||||
|
||||
Returns:
|
||||
String containing the requested lines
|
||||
"""
|
||||
path = self._validate_artifact_path(Path(ref.path))
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Artifact not found: {ref.path}")
|
||||
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
lines = content.splitlines()
|
||||
|
||||
# Convert to 0-indexed
|
||||
start_idx = max(0, start_line - 1)
|
||||
end_idx = end_line if end_line is None else end_line
|
||||
|
||||
return "\n".join(lines[start_idx:end_idx])
|
||||
|
||||
def delete(self, ref: ArtifactRef) -> bool:
|
||||
"""
|
||||
Delete an artifact.
|
||||
|
||||
Args:
|
||||
ref: Reference to the artifact
|
||||
|
||||
Returns:
|
||||
True if deleted successfully
|
||||
"""
|
||||
path = self._validate_artifact_path(Path(ref.path))
|
||||
meta_path = path.with_suffix(".meta.json")
|
||||
|
||||
deleted = False
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
deleted = True
|
||||
|
||||
if meta_path.exists():
|
||||
meta_path.unlink()
|
||||
|
||||
return deleted
|
||||
|
||||
def list_artifacts(
|
||||
self,
|
||||
run_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
tool_name: Optional[str] = None,
|
||||
) -> List[ArtifactRef]:
|
||||
"""
|
||||
List artifacts matching filters.
|
||||
|
||||
Args:
|
||||
run_id: Filter by run ID
|
||||
agent_id: Filter by agent ID
|
||||
tool_name: Filter by tool name
|
||||
|
||||
Returns:
|
||||
List of matching ArtifactRef objects
|
||||
"""
|
||||
artifacts = []
|
||||
|
||||
# Determine search path based on filters
|
||||
if agent_id and run_id:
|
||||
search_path = self.base_dir / agent_id / run_id
|
||||
if search_path.exists():
|
||||
search_paths = [search_path]
|
||||
else:
|
||||
search_paths = []
|
||||
elif agent_id:
|
||||
agent_path = self.base_dir / agent_id
|
||||
if agent_path.exists():
|
||||
search_paths = [p for p in agent_path.iterdir() if p.is_dir()]
|
||||
else:
|
||||
search_paths = []
|
||||
else:
|
||||
# Search all - but respect run_id filter if provided
|
||||
search_paths = []
|
||||
for agent_dir in self.base_dir.iterdir():
|
||||
if agent_dir.is_dir():
|
||||
for run_dir in agent_dir.iterdir():
|
||||
if run_dir.is_dir():
|
||||
# If run_id filter provided, only include matching runs
|
||||
if run_id is None or run_dir.name == run_id:
|
||||
search_paths.append(run_dir)
|
||||
|
||||
# Search for artifacts
|
||||
for dir_path in search_paths:
|
||||
for meta_file in dir_path.glob("*.meta.json"):
|
||||
try:
|
||||
meta_data = json.loads(meta_file.read_text())
|
||||
|
||||
# Apply tool_name filter if specified
|
||||
if tool_name and meta_data.get("tool_name") != tool_name:
|
||||
continue
|
||||
|
||||
# Reconstruct artifact path
|
||||
artifact_file = meta_file.with_suffix("").with_suffix(".artifact")
|
||||
if not artifact_file.exists():
|
||||
continue
|
||||
|
||||
# Create ArtifactRef
|
||||
ref = ArtifactRef(
|
||||
path=str(artifact_file),
|
||||
summary="", # Not stored in meta, would need to regenerate
|
||||
size_bytes=meta_data.get("size_bytes", 0),
|
||||
mime_type=meta_data.get("mime_type", "application/octet-stream"),
|
||||
checksum=meta_data.get("checksum", ""),
|
||||
created_at=meta_data.get("created_at", 0),
|
||||
artifact_id=artifact_file.stem,
|
||||
agent_id=meta_data.get("agent_id", ""),
|
||||
run_id=meta_data.get("run_id", ""),
|
||||
tool_name=meta_data.get("tool_name"),
|
||||
turn_id=meta_data.get("turn_id", 0),
|
||||
)
|
||||
artifacts.append(ref)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error loading artifact metadata from {meta_file}: {e}")
|
||||
|
||||
# Sort by creation time (newest first)
|
||||
artifacts.sort(key=lambda x: x.created_at, reverse=True)
|
||||
|
||||
return artifacts
|
||||
|
||||
def cleanup_old_artifacts(self, days: Optional[int] = None) -> int:
|
||||
"""
|
||||
Clean up artifacts older than retention period.
|
||||
|
||||
Args:
|
||||
days: Days to retain (overrides default retention_days)
|
||||
|
||||
Returns:
|
||||
Number of artifacts deleted
|
||||
"""
|
||||
days = self.retention_days if days is None else days
|
||||
cutoff_time = time.time() - (days * 24 * 60 * 60)
|
||||
deleted_count = 0
|
||||
|
||||
for artifact in self.list_artifacts():
|
||||
if artifact.created_at < cutoff_time:
|
||||
if self.delete(artifact):
|
||||
deleted_count += 1
|
||||
logging.debug(f"Deleted old artifact {artifact.artifact_id}")
|
||||
|
||||
return deleted_count
|
||||
@@ -314,7 +314,8 @@ def read_file(
|
||||
start_line: Optional[int] = None,
|
||||
end_line: Optional[int] = None,
|
||||
context_lines: int = 0,
|
||||
max_lines: int = 500
|
||||
max_lines: int = 500,
|
||||
workspace_root: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Read file contents with optional line range.
|
||||
|
||||
@@ -328,8 +329,17 @@ def read_file(
|
||||
Returns:
|
||||
Dictionary with content, line info, and metadata
|
||||
"""
|
||||
filepath = os.path.abspath(filepath)
|
||||
|
||||
from praisonaiagents.tools.path_safety import resolve_within_root
|
||||
|
||||
safe_path = resolve_within_root(filepath, workspace_root or os.getcwd())
|
||||
if safe_path is None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Path outside workspace: {filepath}",
|
||||
"path": filepath,
|
||||
}
|
||||
filepath = safe_path
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
return {
|
||||
"success": False,
|
||||
|
||||
@@ -18,6 +18,8 @@ from .protocols import (
|
||||
GatewayEvent,
|
||||
GatewayMessage,
|
||||
EventType,
|
||||
OperatorScope,
|
||||
GatewayCloseCode,
|
||||
# Push protocols and dataclasses
|
||||
PushChannelProtocol,
|
||||
PresenceProtocol,
|
||||
@@ -28,6 +30,28 @@ from .protocols import (
|
||||
# Home channel and delivery protocols
|
||||
HomeChannelRegistryProtocol,
|
||||
DeliveryResolverProtocol,
|
||||
# Agent-facing outbound messaging
|
||||
OutboundMessengerProtocol,
|
||||
DeliveryResult,
|
||||
TargetInfo,
|
||||
# Inbound route binding (Issue #2225)
|
||||
RouteBinding,
|
||||
RouteFacts,
|
||||
RouteMatch,
|
||||
resolve_route,
|
||||
# Per-route, trust-tiered toolset scoping (Issue #2298)
|
||||
ToolPolicy,
|
||||
UNTRUSTED_DENY_SUBSTRINGS,
|
||||
TRUST_TIERS,
|
||||
# Outbound send-policy guard (Issue #2226)
|
||||
SendDecision,
|
||||
SendPolicyProtocol,
|
||||
SendPolicy,
|
||||
# Gateway idle-dormancy / scale-to-zero (Issue #2332)
|
||||
IdleDecision,
|
||||
GatewayIdlePolicyProtocol,
|
||||
GatewayIdlePolicy, # backward-compat alias
|
||||
ScaleToZeroPolicy,
|
||||
# Protocol version negotiation
|
||||
PROTOCOL_VERSION,
|
||||
MIN_PROTOCOL_VERSION,
|
||||
@@ -37,6 +61,13 @@ from .protocols import (
|
||||
GapInfo,
|
||||
ResumeSnapshot,
|
||||
)
|
||||
from .hooks import (
|
||||
HookAction,
|
||||
HookConfig,
|
||||
InboundTriggerProtocol,
|
||||
render_template,
|
||||
compute_idempotency_key,
|
||||
)
|
||||
from .config import (
|
||||
GatewayConfig,
|
||||
SessionConfig,
|
||||
@@ -97,6 +128,8 @@ __all__ = [
|
||||
"GatewayEvent",
|
||||
"GatewayMessage",
|
||||
"EventType",
|
||||
"OperatorScope",
|
||||
"GatewayCloseCode",
|
||||
# Push protocols (always available)
|
||||
"PushChannelProtocol",
|
||||
"PresenceProtocol",
|
||||
@@ -107,6 +140,28 @@ __all__ = [
|
||||
# Home channel and delivery protocols
|
||||
"HomeChannelRegistryProtocol",
|
||||
"DeliveryResolverProtocol",
|
||||
# Agent-facing outbound messaging
|
||||
"OutboundMessengerProtocol",
|
||||
"DeliveryResult",
|
||||
"TargetInfo",
|
||||
# Inbound route binding (Issue #2225)
|
||||
"RouteBinding",
|
||||
"RouteFacts",
|
||||
"RouteMatch",
|
||||
"resolve_route",
|
||||
# Per-route, trust-tiered toolset scoping (Issue #2298)
|
||||
"ToolPolicy",
|
||||
"UNTRUSTED_DENY_SUBSTRINGS",
|
||||
"TRUST_TIERS",
|
||||
# Outbound send-policy guard (Issue #2226)
|
||||
"SendDecision",
|
||||
"SendPolicyProtocol",
|
||||
"SendPolicy",
|
||||
# Gateway idle-dormancy / scale-to-zero (Issue #2332)
|
||||
"IdleDecision",
|
||||
"GatewayIdlePolicyProtocol",
|
||||
"GatewayIdlePolicy",
|
||||
"ScaleToZeroPolicy",
|
||||
# Protocol version negotiation
|
||||
"PROTOCOL_VERSION",
|
||||
"MIN_PROTOCOL_VERSION",
|
||||
@@ -115,6 +170,12 @@ __all__ = [
|
||||
"ProtocolHelloOk",
|
||||
"GapInfo",
|
||||
"ResumeSnapshot",
|
||||
# Inbound trigger / webhook contract (Issue #2281)
|
||||
"HookAction",
|
||||
"HookConfig",
|
||||
"InboundTriggerProtocol",
|
||||
"render_template",
|
||||
"compute_idempotency_key",
|
||||
# Config (always available)
|
||||
"GatewayConfig",
|
||||
"SessionConfig",
|
||||
|
||||
@@ -229,7 +229,15 @@ class GatewayConfig:
|
||||
ssl_cert: Path to SSL certificate (for HTTPS/WSS)
|
||||
ssl_key: Path to SSL key
|
||||
max_buffered_bytes: Maximum buffered bytes before slow consumer disconnect (default 1MB)
|
||||
max_queued_frames: Maximum queued outbound frames per client before slow
|
||||
consumer disconnect (0 = unlimited frame count; byte ceiling still
|
||||
applies). Default 1000.
|
||||
push: Push notification service configuration
|
||||
auth_scopes: Optional operator scope policy mapping token -> list of
|
||||
scope names (see OperatorScope). When None/empty (default), any
|
||||
successfully authenticated client is granted all scopes — identical
|
||||
to the previous binary behaviour. Single-operator setups are
|
||||
unaffected.
|
||||
"""
|
||||
|
||||
host: str = "127.0.0.1"
|
||||
@@ -246,7 +254,9 @@ class GatewayConfig:
|
||||
ssl_cert: Optional[str] = None
|
||||
ssl_key: Optional[str] = None
|
||||
max_buffered_bytes: int = 1024 * 1024 # 1MB default
|
||||
max_queued_frames: int = 1000 # Per-client outbound frame ceiling
|
||||
push: PushConfig = field(default_factory=PushConfig)
|
||||
auth_scopes: Optional[Dict[str, List[str]]] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Post-initialization to set bind_host from host if not specified and validate values."""
|
||||
@@ -256,13 +266,68 @@ class GatewayConfig:
|
||||
raise ValueError(
|
||||
"max_buffered_bytes must be >= 0 (use 0 to disable slow-consumer checks)"
|
||||
)
|
||||
if self.max_queued_frames < 0:
|
||||
raise ValueError(
|
||||
"max_queued_frames must be >= 0 (use 0 to disable the frame-count ceiling)"
|
||||
)
|
||||
if self.max_connections < 0:
|
||||
raise ValueError("max_connections must be >= 0")
|
||||
if self.heartbeat_interval < 0:
|
||||
raise ValueError("heartbeat_interval must be >= 0")
|
||||
if self.reconnect_timeout < 0:
|
||||
raise ValueError("reconnect_timeout must be >= 0")
|
||||
|
||||
|
||||
@property
|
||||
def has_scope_policy(self) -> bool:
|
||||
"""Whether an operator scope policy is configured.
|
||||
|
||||
When False (the default), every authenticated client is granted all
|
||||
scopes — preserving the original binary auth behaviour.
|
||||
"""
|
||||
return bool(self.auth_scopes)
|
||||
|
||||
def resolve_scopes(self, token: Optional[str]) -> List[str]:
|
||||
"""Resolve the operator scopes granted to ``token``.
|
||||
|
||||
Backward-compatible contract:
|
||||
* No scope policy configured -> all scopes (today's behaviour).
|
||||
* Policy configured + token listed -> that token's scopes.
|
||||
* Policy configured + token absent/None -> no scopes (deny).
|
||||
|
||||
Scope names are returned as plain strings so callers in the wrapper
|
||||
can compare against ``OperatorScope`` values without importing them.
|
||||
Unknown scope names (e.g. typos in ``gateway.yaml``) are dropped and a
|
||||
warning is logged so misconfiguration surfaces early instead of
|
||||
silently denying all access.
|
||||
"""
|
||||
from .protocols import OperatorScope
|
||||
|
||||
valid_scopes = {s.value for s in OperatorScope.all()}
|
||||
all_scopes = list(valid_scopes)
|
||||
if not self.auth_scopes:
|
||||
return all_scopes
|
||||
if token is None:
|
||||
return []
|
||||
granted = self.auth_scopes.get(token)
|
||||
if granted is None:
|
||||
return []
|
||||
resolved: List[str] = []
|
||||
unknown: List[str] = []
|
||||
for s in granted:
|
||||
name = str(s)
|
||||
if name in valid_scopes:
|
||||
resolved.append(name)
|
||||
else:
|
||||
unknown.append(name)
|
||||
if unknown:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
"Ignoring unknown operator scope(s) %s; valid scopes are %s",
|
||||
unknown,
|
||||
sorted(valid_scopes),
|
||||
)
|
||||
return resolved
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary (hides sensitive data)."""
|
||||
return {
|
||||
@@ -278,7 +343,9 @@ class GatewayConfig:
|
||||
"reconnect_timeout": self.reconnect_timeout,
|
||||
"ssl_enabled": bool(self.ssl_cert and self.ssl_key),
|
||||
"max_buffered_bytes": self.max_buffered_bytes,
|
||||
"max_queued_frames": self.max_queued_frames,
|
||||
"push": self.push.to_dict(),
|
||||
"scope_policy_enabled": self.has_scope_policy,
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -371,6 +438,7 @@ class MultiChannelGatewayConfig:
|
||||
gateway: GatewayConfig = field(default_factory=GatewayConfig)
|
||||
agents: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
channels: Dict[str, ChannelRouteConfig] = field(default_factory=dict)
|
||||
hooks: List[Any] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "MultiChannelGatewayConfig":
|
||||
@@ -416,6 +484,29 @@ class MultiChannelGatewayConfig:
|
||||
metadata=sc_data.get("metadata", {}),
|
||||
)
|
||||
|
||||
# Parse optional operator scope policy. Two supported shapes:
|
||||
# gateway:
|
||||
# auth:
|
||||
# tokens:
|
||||
# - token: "${VIEWER_TOKEN}"
|
||||
# scopes: [read]
|
||||
# or a flat mapping:
|
||||
# gateway:
|
||||
# auth_scopes:
|
||||
# "${VIEWER_TOKEN}": [read]
|
||||
auth_scopes: Optional[Dict[str, List[str]]] = None
|
||||
auth_section = gw_data.get("auth")
|
||||
if isinstance(auth_section, dict) and isinstance(auth_section.get("tokens"), list):
|
||||
auth_scopes = {}
|
||||
for entry in auth_section["tokens"]:
|
||||
if isinstance(entry, dict) and entry.get("token"):
|
||||
auth_scopes[str(entry["token"])] = list(entry.get("scopes", []))
|
||||
elif isinstance(gw_data.get("auth_scopes"), dict):
|
||||
auth_scopes = {
|
||||
str(tok): list(scopes)
|
||||
for tok, scopes in gw_data["auth_scopes"].items()
|
||||
}
|
||||
|
||||
gateway_config = GatewayConfig(
|
||||
host=gw_data.get("host", "127.0.0.1"),
|
||||
port=gw_data.get("port", 8765),
|
||||
@@ -429,7 +520,9 @@ class MultiChannelGatewayConfig:
|
||||
reconnect_timeout=gw_data.get("reconnect_timeout", 60),
|
||||
ssl_cert=gw_data.get("ssl_cert"),
|
||||
ssl_key=gw_data.get("ssl_key"),
|
||||
max_buffered_bytes=gw_data.get("max_buffered_bytes", 1024 * 1024),
|
||||
max_buffered_bytes=int(gw_data.get("max_buffered_bytes", 1024 * 1024)),
|
||||
max_queued_frames=int(gw_data.get("max_queued_frames", 1000)),
|
||||
auth_scopes=auth_scopes,
|
||||
)
|
||||
|
||||
# Parse agents section (pass through as dicts)
|
||||
@@ -451,10 +544,29 @@ class MultiChannelGatewayConfig:
|
||||
},
|
||||
)
|
||||
|
||||
# Parse inbound trigger hooks (Issue #2281). The hooks live either at
|
||||
# the top level (``hooks:``) or nested under ``gateway:`` for grouping.
|
||||
from .hooks import HookConfig
|
||||
|
||||
raw_hooks = data.get("hooks")
|
||||
if raw_hooks is None:
|
||||
raw_hooks = gw_data.get("hooks")
|
||||
hooks: List[HookConfig] = []
|
||||
for entry in raw_hooks or []:
|
||||
if isinstance(entry, dict) and entry.get("path"):
|
||||
try:
|
||||
hooks.append(HookConfig.from_dict(entry))
|
||||
except (ValueError, TypeError):
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
"Skipping invalid gateway hook entry: %s", entry
|
||||
)
|
||||
|
||||
return cls(
|
||||
gateway=gateway_config,
|
||||
agents=agents,
|
||||
channels=channels,
|
||||
hooks=hooks,
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@@ -465,4 +577,5 @@ class MultiChannelGatewayConfig:
|
||||
"channels": {
|
||||
name: ch.to_dict() for name, ch in self.channels.items()
|
||||
},
|
||||
"hooks": [h.to_dict() for h in self.hooks],
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Inbound trigger (webhook) contract for the PraisonAI gateway.
|
||||
|
||||
This module is **protocol-only** and carries no heavy dependencies. It defines
|
||||
the declarative shape of an inbound event trigger — an authenticated HTTP
|
||||
surface that, on receiving an external payload, runs an agent (or wakes a
|
||||
session) and delivers the result through a channel.
|
||||
|
||||
The HTTP route registration, request authentication, idempotency store, rate
|
||||
limiting and channel delivery all live in the ``praisonai`` wrapper gateway
|
||||
(``praisonai.gateway.server``). Here we only describe:
|
||||
|
||||
- ``HookConfig``: the declarative trigger definition (path/auth/action/agent/
|
||||
session/idempotency/delivery/message).
|
||||
- ``render_template``: a tiny, dependency-free ``{{ payload.x }}`` / ``{x}``
|
||||
templating helper used to build session keys, idempotency keys and messages
|
||||
from a JSON payload.
|
||||
- ``compute_idempotency_key``: derive a stable dedup key from a payload.
|
||||
- ``InboundTriggerProtocol``: the structural contract a gateway implements to
|
||||
register hooks.
|
||||
|
||||
It sits alongside the existing internal lifecycle hooks (``SCHEDULE_TRIGGER``,
|
||||
``GATEWAY_START``) as the *inbound* counterpart: those fire from inside the
|
||||
process; this fires from an external HTTP request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional, Protocol, runtime_checkable
|
||||
|
||||
__all__ = [
|
||||
"HookAction",
|
||||
"HookConfig",
|
||||
"InboundTriggerProtocol",
|
||||
"compute_idempotency_key",
|
||||
"render_template",
|
||||
]
|
||||
|
||||
|
||||
class HookAction:
|
||||
"""Allowed hook actions (string constants, kept dependency-free)."""
|
||||
|
||||
AGENT = "agent" # Run an agent turn on the templated message.
|
||||
WAKE = "wake" # Nudge an existing session without a new user message.
|
||||
|
||||
@classmethod
|
||||
def all(cls) -> "list[str]":
|
||||
return [cls.AGENT, cls.WAKE]
|
||||
|
||||
|
||||
# Matches both ``{{ payload.a.b }}`` (Jinja-ish) and ``{a.b}`` (str.format-ish)
|
||||
# placeholders so the same template works across the YAML and Python surfaces.
|
||||
# A single combined pattern is used (double-brace alternative first so it wins)
|
||||
# so each placeholder is substituted exactly once in one pass — a payload value
|
||||
# that itself contains ``{...}`` is therefore never re-expanded.
|
||||
_PLACEHOLDER = re.compile(r"\{\{\s*([^}]+?)\s*\}\}|\{\s*([^{}]+?)\s*\}")
|
||||
|
||||
|
||||
def _lookup(path: str, payload: Dict[str, Any]) -> Any:
|
||||
"""Resolve a dotted ``path`` against ``payload``.
|
||||
|
||||
A leading ``payload.`` prefix is optional, so ``{{ payload.from }}`` and
|
||||
``{from}`` resolve identically. Missing keys resolve to an empty string so
|
||||
a template never raises on a partial payload.
|
||||
"""
|
||||
key = path.strip()
|
||||
if key.startswith("payload."):
|
||||
key = key[len("payload."):]
|
||||
elif key == "payload":
|
||||
return payload
|
||||
|
||||
current: Any = payload
|
||||
for part in key.split("."):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
else:
|
||||
return ""
|
||||
return current
|
||||
|
||||
|
||||
def render_template(template: Optional[str], payload: Dict[str, Any]) -> str:
|
||||
"""Render ``template`` against ``payload`` with no external dependencies.
|
||||
|
||||
Supports ``{{ payload.x }}`` and ``{x}`` placeholders resolving dotted
|
||||
paths into the JSON ``payload``. Returns an empty string when ``template``
|
||||
is falsy. Unknown placeholders render as empty strings rather than raising.
|
||||
|
||||
Args:
|
||||
template: The template string (or None).
|
||||
payload: The decoded JSON request body.
|
||||
|
||||
Returns:
|
||||
The rendered string.
|
||||
"""
|
||||
if not template:
|
||||
return ""
|
||||
|
||||
def _resolve(match: "re.Match[str]") -> str:
|
||||
# group(1) is the ``{{ ... }}`` body, group(2) the ``{ ... }`` body.
|
||||
expr = match.group(1) if match.group(1) is not None else match.group(2)
|
||||
value = _lookup(expr, payload)
|
||||
return "" if value is None else str(value)
|
||||
|
||||
return _PLACEHOLDER.sub(_resolve, template)
|
||||
|
||||
|
||||
def compute_idempotency_key(
|
||||
template: Optional[str],
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
path: str = "",
|
||||
) -> str:
|
||||
"""Derive a stable idempotency key for a hook delivery.
|
||||
|
||||
When ``template`` is provided it is rendered against ``payload`` and the
|
||||
result (scoped by ``path``) is hashed. When it is not, the whole payload is
|
||||
hashed deterministically so identical retried deliveries still dedup.
|
||||
|
||||
Args:
|
||||
template: Optional idempotency-key template (e.g. ``"{message_id}"``).
|
||||
payload: The decoded JSON request body.
|
||||
path: The hook path, mixed in so the same id on different hooks does
|
||||
not collide.
|
||||
|
||||
Returns:
|
||||
A hex digest usable as a dedup key.
|
||||
"""
|
||||
if template:
|
||||
rendered = render_template(template, payload).strip()
|
||||
if rendered:
|
||||
basis = f"{path}\x00{rendered}"
|
||||
return hashlib.sha256(basis.encode("utf-8")).hexdigest()
|
||||
|
||||
# Fall back to a deterministic hash of the full payload.
|
||||
import json
|
||||
|
||||
try:
|
||||
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
except (TypeError, ValueError):
|
||||
canonical = repr(payload)
|
||||
basis = f"{path}\x00{canonical}"
|
||||
return hashlib.sha256(basis.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass
|
||||
class HookConfig:
|
||||
"""Declarative definition of an inbound event trigger.
|
||||
|
||||
Exposes ``POST /hooks/<path>`` on the gateway. On a request the gateway
|
||||
authenticates, deduplicates, resolves a session, runs the agent (or wakes a
|
||||
session), and delivers the reply through the delivery router.
|
||||
|
||||
Attributes:
|
||||
path: URL path segment, e.g. ``"gmail"`` exposes ``POST /hooks/gmail``.
|
||||
agent: Agent id to run (``action="agent"``). Defaults to the gateway's
|
||||
first registered agent when omitted.
|
||||
action: ``"agent"`` runs a turn, ``"wake"`` nudges a session.
|
||||
auth: Optional bearer token / shared secret required on the request.
|
||||
When omitted the gateway's own ``auth_token`` is used.
|
||||
session_key: Template for the session id, so related events share
|
||||
context, e.g. ``"hook:gmail:{message_id}"``.
|
||||
idempotency_key: Template for the dedup key, e.g. ``"{message_id}"``.
|
||||
When omitted the whole payload is hashed.
|
||||
deliver_to: ``channel:target`` delivery spec for the agent reply, e.g.
|
||||
``"telegram:123456789"``. Omit to skip outbound delivery.
|
||||
message: Template for the message built from the payload.
|
||||
enabled: Whether the hook is active.
|
||||
metadata: Free-form extra settings.
|
||||
"""
|
||||
|
||||
path: str
|
||||
agent: Optional[str] = None
|
||||
action: str = HookAction.AGENT
|
||||
auth: Optional[str] = None
|
||||
session_key: Optional[str] = None
|
||||
idempotency_key: Optional[str] = None
|
||||
deliver_to: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
enabled: bool = True
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.path = (self.path or "").strip().strip("/")
|
||||
if not self.path:
|
||||
raise ValueError("HookConfig.path must be a non-empty path segment")
|
||||
if self.action not in HookAction.all():
|
||||
raise ValueError(
|
||||
f"HookConfig.action must be one of {HookAction.all()}, "
|
||||
f"got {self.action!r}"
|
||||
)
|
||||
|
||||
@property
|
||||
def route(self) -> str:
|
||||
"""The full gateway route this hook is served on."""
|
||||
return f"/hooks/{self.path}"
|
||||
|
||||
def resolve_session_key(self, payload: Dict[str, Any]) -> str:
|
||||
"""Resolve the session id for ``payload``.
|
||||
|
||||
Falls back to a stable per-hook key when no ``session_key`` template is
|
||||
configured, so a hook without an explicit key still groups its events.
|
||||
"""
|
||||
if self.session_key:
|
||||
rendered = render_template(self.session_key, payload).strip()
|
||||
if rendered:
|
||||
return rendered
|
||||
return f"hook:{self.path}"
|
||||
|
||||
def resolve_idempotency_key(self, payload: Dict[str, Any]) -> str:
|
||||
"""Resolve the dedup key for ``payload``."""
|
||||
return compute_idempotency_key(
|
||||
self.idempotency_key, payload, path=self.path
|
||||
)
|
||||
|
||||
def resolve_message(self, payload: Dict[str, Any]) -> str:
|
||||
"""Render the agent message for ``payload``."""
|
||||
return render_template(self.message, payload)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a dictionary (hides the auth secret)."""
|
||||
return {
|
||||
"path": self.path,
|
||||
"agent": self.agent,
|
||||
"action": self.action,
|
||||
"auth": "***" if self.auth else None,
|
||||
"session_key": self.session_key,
|
||||
"idempotency_key": self.idempotency_key,
|
||||
"deliver_to": self.deliver_to,
|
||||
"message": self.message,
|
||||
"enabled": self.enabled,
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "HookConfig":
|
||||
"""Create a ``HookConfig`` from a parsed YAML/JSON mapping."""
|
||||
known = {
|
||||
"path",
|
||||
"agent",
|
||||
"action",
|
||||
"auth",
|
||||
"session_key",
|
||||
"idempotency_key",
|
||||
"deliver_to",
|
||||
"message",
|
||||
"enabled",
|
||||
"metadata",
|
||||
}
|
||||
return cls(
|
||||
path=data.get("path", ""),
|
||||
agent=data.get("agent"),
|
||||
action=data.get("action", HookAction.AGENT),
|
||||
auth=data.get("auth"),
|
||||
session_key=data.get("session_key"),
|
||||
idempotency_key=data.get("idempotency_key"),
|
||||
deliver_to=data.get("deliver_to"),
|
||||
message=data.get("message"),
|
||||
enabled=data.get("enabled", True),
|
||||
metadata={
|
||||
**(data.get("metadata") or {}),
|
||||
**{k: v for k, v in data.items() if k not in known},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class InboundTriggerProtocol(Protocol):
|
||||
"""Structural contract a gateway implements to host inbound triggers."""
|
||||
|
||||
def register_hook(self, hook: "HookConfig | Dict[str, Any]", **kwargs: Any) -> str:
|
||||
"""Register an inbound trigger, returning its path."""
|
||||
...
|
||||
|
||||
def unregister_hook(self, path: str) -> bool:
|
||||
"""Remove a registered hook by path."""
|
||||
...
|
||||
|
||||
def list_hooks(self) -> "list[str]":
|
||||
"""List registered hook paths."""
|
||||
...
|
||||
@@ -24,6 +24,7 @@ from typing import (
|
||||
Literal,
|
||||
Optional,
|
||||
Protocol,
|
||||
Set,
|
||||
TypedDict,
|
||||
Union,
|
||||
runtime_checkable,
|
||||
@@ -47,6 +48,49 @@ class ConnectErrorCode(str, Enum):
|
||||
PROTOCOL_UNSUPPORTED = "protocol_unsupported"
|
||||
PAIRING_REQUIRED = "pairing_required"
|
||||
AGENT_NOT_FOUND = "agent_not_found"
|
||||
RATE_LIMITED = "rate_limited"
|
||||
ORIGIN_NOT_ALLOWED = "origin_not_allowed"
|
||||
CONFIGURATION_ERROR = "configuration_error"
|
||||
|
||||
|
||||
class GatewayCloseCode(str, Enum):
|
||||
"""Structured, machine-readable reasons for a server-initiated close.
|
||||
|
||||
Distinct from :class:`ConnectErrorCode` (which describes why a *connection*
|
||||
was rejected at/handshake time), these describe why an already-established
|
||||
connection is being torn down by the server mid-session.
|
||||
|
||||
Codes:
|
||||
SLOW_CONSUMER: The client's outbound buffer exceeded the gateway's
|
||||
advertised ``max_buffered_bytes`` policy (a genuinely slow/stalled
|
||||
consumer). The server evicts it so its backlog cannot grow without
|
||||
bound or stall delivery to healthy clients.
|
||||
"""
|
||||
|
||||
SLOW_CONSUMER = "slow_consumer"
|
||||
|
||||
|
||||
class ConnectRecoveryStep(str, Enum):
|
||||
"""Machine-readable recovery hint for a connection rejection.
|
||||
|
||||
Clients branch on ``(code, next_step)`` to implement deterministic,
|
||||
uniform reconnect behaviour without parsing free-text reasons:
|
||||
|
||||
REAUTHENTICATE: Obtain fresh credentials, then reconnect.
|
||||
REPAIR: Re-run the device pairing flow, then reconnect.
|
||||
UPGRADE_CLIENT: The client protocol is too old; update the client.
|
||||
DOWNGRADE_CLIENT: The client protocol is newer than the server
|
||||
supports; use an older client or wait for a server upgrade.
|
||||
WAIT_THEN_RETRY: Back off (see ``retry_after_seconds``) then reconnect.
|
||||
DO_NOT_RETRY: The rejection is terminal; reconnecting will not help.
|
||||
"""
|
||||
|
||||
REAUTHENTICATE = "reauthenticate"
|
||||
REPAIR = "repair"
|
||||
UPGRADE_CLIENT = "upgrade_client"
|
||||
DOWNGRADE_CLIENT = "downgrade_client"
|
||||
WAIT_THEN_RETRY = "wait_then_retry"
|
||||
DO_NOT_RETRY = "do_not_retry"
|
||||
|
||||
|
||||
class EventType(str, Enum):
|
||||
@@ -109,6 +153,34 @@ class EventType(str, Enum):
|
||||
HELLO_ERROR = "hello_error"
|
||||
|
||||
|
||||
class OperatorScope(str, Enum):
|
||||
"""Operator authorisation scopes for multi-operator Gateway access control.
|
||||
|
||||
These describe *what an authenticated operator is allowed to do*, layered on
|
||||
top of (and orthogonal to) authentication. The vocabulary lives in the core
|
||||
SDK so that protocol clients and the wrapper Gateway share the same names;
|
||||
enforcement happens in the wrapper where requests are dispatched.
|
||||
|
||||
Scopes:
|
||||
READ: View dashboard / receive session transcripts and status.
|
||||
WRITE: Send messages as the agent.
|
||||
APPROVALS: Resolve tool-execution approvals (security-sensitive).
|
||||
PAIRING: Approve / revoke device pairing.
|
||||
ADMIN: Channel control (pause / resume / reconnect) and management.
|
||||
"""
|
||||
|
||||
READ = "read"
|
||||
WRITE = "write"
|
||||
APPROVALS = "approvals"
|
||||
PAIRING = "pairing"
|
||||
ADMIN = "admin"
|
||||
|
||||
@classmethod
|
||||
def all(cls) -> "List[OperatorScope]":
|
||||
"""Return every scope (granted by default when no policy is configured)."""
|
||||
return list(cls)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HelloParams:
|
||||
"""Parameters for initiating a versioned handshake.
|
||||
@@ -151,17 +223,56 @@ class HelloResult:
|
||||
|
||||
@dataclass
|
||||
class HelloError:
|
||||
"""Error response for failed handshake.
|
||||
|
||||
"""Structured connect-rejection envelope.
|
||||
|
||||
Emitted from *every* connection rejection path — both pre-handshake
|
||||
transport checks (auth/origin/rate-limit) and handshake negotiation —
|
||||
so clients can implement deterministic reconnect logic by branching on
|
||||
``(code, next_step)`` instead of string-matching close reasons.
|
||||
|
||||
Attributes:
|
||||
code: Structured error code
|
||||
message: Human-readable error message
|
||||
next_action: Suggested next action (e.g., "upgrade_client", "pair_device")
|
||||
code: Structured, machine-readable error code.
|
||||
message: Human-readable error message (display only).
|
||||
next_step: Machine-readable recovery hint telling the client what to
|
||||
do next (re-authenticate, re-pair, upgrade, wait then retry, ...).
|
||||
retry_after_seconds: Optional backoff hint (rate limiting / transient
|
||||
unavailability). Only meaningful with ``WAIT_THEN_RETRY``.
|
||||
next_action: Deprecated free-text hint, retained for backward
|
||||
compatibility. Prefer ``next_step``.
|
||||
"""
|
||||
code: ConnectErrorCode
|
||||
message: str
|
||||
next_step: Optional[ConnectRecoveryStep] = None
|
||||
retry_after_seconds: Optional[int] = None
|
||||
next_action: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to a ``hello_error`` wire frame.
|
||||
|
||||
The ``next`` key is preserved for backward compatibility with existing
|
||||
clients; ``next_step`` and ``retry_after_seconds`` are the structured
|
||||
recovery fields new clients should branch on.
|
||||
"""
|
||||
frame: Dict[str, Any] = {
|
||||
"type": "hello_error",
|
||||
"code": self.code.value,
|
||||
"message": self.message,
|
||||
}
|
||||
if self.next_step is not None:
|
||||
frame["next_step"] = self.next_step.value
|
||||
if self.retry_after_seconds is not None:
|
||||
frame["retry_after_seconds"] = self.retry_after_seconds
|
||||
# Backward-compatible legacy field: fall back to next_step's value.
|
||||
# Omitted entirely when there is no recovery hint so clients using
|
||||
# strict schema validation or ``if frame["next"]`` are not tripped by
|
||||
# an explicit ``null``.
|
||||
legacy_next = self.next_action
|
||||
if legacy_next is None and self.next_step is not None:
|
||||
legacy_next = self.next_step.value
|
||||
if legacy_next is not None:
|
||||
frame["next"] = legacy_next
|
||||
return frame
|
||||
|
||||
|
||||
@dataclass
|
||||
class GatewayEvent:
|
||||
@@ -175,6 +286,19 @@ class GatewayEvent:
|
||||
source: Source identifier (agent_id, client_id, etc.)
|
||||
target: Target identifier (optional, for directed events)
|
||||
sequence: Monotonic sequence number for gap detection (optional)
|
||||
|
||||
Wire Protocol Extensions:
|
||||
When events are sent over the gateway, additional fields are added:
|
||||
- seq: Top-level monotonic sequence number for gap detection
|
||||
- cursor: Event cursor position (also stored in data['cursor'])
|
||||
|
||||
Resume Protocol:
|
||||
The 'joined' acknowledgment includes:
|
||||
- cursor: Current head cursor position
|
||||
- oldest_cursor: Oldest event still in buffer
|
||||
- resync_required: True if requested 'since' is below oldest_cursor
|
||||
|
||||
When resync_required=true, a 'snapshot' message follows with full state.
|
||||
"""
|
||||
|
||||
type: Union[EventType, str]
|
||||
@@ -904,6 +1028,352 @@ class OutboundDeliveryProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-route, trust-tiered toolset scoping (Issue #2298)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Conservative deny-list applied to ``trust: "untrusted"`` routes. Inbound
|
||||
# content from strangers / generic webhooks is the framework's largest
|
||||
# prompt-injection surface, so dangerous tool *families* are never advertised
|
||||
# to the model on these routes (shell, file mutation, delegation,
|
||||
# self-scheduling). Names are matched case-insensitively against substrings of
|
||||
# the tool name so deployments do not have to enumerate every concrete tool.
|
||||
UNTRUSTED_DENY_SUBSTRINGS: List[str] = [
|
||||
"shell",
|
||||
"exec",
|
||||
"command",
|
||||
"subprocess",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"delete_file",
|
||||
"rm_file",
|
||||
"delegate",
|
||||
"handoff",
|
||||
"cronjob",
|
||||
"schedule",
|
||||
]
|
||||
|
||||
# Trust tiers, ordered from least to most privileged.
|
||||
TRUST_TIERS: List[str] = ["untrusted", "standard", "trusted"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolPolicy:
|
||||
"""Declarative, per-route scope applied to an agent's tool surface.
|
||||
|
||||
Mirrors the scheduler's ``RunPolicy.filter_tools`` contract (wrapper layer)
|
||||
but lives in core so :class:`RouteBinding` can *declare* the scope without
|
||||
importing any heavy wrapper code. The wrapper inbound path applies it via a
|
||||
small apply/restore helper, exactly as the scheduler already does for
|
||||
unattended runs.
|
||||
|
||||
Attributes:
|
||||
allow_tools: If set, only tools whose name is in this set are kept;
|
||||
everything else is removed. ``None`` means "allow all except
|
||||
``deny_tools`` / the trust deny-list".
|
||||
deny_tools: Exact tool names removed before the run.
|
||||
deny_substrings: Case-insensitive substrings; a tool whose name
|
||||
contains any of them is removed (used by the ``untrusted`` tier).
|
||||
"""
|
||||
|
||||
allow_tools: Optional[Set[str]] = None
|
||||
deny_tools: Set[str] = field(default_factory=set)
|
||||
deny_substrings: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_noop(self) -> bool:
|
||||
"""``True`` when the policy would never remove any tool."""
|
||||
return (
|
||||
self.allow_tools is None
|
||||
and not self.deny_tools
|
||||
and not self.deny_substrings
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _tool_name(tool: Any) -> str:
|
||||
"""Best-effort name for a tool (matches Agent's own resolution)."""
|
||||
name = getattr(tool, "name", None)
|
||||
if isinstance(name, str) and name:
|
||||
return name
|
||||
dunder = getattr(tool, "__name__", None)
|
||||
if isinstance(dunder, str) and dunder:
|
||||
return dunder
|
||||
if isinstance(tool, dict):
|
||||
fn = tool.get("function")
|
||||
if isinstance(fn, dict) and isinstance(fn.get("name"), str):
|
||||
return fn["name"]
|
||||
top = tool.get("name")
|
||||
if isinstance(top, str) and top:
|
||||
return top
|
||||
return str(tool)
|
||||
|
||||
def is_tool_allowed(self, tool: Any) -> bool:
|
||||
"""Return ``True`` if ``tool`` may be exposed on this route."""
|
||||
name = self._tool_name(tool)
|
||||
if name in self.deny_tools:
|
||||
return False
|
||||
lowered = name.lower()
|
||||
for needle in self.deny_substrings:
|
||||
if needle and needle.lower() in lowered:
|
||||
return False
|
||||
if self.allow_tools is not None and name not in self.allow_tools:
|
||||
return False
|
||||
return True
|
||||
|
||||
def filter_tools(self, tools: Optional[List[Any]]) -> List[Any]:
|
||||
"""Return a copy of ``tools`` with denied/disallowed tools removed."""
|
||||
if not tools:
|
||||
return []
|
||||
return [tool for tool in tools if self.is_tool_allowed(tool)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inbound route binding (Issue #2225)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class RouteBinding:
|
||||
"""A single declarative inbound-routing rule.
|
||||
|
||||
A binding maps a set of optional inbound conditions to a handling agent.
|
||||
Bindings are evaluated most-specific-first so operators get deterministic,
|
||||
debuggable routing across a fleet of agents behind one gateway.
|
||||
|
||||
All condition fields are optional; ``None`` means "do not constrain on this
|
||||
field". A binding matches a set of :class:`RouteFacts` only when *every*
|
||||
non-``None`` condition equals the corresponding fact.
|
||||
|
||||
Attributes:
|
||||
agent: The agent id to route to when this binding matches.
|
||||
chat_type: Chat type ("dm" | "group" | "channel").
|
||||
peer: Sender/user id (most specific).
|
||||
role: Role / guild-role membership of the sender.
|
||||
channel_id: Specific chat/channel id.
|
||||
account: Receiving bot account (for multi-account channels).
|
||||
priority: Higher wins; ties are broken by specificity then order.
|
||||
trust: Optional trust tier ("untrusted" | "standard" | "trusted").
|
||||
``untrusted`` advertises a conservative, read-only-leaning toolset
|
||||
to the model so dangerous tools are never offered on third-party /
|
||||
stranger / generic-webhook routes (Issue #2298). ``None`` /
|
||||
``standard`` / ``trusted`` apply no tier deny-list.
|
||||
allow_tools: If set, only these tool names are exposed on this route.
|
||||
deny_tools: Tool names removed before the run on this route.
|
||||
"""
|
||||
|
||||
agent: str
|
||||
chat_type: Optional[str] = None
|
||||
peer: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
channel_id: Optional[str] = None
|
||||
account: Optional[str] = None
|
||||
priority: int = 0
|
||||
trust: Optional[str] = None
|
||||
allow_tools: Optional[List[str]] = None
|
||||
deny_tools: Optional[List[str]] = None
|
||||
|
||||
# Specificity weights — exact peer beats role/channel beats account
|
||||
# beats chat-type. Higher means more specific.
|
||||
_SPECIFICITY = {
|
||||
"peer": 16,
|
||||
"role": 8,
|
||||
"channel_id": 8,
|
||||
"account": 4,
|
||||
"chat_type": 2,
|
||||
}
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Normalise ``trust`` so config typos cannot silently fail open.
|
||||
|
||||
Whitespace/case variants of a known tier (e.g. ``" Untrusted "``) are
|
||||
canonicalised. Any *unknown* non-empty value is treated as the most
|
||||
restrictive tier (``untrusted``) rather than as "no policy", so a
|
||||
misconfigured route can never accidentally expose the full toolset.
|
||||
"""
|
||||
if self.trust is None:
|
||||
return
|
||||
normalized = str(self.trust).strip().lower()
|
||||
if not normalized:
|
||||
self.trust = None
|
||||
elif normalized in TRUST_TIERS:
|
||||
self.trust = normalized
|
||||
else:
|
||||
self.trust = "untrusted"
|
||||
|
||||
@property
|
||||
def specificity(self) -> int:
|
||||
"""Sum of weights for the conditions this binding constrains on."""
|
||||
score = 0
|
||||
for field_name, weight in self._SPECIFICITY.items():
|
||||
if getattr(self, field_name) is not None:
|
||||
score += weight
|
||||
return score
|
||||
|
||||
def matches(self, facts: "RouteFacts") -> bool:
|
||||
"""Return True if every constrained condition equals the facts."""
|
||||
if self.peer is not None and str(self.peer) != str(facts.peer):
|
||||
return False
|
||||
if self.channel_id is not None and str(self.channel_id) != str(facts.channel_id):
|
||||
return False
|
||||
if self.account is not None and str(self.account) != str(facts.account):
|
||||
return False
|
||||
if self.chat_type is not None and self.chat_type != facts.chat_type:
|
||||
return False
|
||||
if self.role is not None:
|
||||
expected_role = str(self.role)
|
||||
if expected_role not in [str(role) for role in (facts.roles or [])]:
|
||||
return False
|
||||
return True
|
||||
|
||||
def tool_policy(self) -> Optional["ToolPolicy"]:
|
||||
"""Build the :class:`ToolPolicy` this binding declares, if any.
|
||||
|
||||
Returns ``None`` when the binding does not constrain the toolset, so
|
||||
callers can cheaply skip the apply/restore dance for trusted routes.
|
||||
The ``untrusted`` trust tier seeds a conservative substring deny-list;
|
||||
explicit ``allow_tools`` / ``deny_tools`` layer on top of it.
|
||||
"""
|
||||
deny_substrings: List[str] = []
|
||||
if self.trust == "untrusted":
|
||||
deny_substrings = list(UNTRUSTED_DENY_SUBSTRINGS)
|
||||
|
||||
allow = set(self.allow_tools) if self.allow_tools else None
|
||||
deny = set(self.deny_tools) if self.deny_tools else set()
|
||||
|
||||
if allow is None and not deny and not deny_substrings:
|
||||
return None
|
||||
return ToolPolicy(
|
||||
allow_tools=allow,
|
||||
deny_tools=deny,
|
||||
deny_substrings=deny_substrings,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "RouteBinding":
|
||||
"""Create a binding from a YAML/dict mapping.
|
||||
|
||||
Accepts ``agent`` (required). Unknown keys are ignored so the shape
|
||||
can evolve without breaking older configs.
|
||||
"""
|
||||
return cls(
|
||||
agent=data.get("agent", "default"),
|
||||
chat_type=data.get("chat_type"),
|
||||
peer=_as_opt_str(data.get("peer")),
|
||||
role=data.get("role"),
|
||||
channel_id=_as_opt_str(data.get("channel_id")),
|
||||
account=_as_opt_str(data.get("account")),
|
||||
priority=int(data.get("priority", 0) or 0),
|
||||
trust=_as_opt_str(data.get("trust")),
|
||||
allow_tools=_as_opt_str_list(data.get("allow_tools")),
|
||||
deny_tools=_as_opt_str_list(data.get("deny_tools")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteFacts:
|
||||
"""Inbound facts extracted from a message, used to resolve a binding.
|
||||
|
||||
Attributes:
|
||||
chat_type: Normalised chat type ("dm" | "group" | "channel" | "default").
|
||||
peer: Sender/user id.
|
||||
roles: Roles/guild-role memberships of the sender.
|
||||
channel_id: The chat/channel id the message arrived in.
|
||||
account: The receiving bot account (multi-account channels).
|
||||
"""
|
||||
|
||||
chat_type: str = "default"
|
||||
peer: Optional[str] = None
|
||||
roles: List[str] = field(default_factory=list)
|
||||
channel_id: Optional[str] = None
|
||||
account: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteMatch:
|
||||
"""Result of resolving a route.
|
||||
|
||||
Attributes:
|
||||
agent: The resolved agent id.
|
||||
binding: The binding that matched, or ``None`` when the fallback was used.
|
||||
reason: Short human-readable explanation for logging/debugging.
|
||||
"""
|
||||
|
||||
agent: str
|
||||
binding: Optional[RouteBinding] = None
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def _as_opt_str(value: Any) -> Optional[str]:
|
||||
"""Coerce a value to a string, preserving ``None``."""
|
||||
if value is None:
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
|
||||
def _as_opt_str_list(value: Any) -> Optional[List[str]]:
|
||||
"""Coerce a YAML scalar or sequence into ``Optional[List[str]]``.
|
||||
|
||||
Accepts a single string (wrapped into a one-element list) or any iterable
|
||||
of values; returns ``None`` for ``None``/empty so an absent key stays
|
||||
unconstrained.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return [value] if value else None
|
||||
try:
|
||||
items = [str(v) for v in value]
|
||||
except TypeError:
|
||||
return [str(value)]
|
||||
return items or None
|
||||
|
||||
|
||||
def resolve_route(
|
||||
bindings: List[RouteBinding],
|
||||
facts: RouteFacts,
|
||||
default_agent: str = "default",
|
||||
) -> RouteMatch:
|
||||
"""Resolve the handling agent from priority-ordered bindings.
|
||||
|
||||
Bindings are evaluated most-specific-first: the matching binding with the
|
||||
highest ``priority`` wins; ties are broken by specificity (exact peer →
|
||||
role/channel → account → chat-type), then by declaration order.
|
||||
|
||||
Args:
|
||||
bindings: Candidate route bindings (any order).
|
||||
facts: Inbound facts extracted from the message.
|
||||
default_agent: Agent id to fall back to when nothing matches.
|
||||
|
||||
Returns:
|
||||
A :class:`RouteMatch` with the selected agent and matched binding.
|
||||
"""
|
||||
best: Optional[RouteBinding] = None
|
||||
best_key: tuple = ()
|
||||
for idx, binding in enumerate(bindings):
|
||||
if not binding.matches(facts):
|
||||
continue
|
||||
# Higher priority wins, then higher specificity, then earlier order.
|
||||
key = (binding.priority, binding.specificity, -idx)
|
||||
if best is None or key > best_key:
|
||||
best = binding
|
||||
best_key = key
|
||||
|
||||
if best is not None:
|
||||
return RouteMatch(
|
||||
agent=best.agent,
|
||||
binding=best,
|
||||
reason=(
|
||||
f"matched binding (priority={best.priority}, "
|
||||
f"specificity={best.specificity})"
|
||||
),
|
||||
)
|
||||
|
||||
return RouteMatch(
|
||||
agent=default_agent,
|
||||
binding=None,
|
||||
reason="no binding matched; using default",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth Mode protocols and helpers (bind-aware authentication posture)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1201,6 +1671,366 @@ class DeliveryResolverProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent-facing outbound messaging (Issue #2183)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class DeliveryResult:
|
||||
"""Outcome of an agent-initiated proactive send.
|
||||
|
||||
Attributes:
|
||||
ok: Whether the send was accepted for delivery.
|
||||
target: The resolved target the message was routed to.
|
||||
summary: Human-readable summary suitable for returning to the model.
|
||||
detail: Optional extra information (error text, message id, etc.).
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
target: str = ""
|
||||
summary: str = ""
|
||||
detail: Optional[str] = None
|
||||
|
||||
def as_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a serializable dictionary."""
|
||||
return {
|
||||
"ok": self.ok,
|
||||
"target": self.target,
|
||||
"summary": self.summary,
|
||||
"detail": self.detail,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TargetInfo:
|
||||
"""A reachable delivery target the agent can address.
|
||||
|
||||
Attributes:
|
||||
target: The token to pass to ``send`` (e.g. "origin", "slack:#ops").
|
||||
platform: Platform name (e.g. "telegram", "slack").
|
||||
kind: Target kind ("origin", "home", or "alias").
|
||||
label: Friendly label for display to the model/user.
|
||||
"""
|
||||
|
||||
target: str
|
||||
platform: str = ""
|
||||
kind: str = "alias"
|
||||
label: str = ""
|
||||
|
||||
def as_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a serializable dictionary."""
|
||||
return {
|
||||
"target": self.target,
|
||||
"platform": self.platform,
|
||||
"kind": self.kind,
|
||||
"label": self.label,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class OutboundMessengerProtocol(Protocol):
|
||||
"""Protocol for agent-facing proactive message delivery.
|
||||
|
||||
A concrete implementation is provided by the running gateway/bot (in the
|
||||
praisonai wrapper) and registered into the per-turn context so the
|
||||
built-in ``send_message`` tool can resolve it. It bridges to the existing
|
||||
delivery stack (DeliveryRouter, HomeChannelRegistry, outbox, mirroring).
|
||||
|
||||
Example usage (implementation in praisonai wrapper)::
|
||||
|
||||
messenger = BotOutboundMessenger(bot, resolver, router)
|
||||
token = register_outbound_messenger(messenger)
|
||||
try:
|
||||
... # agent runs; send_message tool resolves the messenger
|
||||
finally:
|
||||
clear_outbound_messenger(token)
|
||||
"""
|
||||
|
||||
async def send(
|
||||
self,
|
||||
target: str,
|
||||
text: str,
|
||||
*,
|
||||
media: Optional[List[str]] = None,
|
||||
) -> "DeliveryResult":
|
||||
"""Deliver a message to a symbolic target.
|
||||
|
||||
Args:
|
||||
target: Symbolic target token ("origin", "<platform>",
|
||||
"<platform>:<chat_id>[:<thread_id>]", or a friendly alias).
|
||||
text: The message text to send.
|
||||
media: Optional list of local file paths to attach.
|
||||
|
||||
Returns:
|
||||
A :class:`DeliveryResult` describing the outcome.
|
||||
"""
|
||||
...
|
||||
|
||||
def list_targets(self) -> List["TargetInfo"]:
|
||||
"""List the targets currently reachable from this runtime."""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outbound send-policy guard (Issue #2226)
|
||||
#
|
||||
# ``send_message`` lets the model choose where to deliver. Because the target
|
||||
# is model-controlled, poisoned inbound content (prompt injection) can steer an
|
||||
# agent into delivering to a channel the operator never intended. The router
|
||||
# only fails on *unresolvable* targets — a reachability check, not an
|
||||
# authorisation one. This policy seam sits in core, *before* dispatch, so every
|
||||
# messenger implementation is constrained, not just one adapter. Absent a
|
||||
# policy, today's behaviour is preserved (allow-all).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SendDecision:
|
||||
"""Closed decision shape for an outbound send-policy evaluation.
|
||||
|
||||
Attributes:
|
||||
allow: Whether the send to the requested target is permitted.
|
||||
reason: Optional model-readable explanation (used in the
|
||||
:class:`DeliveryResult` detail when denied).
|
||||
"""
|
||||
|
||||
allow: bool
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SendPolicyProtocol(Protocol):
|
||||
"""Protocol for authorising agent-initiated proactive sends.
|
||||
|
||||
Implementations decide whether an agent may deliver to a model-chosen
|
||||
``target``. The hook is evaluated inside the ``send_message`` path before
|
||||
the messenger dispatches, so a denied send returns a clean, model-readable
|
||||
:class:`DeliveryResult` (``ok=False``) rather than delivering.
|
||||
|
||||
A concrete, config-driven implementation (:class:`SendPolicy`) is provided
|
||||
for the common allow/deny case; richer back-ends may live in plugins.
|
||||
"""
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
target: str,
|
||||
*,
|
||||
agent_id: str = "",
|
||||
session_id: str = "",
|
||||
origin: Optional[str] = None,
|
||||
) -> SendDecision:
|
||||
"""Return a :class:`SendDecision` for the requested ``target``."""
|
||||
...
|
||||
|
||||
|
||||
class SendPolicy:
|
||||
"""A lightweight allow/deny send-policy with an optional default-deny posture.
|
||||
|
||||
This is the config-driven default referenced by ``send_policy`` blocks in
|
||||
``gateway.yaml`` and the ``Bot(..., send_policy=...)`` Python surface. It is
|
||||
intentionally minimal (no heavy dependencies) and lives in core so the
|
||||
built-in ``send_message`` path is always interceptable.
|
||||
|
||||
Matching is exact against the symbolic target token (e.g. ``"origin"``,
|
||||
``"slack:#ops"``, or a friendly alias). With ``default="deny"`` only listed
|
||||
targets are permitted; with ``default="allow"`` all targets are permitted
|
||||
except those in ``deny``.
|
||||
|
||||
Example::
|
||||
|
||||
# default-deny: only the conversation origin and an ops alias allowed
|
||||
SendPolicy(default="deny", allow=["origin", "ops-alerts"])
|
||||
# default-allow: everything permitted except an exec channel
|
||||
SendPolicy(default="allow", deny=["slack:#exec"])
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
default: str = "allow",
|
||||
allow: Optional[List[str]] = None,
|
||||
deny: Optional[List[str]] = None,
|
||||
):
|
||||
default = (default or "allow").lower()
|
||||
if default not in ("allow", "deny"):
|
||||
raise ValueError(
|
||||
f"send_policy default must be 'allow' or 'deny', got {default!r}"
|
||||
)
|
||||
self.default = default
|
||||
self.allow = list(allow or [])
|
||||
self.deny = list(deny or [])
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
target: str,
|
||||
*,
|
||||
agent_id: str = "",
|
||||
session_id: str = "",
|
||||
origin: Optional[str] = None,
|
||||
) -> SendDecision:
|
||||
if target in self.deny:
|
||||
return SendDecision(
|
||||
allow=False,
|
||||
reason=f"target '{target}' is denied by send_policy",
|
||||
)
|
||||
if self.default == "deny":
|
||||
if target in self.allow:
|
||||
return SendDecision(allow=True)
|
||||
return SendDecision(
|
||||
allow=False,
|
||||
reason=f"target '{target}' is not permitted by send_policy",
|
||||
)
|
||||
return SendDecision(allow=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway idle-dormancy / scale-to-zero (Issue #2332)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class IdleDecision:
|
||||
"""Result of an idle/quiesce evaluation.
|
||||
|
||||
Attributes:
|
||||
idle: Whether the gateway is currently quiescent.
|
||||
reason: Optional human-readable explanation (logged on quiesce).
|
||||
"""
|
||||
|
||||
idle: bool
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class GatewayIdlePolicyProtocol(Protocol):
|
||||
"""Protocol for gateway-process idle/scale-to-zero decisions.
|
||||
|
||||
Pure, import-free decision contract consumed by the wrapper's
|
||||
``BotOS`` run-loop. The wrapper supplies live facts (running turns,
|
||||
last inbound timestamp, background-work flag) and the policy decides
|
||||
whether the whole gateway may safely stand down. Concrete drivers
|
||||
(suspend the compute host, stand transports down, register a wake
|
||||
URL) live in the wrapper; this contract keeps the *decision* testable
|
||||
in isolation without a live gateway.
|
||||
|
||||
A config-driven default (:class:`ScaleToZeroPolicy`) is provided for
|
||||
the common "idle for N minutes with nothing in flight" case.
|
||||
"""
|
||||
|
||||
def should_arm(
|
||||
self,
|
||||
*,
|
||||
transports_quiescable: bool,
|
||||
wake_registered: bool,
|
||||
) -> bool:
|
||||
"""Return whether dormancy may be armed at all.
|
||||
|
||||
Implementations gate on whether transports can be cleanly stood
|
||||
down and a wake path exists, so a gateway never quiesces into a
|
||||
state it cannot resume from.
|
||||
"""
|
||||
...
|
||||
|
||||
def is_idle(
|
||||
self,
|
||||
*,
|
||||
running_turns: int,
|
||||
last_inbound_ts: float,
|
||||
has_background_work: bool,
|
||||
now: float,
|
||||
) -> IdleDecision:
|
||||
"""Return an :class:`IdleDecision` for the supplied facts."""
|
||||
...
|
||||
|
||||
|
||||
class ScaleToZeroPolicy:
|
||||
"""Config-driven idle policy for safe scale-to-zero.
|
||||
|
||||
The default referenced by ``scale_to_zero:`` blocks in ``gateway.yaml``
|
||||
and the ``BotOS(..., idle_policy=...)`` Python surface. It is
|
||||
intentionally minimal and dependency-free so the decision lives in
|
||||
core and is provable in isolation; the wrapper owns the side effects
|
||||
(suspend host, stand transports down, wake endpoint).
|
||||
|
||||
The gateway is considered idle only when *all* guards pass:
|
||||
|
||||
* no in-flight agent turn (``running_turns == 0``),
|
||||
* no live background work (scheduled jobs, delegated subagents,
|
||||
durable outbox drains — ``has_background_work`` is ``False``),
|
||||
* no inbound message for at least ``idle_timeout_minutes``.
|
||||
|
||||
Example::
|
||||
|
||||
ScaleToZeroPolicy(idle_timeout_minutes=5,
|
||||
wake_url="https://my-bot.fly.dev/_wake")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
idle_timeout_minutes: float = 5.0,
|
||||
wake_url: Optional[str] = None,
|
||||
enabled: bool = True,
|
||||
):
|
||||
if idle_timeout_minutes <= 0:
|
||||
raise ValueError(
|
||||
f"idle_timeout_minutes must be > 0, got {idle_timeout_minutes!r}"
|
||||
)
|
||||
self.idle_timeout_minutes = float(idle_timeout_minutes)
|
||||
self.wake_url = wake_url
|
||||
self.enabled = bool(enabled)
|
||||
|
||||
@property
|
||||
def idle_timeout_seconds(self) -> float:
|
||||
return self.idle_timeout_minutes * 60.0
|
||||
|
||||
def should_arm(
|
||||
self,
|
||||
*,
|
||||
transports_quiescable: bool,
|
||||
wake_registered: bool,
|
||||
) -> bool:
|
||||
if not self.enabled:
|
||||
return False
|
||||
# Never arm into a state we cannot resume from.
|
||||
return bool(transports_quiescable and wake_registered)
|
||||
|
||||
def is_idle(
|
||||
self,
|
||||
*,
|
||||
running_turns: int,
|
||||
last_inbound_ts: float,
|
||||
has_background_work: bool,
|
||||
now: float,
|
||||
) -> IdleDecision:
|
||||
if not self.enabled:
|
||||
return IdleDecision(idle=False, reason="scale_to_zero disabled")
|
||||
if running_turns > 0:
|
||||
return IdleDecision(
|
||||
idle=False,
|
||||
reason=f"{running_turns} agent turn(s) in flight",
|
||||
)
|
||||
if has_background_work:
|
||||
return IdleDecision(
|
||||
idle=False,
|
||||
reason="background work in progress",
|
||||
)
|
||||
elapsed = now - last_inbound_ts
|
||||
if elapsed < self.idle_timeout_seconds:
|
||||
remaining = self.idle_timeout_seconds - elapsed
|
||||
return IdleDecision(
|
||||
idle=False,
|
||||
reason=f"last inbound {elapsed:.0f}s ago; {remaining:.0f}s to idle",
|
||||
)
|
||||
return IdleDecision(
|
||||
idle=True,
|
||||
reason=f"idle for {elapsed:.0f}s with nothing in flight",
|
||||
)
|
||||
|
||||
|
||||
# Backward-compatible alias. The canonical name follows the repo's
|
||||
# ``*Protocol`` suffix convention (e.g. ``SendPolicyProtocol``); the old
|
||||
# name is retained so existing imports keep working.
|
||||
GatewayIdlePolicy = GatewayIdlePolicyProtocol
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol Version Negotiation (Issue #2130)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -75,6 +75,10 @@ __all__ = [
|
||||
"MessageReceivedInput",
|
||||
"MessageSendingInput",
|
||||
"MessageSentInput",
|
||||
# Gateway & schedule lifecycle event inputs
|
||||
"GatewayStartInput",
|
||||
"GatewayStopInput",
|
||||
"ScheduleTriggerInput",
|
||||
# Middleware types
|
||||
"InvocationContext",
|
||||
"ModelRequest",
|
||||
@@ -137,6 +141,9 @@ _LAZY_GROUPS = {
|
||||
'MessageReceivedInput': ('praisonaiagents.hooks.events', 'MessageReceivedInput'),
|
||||
'MessageSendingInput': ('praisonaiagents.hooks.events', 'MessageSendingInput'),
|
||||
'MessageSentInput': ('praisonaiagents.hooks.events', 'MessageSentInput'),
|
||||
'GatewayStartInput': ('praisonaiagents.hooks.events', 'GatewayStartInput'),
|
||||
'GatewayStopInput': ('praisonaiagents.hooks.events', 'GatewayStopInput'),
|
||||
'ScheduleTriggerInput': ('praisonaiagents.hooks.events', 'ScheduleTriggerInput'),
|
||||
},
|
||||
'middleware_types': {
|
||||
'InvocationContext': ('praisonaiagents.hooks.middleware', 'InvocationContext'),
|
||||
|
||||
@@ -276,3 +276,52 @@ class MessageSentInput(HookInput):
|
||||
"message_id": self.message_id,
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class GatewayStartInput(HookInput):
|
||||
"""Input for GATEWAY_START hooks (gateway/BotOS started)."""
|
||||
platforms: List[str] = field(default_factory=list)
|
||||
bot_count: int = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"platforms": self.platforms,
|
||||
"bot_count": self.bot_count,
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class GatewayStopInput(HookInput):
|
||||
"""Input for GATEWAY_STOP hooks (gateway/BotOS stopped)."""
|
||||
platforms: List[str] = field(default_factory=list)
|
||||
bot_count: int = 0
|
||||
reason: str = "stop"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"platforms": self.platforms,
|
||||
"bot_count": self.bot_count,
|
||||
"reason": self.reason,
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduleTriggerInput(HookInput):
|
||||
"""Input for SCHEDULE_TRIGGER hooks (a scheduled job fired)."""
|
||||
job_name: str = ""
|
||||
job_id: str = ""
|
||||
message: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"job_name": self.job_name,
|
||||
"job_id": self.job_id,
|
||||
"message": self.message[:500] if self.message else "",
|
||||
})
|
||||
return base
|
||||
@@ -15,32 +15,21 @@ import os
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
# Module-level cache for litellm (lazy loaded)
|
||||
_litellm_module = None
|
||||
_litellm_import_attempted = False
|
||||
from ._litellm_loader import get_litellm
|
||||
|
||||
|
||||
def _get_litellm():
|
||||
"""
|
||||
Lazy import litellm module.
|
||||
|
||||
|
||||
Returns litellm module if available, None otherwise.
|
||||
Caches the result to avoid repeated import attempts.
|
||||
"""
|
||||
global _litellm_module, _litellm_import_attempted
|
||||
|
||||
if _litellm_import_attempted:
|
||||
return _litellm_module
|
||||
|
||||
_litellm_import_attempted = True
|
||||
|
||||
try:
|
||||
import litellm
|
||||
_litellm_module = litellm
|
||||
return litellm
|
||||
except ImportError:
|
||||
logging.debug("litellm not available for cost calculation")
|
||||
return None
|
||||
return get_litellm(
|
||||
on_missing=lambda: logging.debug(
|
||||
"litellm not available for cost calculation"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_cost_tracking_enabled() -> bool:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Shared lazy-import helper for the optional litellm dependency.
|
||||
|
||||
This centralizes the "import litellm at most once, then cache the result
|
||||
(None if unavailable)" pattern used across the llm/ package so the lazy-loading
|
||||
behaviour is expressed in a single place.
|
||||
|
||||
litellm remains an optional dependency and is never imported at module level.
|
||||
"""
|
||||
|
||||
# Module-level cache for litellm (lazy loaded)
|
||||
_litellm_module = None
|
||||
_litellm_import_attempted = False
|
||||
|
||||
|
||||
def get_litellm(on_missing=None):
|
||||
"""
|
||||
Lazy import litellm module.
|
||||
|
||||
Returns the litellm module if available, None otherwise.
|
||||
Caches the result to avoid repeated import attempts.
|
||||
|
||||
Args:
|
||||
on_missing: Optional callable invoked (with no arguments) when litellm
|
||||
cannot be imported. Useful for logging. It is invoked on every call
|
||||
that finds litellm unavailable (including cached failures), so each
|
||||
caller's diagnostics are honoured regardless of call ordering.
|
||||
|
||||
Returns:
|
||||
The litellm module if importable, otherwise None.
|
||||
"""
|
||||
global _litellm_module, _litellm_import_attempted
|
||||
|
||||
if not _litellm_import_attempted:
|
||||
_litellm_import_attempted = True
|
||||
try:
|
||||
import litellm
|
||||
_litellm_module = litellm
|
||||
except ImportError:
|
||||
_litellm_module = None
|
||||
|
||||
if _litellm_module is None and on_missing is not None:
|
||||
on_missing()
|
||||
|
||||
return _litellm_module
|
||||
@@ -1516,46 +1516,6 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
return self._is_ollama_provider() and iteration_count == 0
|
||||
return False
|
||||
|
||||
def _try_parse_tool_call_json(self, response_text: str, iteration_count: int) -> tuple:
|
||||
"""
|
||||
Try to parse tool call from JSON response text.
|
||||
|
||||
Returns:
|
||||
tuple: (tool_calls list or None, parse_error string or None)
|
||||
"""
|
||||
if not response_text or not response_text.strip():
|
||||
return None, None
|
||||
|
||||
try:
|
||||
response_json = json.loads(response_text.strip())
|
||||
if isinstance(response_json, dict) and "name" in response_json:
|
||||
tool_calls = [{
|
||||
"id": f"tool_{iteration_count}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": response_json["name"],
|
||||
"arguments": json.dumps(response_json.get("arguments", {}))
|
||||
}
|
||||
}]
|
||||
return tool_calls, None
|
||||
elif isinstance(response_json, list):
|
||||
tool_calls = []
|
||||
for idx, tool_json in enumerate(response_json):
|
||||
if isinstance(tool_json, dict) and "name" in tool_json:
|
||||
tool_calls.append({
|
||||
"id": f"tool_{iteration_count}_{idx}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_json["name"],
|
||||
"arguments": json.dumps(tool_json.get("arguments", {}))
|
||||
}
|
||||
})
|
||||
return tool_calls if tool_calls else None, None
|
||||
else:
|
||||
return None, "Response is not a valid tool call format (missing 'name' field)"
|
||||
except json.JSONDecodeError as e:
|
||||
return None, f"JSON parse error: {str(e)}"
|
||||
|
||||
def _validate_tool_call(self, tool_call: Dict, formatted_tools: Optional[List]) -> Optional[str]:
|
||||
"""
|
||||
Validate a tool call against available tools.
|
||||
@@ -1897,42 +1857,18 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
def _fix_array_schemas(self, schema: Dict) -> Dict:
|
||||
"""
|
||||
Recursively fix array schemas by adding missing 'items' attribute.
|
||||
|
||||
This ensures compatibility with OpenAI's function calling format which
|
||||
requires array types to specify the type of items they contain.
|
||||
|
||||
|
||||
Thin wrapper around the shared ``fix_array_schemas`` helper, kept for
|
||||
backward compatibility with any external subclassing.
|
||||
|
||||
Args:
|
||||
schema: The schema dictionary to fix
|
||||
|
||||
|
||||
Returns:
|
||||
dict: The fixed schema
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
|
||||
# Create a copy to avoid modifying the original
|
||||
fixed_schema = schema.copy()
|
||||
|
||||
# Fix array types at the current level
|
||||
if fixed_schema.get("type") == "array" and "items" not in fixed_schema:
|
||||
# Add a default items schema for arrays without it
|
||||
fixed_schema["items"] = {"type": "string"}
|
||||
|
||||
# Recursively fix nested schemas in properties
|
||||
if "properties" in fixed_schema and isinstance(fixed_schema["properties"], dict):
|
||||
fixed_properties = {}
|
||||
for prop_name, prop_schema in fixed_schema["properties"].items():
|
||||
if isinstance(prop_schema, dict):
|
||||
fixed_properties[prop_name] = self._fix_array_schemas(prop_schema)
|
||||
else:
|
||||
fixed_properties[prop_name] = prop_schema
|
||||
fixed_schema["properties"] = fixed_properties
|
||||
|
||||
# Fix items schema if it exists
|
||||
if "items" in fixed_schema and isinstance(fixed_schema["items"], dict):
|
||||
fixed_schema["items"] = self._fix_array_schemas(fixed_schema["items"])
|
||||
|
||||
return fixed_schema
|
||||
from .schema_utils import fix_array_schemas
|
||||
return fix_array_schemas(schema)
|
||||
|
||||
def _get_tools_cache_key(self, tools):
|
||||
"""Generate a cache key for tools list."""
|
||||
@@ -5598,26 +5534,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
return response_text, tool_calls if tool_calls else None
|
||||
|
||||
def _prepare_response_logging(self, temperature: float, stream: bool, verbose: bool, markdown: bool, **kwargs) -> Optional[Dict[str, Any]]:
|
||||
"""Prepare debug logging information for response methods"""
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
debug_info = {
|
||||
"model": self.model,
|
||||
"timeout": self.timeout,
|
||||
"temperature": temperature,
|
||||
"top_p": self.top_p,
|
||||
"n": self.n,
|
||||
"max_tokens": self.max_tokens,
|
||||
"presence_penalty": self.presence_penalty,
|
||||
"frequency_penalty": self.frequency_penalty,
|
||||
"stream": stream,
|
||||
"verbose": verbose,
|
||||
"markdown": markdown,
|
||||
"kwargs": str(kwargs)
|
||||
}
|
||||
return debug_info
|
||||
return None
|
||||
|
||||
def _process_streaming_chunk(self, chunk) -> Optional[str]:
|
||||
"""Extract content from a streaming chunk"""
|
||||
if chunk and chunk.choices and chunk.choices[0].delta.content:
|
||||
|
||||
@@ -19,32 +19,7 @@ Sources:
|
||||
- https://docs.litellm.ai/docs/completion/prompt_caching
|
||||
"""
|
||||
|
||||
|
||||
# Module-level cache for litellm (lazy loaded)
|
||||
_litellm_module = None
|
||||
_litellm_import_attempted = False
|
||||
|
||||
|
||||
def _get_litellm():
|
||||
"""
|
||||
Lazy import litellm module.
|
||||
|
||||
Returns litellm module if available, None otherwise.
|
||||
Caches the result to avoid repeated import attempts.
|
||||
"""
|
||||
global _litellm_module, _litellm_import_attempted
|
||||
|
||||
if _litellm_import_attempted:
|
||||
return _litellm_module
|
||||
|
||||
_litellm_import_attempted = True
|
||||
|
||||
try:
|
||||
import litellm
|
||||
_litellm_module = litellm
|
||||
return litellm
|
||||
except ImportError:
|
||||
return None
|
||||
from ._litellm_loader import get_litellm as _get_litellm
|
||||
|
||||
|
||||
def supports_structured_outputs(model_name: str) -> bool:
|
||||
|
||||
@@ -385,42 +385,18 @@ class OpenAIClient:
|
||||
def _fix_array_schemas(self, schema: Dict) -> Dict:
|
||||
"""
|
||||
Recursively fix array schemas by adding missing 'items' attribute.
|
||||
|
||||
This ensures compatibility with OpenAI's function calling format which
|
||||
requires array types to specify the type of items they contain.
|
||||
|
||||
|
||||
Thin wrapper around the shared ``fix_array_schemas`` helper, kept for
|
||||
backward compatibility with any external subclassing.
|
||||
|
||||
Args:
|
||||
schema: The schema dictionary to fix
|
||||
|
||||
|
||||
Returns:
|
||||
dict: The fixed schema
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
|
||||
# Create a copy to avoid modifying the original
|
||||
fixed_schema = schema.copy()
|
||||
|
||||
# Fix array types at the current level
|
||||
if fixed_schema.get("type") == "array" and "items" not in fixed_schema:
|
||||
# Add a default items schema for arrays without it
|
||||
fixed_schema["items"] = {"type": "string"}
|
||||
|
||||
# Recursively fix nested schemas in properties
|
||||
if "properties" in fixed_schema and isinstance(fixed_schema["properties"], dict):
|
||||
fixed_properties = {}
|
||||
for prop_name, prop_schema in fixed_schema["properties"].items():
|
||||
if isinstance(prop_schema, dict):
|
||||
fixed_properties[prop_name] = self._fix_array_schemas(prop_schema)
|
||||
else:
|
||||
fixed_properties[prop_name] = prop_schema
|
||||
fixed_schema["properties"] = fixed_properties
|
||||
|
||||
# Fix items schema if it exists
|
||||
if "items" in fixed_schema and isinstance(fixed_schema["items"], dict):
|
||||
fixed_schema["items"] = self._fix_array_schemas(fixed_schema["items"])
|
||||
|
||||
return fixed_schema
|
||||
from .schema_utils import fix_array_schemas
|
||||
return fix_array_schemas(schema)
|
||||
|
||||
def _get_tools_cache_key(self, tools: List[Any]) -> str:
|
||||
"""Generate a cache key for tools."""
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Shared schema utilities for LLM tool calling.
|
||||
|
||||
This module provides helpers for normalising tool/function schemas so they are
|
||||
compatible with OpenAI-style function calling across the LLM, OpenAI client and
|
||||
MCP code paths.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def fix_array_schemas(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Recursively fix array schemas by adding missing 'items' attribute.
|
||||
|
||||
This is the single canonical implementation shared by the LLM, OpenAI client
|
||||
and MCP code paths. It ensures compatibility with OpenAI's function calling
|
||||
format which requires array types to specify the type of items they contain,
|
||||
and normalises nested ``properties``, ``items``, ``additionalProperties`` and
|
||||
``anyOf``/``oneOf``/``allOf`` schema constructs.
|
||||
|
||||
Args:
|
||||
schema: The schema dictionary to fix
|
||||
|
||||
Returns:
|
||||
dict: The fixed schema (the original is not mutated)
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
|
||||
# Create a copy to avoid modifying the original
|
||||
fixed_schema = schema.copy()
|
||||
|
||||
# Fix array types at the current level
|
||||
if fixed_schema.get("type") == "array" and "items" not in fixed_schema:
|
||||
# Add a default items schema for arrays without it
|
||||
fixed_schema["items"] = {"type": "string"}
|
||||
|
||||
# Recursively fix nested schemas in properties
|
||||
if "properties" in fixed_schema and isinstance(fixed_schema["properties"], dict):
|
||||
fixed_properties = {}
|
||||
for prop_name, prop_schema in fixed_schema["properties"].items():
|
||||
if isinstance(prop_schema, dict):
|
||||
fixed_properties[prop_name] = fix_array_schemas(prop_schema)
|
||||
else:
|
||||
fixed_properties[prop_name] = prop_schema
|
||||
fixed_schema["properties"] = fixed_properties
|
||||
|
||||
# Fix items schema if it exists
|
||||
if "items" in fixed_schema and isinstance(fixed_schema["items"], dict):
|
||||
fixed_schema["items"] = fix_array_schemas(fixed_schema["items"])
|
||||
|
||||
# Fix additionalProperties if it's a schema
|
||||
if isinstance(fixed_schema.get("additionalProperties"), dict):
|
||||
fixed_schema["additionalProperties"] = fix_array_schemas(
|
||||
fixed_schema["additionalProperties"]
|
||||
)
|
||||
|
||||
# Fix anyOf/oneOf/allOf schemas
|
||||
for key in ("anyOf", "oneOf", "allOf"):
|
||||
if key in fixed_schema and isinstance(fixed_schema[key], list):
|
||||
fixed_schema[key] = [fix_array_schemas(s) for s in fixed_schema[key]]
|
||||
|
||||
return fixed_schema
|
||||
@@ -632,39 +632,18 @@ class MCP:
|
||||
def _fix_array_schemas(self, schema):
|
||||
"""
|
||||
Fix array schemas by adding missing 'items' attribute required by OpenAI.
|
||||
|
||||
This ensures compatibility with OpenAI's function calling format which
|
||||
requires array types to specify the type of items they contain.
|
||||
|
||||
|
||||
Thin wrapper around the shared ``fix_array_schemas`` helper, kept for
|
||||
backward compatibility with any external subclassing.
|
||||
|
||||
Args:
|
||||
schema: The schema dictionary to fix
|
||||
|
||||
|
||||
Returns:
|
||||
dict: The fixed schema
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
|
||||
# Create a copy to avoid modifying the original
|
||||
fixed_schema = schema.copy()
|
||||
|
||||
# Fix array types at the current level
|
||||
if fixed_schema.get("type") == "array" and "items" not in fixed_schema:
|
||||
# Add a default items schema for arrays without it
|
||||
fixed_schema["items"] = {"type": "string"}
|
||||
|
||||
# Recursively fix nested schemas
|
||||
if "properties" in fixed_schema:
|
||||
fixed_properties = {}
|
||||
for prop_name, prop_schema in fixed_schema["properties"].items():
|
||||
fixed_properties[prop_name] = self._fix_array_schemas(prop_schema)
|
||||
fixed_schema["properties"] = fixed_properties
|
||||
|
||||
# Fix items schema if it exists
|
||||
if "items" in fixed_schema:
|
||||
fixed_schema["items"] = self._fix_array_schemas(fixed_schema["items"])
|
||||
|
||||
return fixed_schema
|
||||
from ..llm.schema_utils import fix_array_schemas
|
||||
return fix_array_schemas(schema)
|
||||
|
||||
def to_openai_tool(self):
|
||||
"""Convert the MCP tool to an OpenAI-compatible tool definition.
|
||||
|
||||
@@ -16,56 +16,26 @@ from typing import Any, Dict, Optional
|
||||
def fix_array_schemas(schema: Any) -> Any:
|
||||
"""
|
||||
Fix array schemas by adding missing 'items' attribute required by OpenAI.
|
||||
|
||||
This ensures compatibility with OpenAI's function calling format which
|
||||
requires array types to specify the type of items they contain.
|
||||
|
||||
|
||||
Thin wrapper around the single canonical ``fix_array_schemas`` helper in
|
||||
``praisonaiagents.llm.schema_utils``. Kept here for backward compatibility
|
||||
with the MCP transports (``mcp_sse``, ``mcp_http_stream``, ``mcp_websocket``)
|
||||
that import this symbol directly.
|
||||
|
||||
Args:
|
||||
schema: The schema dictionary to fix
|
||||
|
||||
|
||||
Returns:
|
||||
dict: The fixed schema with 'items' added to array types
|
||||
|
||||
|
||||
Example:
|
||||
>>> schema = {"type": "array"}
|
||||
>>> fixed = fix_array_schemas(schema)
|
||||
>>> fixed
|
||||
{'type': 'array', 'items': {'type': 'string'}}
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
|
||||
# Create a copy to avoid modifying the original
|
||||
fixed_schema = schema.copy()
|
||||
|
||||
# Fix array types at the current level
|
||||
if fixed_schema.get("type") == "array" and "items" not in fixed_schema:
|
||||
# Add a default items schema for arrays without it
|
||||
fixed_schema["items"] = {"type": "string"}
|
||||
|
||||
# Recursively fix nested schemas
|
||||
if "properties" in fixed_schema:
|
||||
fixed_properties = {}
|
||||
for prop_name, prop_schema in fixed_schema["properties"].items():
|
||||
fixed_properties[prop_name] = fix_array_schemas(prop_schema)
|
||||
fixed_schema["properties"] = fixed_properties
|
||||
|
||||
# Fix items schema if it exists
|
||||
if "items" in fixed_schema:
|
||||
fixed_schema["items"] = fix_array_schemas(fixed_schema["items"])
|
||||
|
||||
# Fix additionalProperties if it's a schema
|
||||
if isinstance(fixed_schema.get("additionalProperties"), dict):
|
||||
fixed_schema["additionalProperties"] = fix_array_schemas(
|
||||
fixed_schema["additionalProperties"]
|
||||
)
|
||||
|
||||
# Fix anyOf/oneOf/allOf schemas
|
||||
for key in ("anyOf", "oneOf", "allOf"):
|
||||
if key in fixed_schema and isinstance(fixed_schema[key], list):
|
||||
fixed_schema[key] = [fix_array_schemas(s) for s in fixed_schema[key]]
|
||||
|
||||
return fixed_schema
|
||||
from ..llm.schema_utils import fix_array_schemas as _fix_array_schemas
|
||||
return _fix_array_schemas(schema)
|
||||
|
||||
|
||||
class ThreadLocalEventLoop:
|
||||
|
||||
@@ -92,6 +92,12 @@ class LearnManager:
|
||||
self._stores: Dict[str, Any] = {}
|
||||
self._custom_stores = custom_stores or {}
|
||||
|
||||
# Retention policy (0 = unbounded; backward-compatible default)
|
||||
retention = {
|
||||
"max_entries": getattr(self.config, "max_entries", 0),
|
||||
"retention_days": getattr(self.config, "retention_days", 0),
|
||||
}
|
||||
|
||||
# Built-in stores are added first, custom stores can override them
|
||||
if self.config.persona and "persona" not in self._custom_stores:
|
||||
self._stores["persona"] = PersonaStore(
|
||||
@@ -99,6 +105,7 @@ class LearnManager:
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
**retention,
|
||||
)
|
||||
|
||||
if self.config.insights and "insights" not in self._custom_stores:
|
||||
@@ -107,6 +114,7 @@ class LearnManager:
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
**retention,
|
||||
)
|
||||
|
||||
if self.config.thread and "threads" not in self._custom_stores:
|
||||
@@ -115,6 +123,7 @@ class LearnManager:
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
**retention,
|
||||
)
|
||||
|
||||
if self.config.patterns and "patterns" not in self._custom_stores:
|
||||
@@ -123,6 +132,7 @@ class LearnManager:
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
**retention,
|
||||
)
|
||||
|
||||
if self.config.decisions and "decisions" not in self._custom_stores:
|
||||
@@ -131,6 +141,7 @@ class LearnManager:
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
**retention,
|
||||
)
|
||||
|
||||
if self.config.feedback and "feedback" not in self._custom_stores:
|
||||
@@ -139,6 +150,7 @@ class LearnManager:
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
**retention,
|
||||
)
|
||||
|
||||
if self.config.improvements and "improvements" not in self._custom_stores:
|
||||
@@ -147,6 +159,7 @@ class LearnManager:
|
||||
user_id=self.user_id,
|
||||
scope=scope,
|
||||
backend=self._backend,
|
||||
**retention,
|
||||
)
|
||||
|
||||
# Add custom stores last (they override built-in stores)
|
||||
@@ -315,6 +328,27 @@ class LearnManager:
|
||||
for store_name, store in self._stores.items()
|
||||
}
|
||||
|
||||
def prune(self) -> Dict[str, int]:
|
||||
"""Archive stale/excess entries across all stores (lifecycle governance).
|
||||
|
||||
Deterministic, usage-driven retention: entries unused beyond
|
||||
``retention_days`` and least-used/oldest entries beyond ``max_entries``
|
||||
are moved to a recoverable archive. No-op for stores without limits.
|
||||
|
||||
Returns:
|
||||
Dict mapping store name to number of entries archived.
|
||||
"""
|
||||
pruned = {}
|
||||
for store_name, store in self._stores.items():
|
||||
prune_fn = getattr(store, "prune", None)
|
||||
if callable(prune_fn):
|
||||
try:
|
||||
pruned[store_name] = prune_fn()
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to prune store '{store_name}': {e}")
|
||||
pruned[store_name] = 0
|
||||
return pruned
|
||||
|
||||
@property
|
||||
def pending_learnings(self) -> List[LearnEntry]:
|
||||
"""Get list of pending learnings awaiting approval (PROPOSE mode)."""
|
||||
|
||||
@@ -32,6 +32,8 @@ class LearnEntry:
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
updated_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
use_count: int = 0
|
||||
last_used: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -40,6 +42,8 @@ class LearnEntry:
|
||||
"metadata": self.metadata,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"use_count": self.use_count,
|
||||
"last_used": self.last_used,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -50,6 +54,8 @@ class LearnEntry:
|
||||
metadata=data.get("metadata", {}),
|
||||
created_at=data.get("created_at", datetime.utcnow().isoformat()),
|
||||
updated_at=data.get("updated_at", datetime.utcnow().isoformat()),
|
||||
use_count=data.get("use_count", 0),
|
||||
last_used=data.get("last_used"),
|
||||
)
|
||||
|
||||
|
||||
@@ -67,10 +73,15 @@ class BaseStore(ABC):
|
||||
user_id: Optional[str] = None,
|
||||
scope: str = "private",
|
||||
backend: Optional["StorageBackendProtocol"] = None,
|
||||
max_entries: int = 0,
|
||||
retention_days: int = 0,
|
||||
):
|
||||
self.user_id = user_id or "default"
|
||||
self.scope = scope
|
||||
self._backend = backend
|
||||
# Retention policy: 0 disables (permissive default — backward-compatible)
|
||||
self.max_entries = max_entries or 0
|
||||
self.retention_days = retention_days or 0
|
||||
self.store_path = store_path or self._default_path()
|
||||
self._entries: Dict[str, LearnEntry] = {}
|
||||
self._was_updated: bool = False
|
||||
@@ -151,10 +162,29 @@ class BaseStore(ABC):
|
||||
metadata=metadata or {},
|
||||
)
|
||||
self._entries[entry_id] = entry
|
||||
# Bounded retention: prune stale/excess entries on write (no-op if unconfigured).
|
||||
# Protect the entry we just added so a full store never evicts the new
|
||||
# learning and returns a value that is no longer retrievable.
|
||||
self.prune(save=False, protect_id=entry_id)
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return entry
|
||||
|
||||
def _touch(self, entries: List[LearnEntry]) -> None:
|
||||
"""Record usage telemetry for retrieved entries.
|
||||
|
||||
Bumps use_count/last_used so value is observable and retention is
|
||||
usage-aware. Persisted only when entries were actually touched.
|
||||
"""
|
||||
if not entries:
|
||||
return
|
||||
now = datetime.utcnow().isoformat()
|
||||
for entry in entries:
|
||||
entry.use_count += 1
|
||||
entry.last_used = now
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
|
||||
def get(self, entry_id: str) -> Optional[LearnEntry]:
|
||||
"""Get entry by ID."""
|
||||
return self._entries.get(entry_id)
|
||||
@@ -166,13 +196,17 @@ class BaseStore(ABC):
|
||||
entry for entry in self._entries.values()
|
||||
if query_lower in entry.content.lower()
|
||||
]
|
||||
return results[:limit]
|
||||
results = results[:limit]
|
||||
self._touch(results)
|
||||
return results
|
||||
|
||||
def list_all(self, limit: int = 100) -> List[LearnEntry]:
|
||||
"""List all entries."""
|
||||
entries = list(self._entries.values())
|
||||
entries.sort(key=lambda x: x.updated_at, reverse=True)
|
||||
return entries[:limit]
|
||||
entries = entries[:limit]
|
||||
self._touch(entries)
|
||||
return entries
|
||||
|
||||
def update(self, entry_id: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> Optional[LearnEntry]:
|
||||
"""Update an existing entry."""
|
||||
@@ -203,6 +237,106 @@ class BaseStore(ABC):
|
||||
self._save()
|
||||
self._was_updated = True
|
||||
return count
|
||||
|
||||
def _archive_path(self) -> Path:
|
||||
"""Path to the recoverable archive file for this store."""
|
||||
return Path(self.store_path).with_suffix(".archive.json")
|
||||
|
||||
def _archive(self, entries: List[LearnEntry]) -> bool:
|
||||
"""Append evicted entries to a recoverable archive (never hard-delete).
|
||||
|
||||
Returns True only when the entries were durably written, so callers can
|
||||
keep the active store and archive consistent (no silent data loss when
|
||||
the sidecar is unwritable).
|
||||
"""
|
||||
if not entries:
|
||||
return True
|
||||
try:
|
||||
import json
|
||||
archive_path = self._archive_path()
|
||||
existing: List[Dict[str, Any]] = []
|
||||
if archive_path.exists():
|
||||
with open(archive_path, "r", encoding="utf-8") as f:
|
||||
existing = json.load(f)
|
||||
if not isinstance(existing, list):
|
||||
existing = []
|
||||
existing.extend(e.to_dict() for e in entries)
|
||||
archive_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(archive_path, "w", encoding="utf-8") as f:
|
||||
json.dump(existing, f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to archive pruned entries: {e}")
|
||||
return False
|
||||
|
||||
def _is_stale(self, entry: LearnEntry, now: datetime) -> bool:
|
||||
"""Whether an entry exceeds the retention window (usage-driven, deterministic)."""
|
||||
if self.retention_days <= 0:
|
||||
return False
|
||||
reference = entry.last_used or entry.updated_at or entry.created_at
|
||||
try:
|
||||
ref_dt = datetime.fromisoformat(reference)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
age_days = (now - ref_dt).total_seconds() / 86400.0
|
||||
return age_days > self.retention_days
|
||||
|
||||
def prune(self, save: bool = True, protect_id: Optional[str] = None) -> int:
|
||||
"""Archive stale and least-used/oldest excess entries.
|
||||
|
||||
Deterministic, usage-driven retention (no LLM judgment):
|
||||
- Entries unused beyond ``retention_days`` are archived as stale.
|
||||
- When more than ``max_entries`` remain, the least-used (then oldest)
|
||||
are archived until the cap is met.
|
||||
|
||||
Archival is recoverable (entries moved to a ``.archive.json`` sidecar);
|
||||
never a silent hard-delete. Returns the number of entries archived.
|
||||
No-op (returns 0) when neither limit is configured — backward-compatible.
|
||||
|
||||
Args:
|
||||
save: Persist the active store after pruning.
|
||||
protect_id: Entry id that must never be evicted (e.g. a learning
|
||||
just added), so a full store cannot discard the new value.
|
||||
"""
|
||||
if self.max_entries <= 0 and self.retention_days <= 0:
|
||||
return 0
|
||||
|
||||
now = datetime.utcnow()
|
||||
evicted: List[LearnEntry] = []
|
||||
|
||||
# 1. Staleness: archive entries beyond the retention window
|
||||
if self.retention_days > 0:
|
||||
for entry_id, entry in list(self._entries.items()):
|
||||
if entry_id == protect_id:
|
||||
continue
|
||||
if self._is_stale(entry, now):
|
||||
evicted.append(self._entries.pop(entry_id))
|
||||
|
||||
# 2. Cap: archive least-used (then oldest) beyond max_entries
|
||||
if self.max_entries > 0 and len(self._entries) > self.max_entries:
|
||||
candidates = [
|
||||
e for e in self._entries.values() if e.id != protect_id
|
||||
]
|
||||
remaining = sorted(
|
||||
candidates,
|
||||
key=lambda e: (e.use_count, e.last_used or "", e.created_at),
|
||||
)
|
||||
overflow = len(self._entries) - self.max_entries
|
||||
for entry in remaining[:overflow]:
|
||||
evicted.append(self._entries.pop(entry.id))
|
||||
|
||||
if evicted:
|
||||
# Recoverable archival is mandatory: if the sidecar write fails,
|
||||
# restore the evicted entries rather than silently losing them.
|
||||
if not self._archive(evicted):
|
||||
for entry in evicted:
|
||||
self._entries[entry.id] = entry
|
||||
return 0
|
||||
self._was_updated = True
|
||||
if save:
|
||||
self._save()
|
||||
return len(evicted)
|
||||
|
||||
|
||||
class PersonaStore(BaseStore):
|
||||
|
||||
@@ -693,6 +693,30 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
return []
|
||||
|
||||
else:
|
||||
# Delegate to the active adapter when provider falls back to
|
||||
# sqlite/in_memory. This avoids the legacy short_mem schema mismatch
|
||||
# where the adapter creates short_term_memory but legacy queries
|
||||
# short_mem (which is never created by the adapter).
|
||||
if getattr(self, "provider", None) in ("sqlite", "in_memory") and getattr(self, "memory_adapter", None):
|
||||
try:
|
||||
adapter_results = self.memory_adapter.search_short_term(query, limit=limit, **kwargs)
|
||||
if min_quality > 0:
|
||||
adapter_results = [
|
||||
r for r in adapter_results
|
||||
if r.get("metadata", {}).get("quality", 0.0) >= min_quality
|
||||
]
|
||||
if relevance_cutoff > 0:
|
||||
adapter_results = [r for r in adapter_results if r.get("score", 1.0) >= relevance_cutoff]
|
||||
final_results = adapter_results[:limit]
|
||||
top_score = final_results[0].get("score") if final_results else None
|
||||
self._emit_memory_event("search", "short_term", query=query,
|
||||
result_count=len(final_results), top_score=top_score)
|
||||
return final_results
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Adapter search_short_term failed, using legacy fallback: {e}", logging.WARNING)
|
||||
# Avoid re-triggering the legacy short_mem schema mismatch.
|
||||
return []
|
||||
|
||||
# Local fallback
|
||||
conn = self._get_stm_conn()
|
||||
c = conn.cursor()
|
||||
@@ -770,8 +794,25 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
ident = str(time.time_ns())
|
||||
created = time.time()
|
||||
|
||||
# Protocol-driven storage: Try adapter first only when the provider has
|
||||
# fallen back to sqlite/in_memory. This keeps storage consistent with
|
||||
# search (which delegates to the adapter for the same providers) and
|
||||
# avoids the legacy long_mem schema mismatch. Other providers (chroma,
|
||||
# mem0, mongodb) keep their existing dedicated write paths below so the
|
||||
# embedding model and vector space stay consistent with search.
|
||||
adapter_success = False
|
||||
if (getattr(self, "provider", None) in ("sqlite", "in_memory")
|
||||
and getattr(self, "memory_adapter", None)):
|
||||
try:
|
||||
result_id = self.memory_adapter.store_long_term(text, metadata=metadata)
|
||||
logger.info(f"Successfully stored via memory adapter with ID: {result_id}")
|
||||
adapter_success = True
|
||||
ident = result_id # Use adapter-provided ID (mirrors store_short_term)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to store via memory adapter, falling back to direct storage: {e}")
|
||||
|
||||
# Store in MongoDB if enabled (first priority)
|
||||
if self.use_mongodb and hasattr(self, "mongo_long_term"):
|
||||
if not adapter_success and self.use_mongodb and hasattr(self, "mongo_long_term"):
|
||||
try:
|
||||
doc = {
|
||||
"_id": ident,
|
||||
@@ -793,24 +834,36 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
logger.error(f"Failed to store in MongoDB long-term memory: {e}")
|
||||
# Continue to SQLite fallback
|
||||
|
||||
# Store in SQLite (with write lock for concurrency safety)
|
||||
try:
|
||||
conn = self._get_ltm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
conn.execute(
|
||||
"INSERT INTO long_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
|
||||
(ident, text, json.dumps(metadata), created)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(f"Successfully stored in SQLite with ID: {ident}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error storing in SQLite: {e}")
|
||||
if not (self.use_mongodb and hasattr(self, "mongo_long_term")):
|
||||
# Only raise if MongoDB is not available as fallback
|
||||
return
|
||||
# Store in SQLite (with write lock for concurrency safety).
|
||||
# Skip the legacy long_mem schema for adapter-driven sqlite/in_memory
|
||||
# providers: those use the adapter's long_term_memory table and the
|
||||
# legacy long_mem table is never created, which would silently drop
|
||||
# the write. Surface the adapter failure instead.
|
||||
if not adapter_success and getattr(self, "provider", None) in ("sqlite", "in_memory") \
|
||||
and getattr(self, "memory_adapter", None):
|
||||
raise RuntimeError(
|
||||
"Long-term store failed via adapter for sqlite/in_memory provider; "
|
||||
"the legacy long_mem table is not schema-compatible."
|
||||
)
|
||||
|
||||
if not adapter_success:
|
||||
try:
|
||||
conn = self._get_ltm_conn()
|
||||
with self._write_lock: # Serialize write operations
|
||||
conn.execute(
|
||||
"INSERT INTO long_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
|
||||
(ident, text, json.dumps(metadata), created)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(f"Successfully stored in SQLite with ID: {ident}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error storing in SQLite: {e}")
|
||||
if not (self.use_mongodb and hasattr(self, "mongo_long_term")):
|
||||
# Only raise if MongoDB is not available as fallback
|
||||
raise
|
||||
|
||||
# Store in vector database if enabled
|
||||
if self.use_rag and hasattr(self, "chroma_col"):
|
||||
if not adapter_success and self.use_rag and hasattr(self, "chroma_col"):
|
||||
try:
|
||||
from praisonaiagents.embedding import embedding as get_embedding
|
||||
|
||||
@@ -841,7 +894,7 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
except Exception as e:
|
||||
logger.error(f"Error storing in ChromaDB: {e}")
|
||||
|
||||
elif self.use_mem0 and hasattr(self, "mem0_client"):
|
||||
elif not adapter_success and self.use_mem0 and hasattr(self, "mem0_client"):
|
||||
try:
|
||||
self.mem0_client.add(text, metadata=metadata)
|
||||
logger.info("Successfully stored in Mem0")
|
||||
@@ -980,6 +1033,29 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Error searching ChromaDB: {e}", logging.ERROR)
|
||||
|
||||
# Delegate to the active adapter when provider falls back to
|
||||
# sqlite/in_memory. This avoids the legacy long_mem schema mismatch
|
||||
# where the adapter creates long_term_memory but legacy queries long_mem.
|
||||
if not found and getattr(self, "provider", None) in ("sqlite", "in_memory") and getattr(self, "memory_adapter", None):
|
||||
try:
|
||||
adapter_results = self.memory_adapter.search_long_term(query, limit=limit, **kwargs)
|
||||
if min_quality > 0:
|
||||
adapter_results = [
|
||||
r for r in adapter_results
|
||||
if r.get("metadata", {}).get("quality", 0.0) >= min_quality
|
||||
]
|
||||
if relevance_cutoff > 0:
|
||||
adapter_results = [r for r in adapter_results if r.get("score", 1.0) >= relevance_cutoff]
|
||||
final_results = adapter_results[:limit]
|
||||
top_score = final_results[0].get("score") if final_results else None
|
||||
self._emit_memory_event("search", "long_term", query=query,
|
||||
result_count=len(final_results), top_score=top_score)
|
||||
return final_results
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Adapter search_long_term failed, using legacy fallback: {e}", logging.WARNING)
|
||||
# Avoid re-triggering the legacy long_mem schema mismatch.
|
||||
return []
|
||||
|
||||
# Always try SQLite as fallback or additional source
|
||||
conn = self._get_ltm_conn()
|
||||
c = conn.cursor()
|
||||
|
||||
@@ -76,7 +76,10 @@ def __getattr__(name: str):
|
||||
return StylePreset
|
||||
|
||||
if name == "OutputConfig":
|
||||
from .config import OutputConfig
|
||||
# Consolidated to the canonical config.OutputConfig (see issue #2294).
|
||||
# The former divergent dataclass in output/config.py was unused; this
|
||||
# import path is preserved for backward compatibility.
|
||||
from ..config import OutputConfig
|
||||
return OutputConfig
|
||||
|
||||
# Status output (for status preset - no timestamps)
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
"""
|
||||
Output Configuration for PraisonAI Agents.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputConfig:
|
||||
"""Configuration for output formatting."""
|
||||
enabled: bool = True
|
||||
default_format: str = "markdown" # markdown, plain, json
|
||||
max_length: Optional[int] = None
|
||||
truncate_long_responses: bool = False
|
||||
include_metadata: bool = False
|
||||
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
Shell command parser for command-aware permission matching.
|
||||
|
||||
Decomposes a shell command string into its constituent simple-commands,
|
||||
handling compound operators (``&&``, ``||``, ``;``, ``|``), subshells and
|
||||
command substitution (``$(...)`` / backticks). Each resulting operation
|
||||
exposes its executable, arguments and redirection targets so the permission
|
||||
engine can evaluate file-mutating operations regardless of where they appear
|
||||
in a compound statement.
|
||||
|
||||
Design goals:
|
||||
- Dependency-light: stdlib ``shlex`` plus a small hand-rolled splitter.
|
||||
- Best-effort and conservative: on any parse failure we fall back to treating
|
||||
the whole command as a single operation (today's behaviour) so a rule is
|
||||
never silently weakened.
|
||||
"""
|
||||
|
||||
import shlex
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
|
||||
|
||||
# Operators that separate simple-commands within a compound command.
|
||||
_SEPARATORS = ("&&", "||", ";", "|", "&", "\n")
|
||||
|
||||
# Redirection operators that truncate/overwrite or append to a file.
|
||||
# These produce an additional ``write:<path>`` sub-target.
|
||||
_WRITE_REDIRECTS = (">", ">>", ">|", "&>", "&>>")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShellOp:
|
||||
"""A single simple-command extracted from a shell command line.
|
||||
|
||||
Attributes:
|
||||
executable: The command name (e.g. ``rm``), or ``""`` if unknown.
|
||||
args: Positional/flag arguments following the executable.
|
||||
write_targets: Paths that are written/truncated via redirection.
|
||||
"""
|
||||
|
||||
executable: str = ""
|
||||
args: List[str] = field(default_factory=list)
|
||||
write_targets: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def command_string(self) -> str:
|
||||
"""Reconstruct an ``<exe> <args>`` string for glob matching."""
|
||||
parts = [self.executable, *self.args]
|
||||
return " ".join(p for p in parts if p)
|
||||
|
||||
|
||||
def _extract_substitutions(token: str) -> List[str]:
|
||||
"""Extract inner commands from ``$(...)`` and backtick substitutions.
|
||||
|
||||
Returns a list of inner command strings (without the surrounding syntax).
|
||||
Best-effort; handles simple, non-nested cases which cover the common
|
||||
evasion vectors.
|
||||
"""
|
||||
inner: List[str] = []
|
||||
|
||||
# Mask single-quoted spans so substitutions inside them are treated as the
|
||||
# literals the shell would see (e.g. ``echo '$(rm -rf x)'`` is harmless).
|
||||
# Double quotes do *not* suppress substitution in the shell, so we leave
|
||||
# those spans intact.
|
||||
masked_chars = []
|
||||
in_single = False
|
||||
for ch in token:
|
||||
if ch == "'":
|
||||
in_single = not in_single
|
||||
masked_chars.append("\x00")
|
||||
continue
|
||||
masked_chars.append("\x00" if in_single else ch)
|
||||
masked = "".join(masked_chars)
|
||||
|
||||
# $(...) substitutions
|
||||
start = 0
|
||||
while True:
|
||||
idx = masked.find("$(", start)
|
||||
if idx == -1:
|
||||
break
|
||||
depth = 0
|
||||
end = -1
|
||||
for i in range(idx + 1, len(masked)):
|
||||
ch = masked[i]
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i
|
||||
break
|
||||
if end == -1:
|
||||
break
|
||||
inner.append(token[idx + 2:end])
|
||||
start = end + 1
|
||||
|
||||
# `...` backtick substitutions
|
||||
parts = masked.split("`")
|
||||
if len(parts) >= 3:
|
||||
# Odd-indexed segments are inside backticks. Use original-text offsets
|
||||
# so the extracted command retains its real characters.
|
||||
offset = 0
|
||||
for i, part in enumerate(parts):
|
||||
seg_start = offset
|
||||
seg_end = offset + len(part)
|
||||
if i % 2 == 1 and part.strip():
|
||||
inner.append(token[seg_start:seg_end])
|
||||
offset = seg_end + 1 # account for the backtick delimiter
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
def _split_simple_commands(cmd: str) -> List[str]:
|
||||
"""Split a command line into simple-command segments on shell separators.
|
||||
|
||||
Respects single/double quotes so separators inside quotes are ignored.
|
||||
Subshell parentheses are stripped and their contents treated as segments.
|
||||
"""
|
||||
segments: List[str] = []
|
||||
current = []
|
||||
i = 0
|
||||
n = len(cmd)
|
||||
quote = None
|
||||
|
||||
def flush():
|
||||
seg = "".join(current).strip()
|
||||
if seg:
|
||||
segments.append(seg)
|
||||
current.clear()
|
||||
|
||||
while i < n:
|
||||
ch = cmd[i]
|
||||
|
||||
if quote:
|
||||
current.append(ch)
|
||||
if ch == quote:
|
||||
quote = None
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch in ("'", '"'):
|
||||
quote = ch
|
||||
current.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Subshell grouping: treat parens as separators (strip them).
|
||||
if ch in ("(", ")"):
|
||||
flush()
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Two-character separators first.
|
||||
two = cmd[i:i + 2]
|
||||
if two in ("&&", "||"):
|
||||
flush()
|
||||
i += 2
|
||||
continue
|
||||
|
||||
if ch in (";", "|", "&", "\n"):
|
||||
flush()
|
||||
i += 1
|
||||
continue
|
||||
|
||||
current.append(ch)
|
||||
i += 1
|
||||
|
||||
flush()
|
||||
return segments
|
||||
|
||||
|
||||
def _parse_segment(segment: str) -> List[ShellOp]:
|
||||
"""Parse a single simple-command segment into ShellOp(s).
|
||||
|
||||
May return multiple ops when the segment embeds command substitutions.
|
||||
"""
|
||||
ops: List[ShellOp] = []
|
||||
|
||||
# First, recurse into any command substitutions so e.g. ``$(rm -rf x)``
|
||||
# is evaluated as an ``rm`` operation.
|
||||
for inner in _extract_substitutions(segment):
|
||||
ops.extend(parse_command(inner))
|
||||
|
||||
try:
|
||||
tokens = shlex.split(segment, comments=False, posix=True)
|
||||
except ValueError:
|
||||
# Unbalanced quotes etc. — conservative fallback: whole segment.
|
||||
tokens = segment.split()
|
||||
|
||||
op = ShellOp()
|
||||
args: List[str] = []
|
||||
skip_next = False
|
||||
skip_input_target = False
|
||||
|
||||
for tok in tokens:
|
||||
if skip_next:
|
||||
op.write_targets.append(tok)
|
||||
skip_next = False
|
||||
continue
|
||||
|
||||
if skip_input_target:
|
||||
skip_input_target = False
|
||||
continue
|
||||
|
||||
# Skip leading environment-variable assignments (FOO=bar cmd).
|
||||
if op.executable == "" and not args and "=" in tok and tok.split("=", 1)[0].isidentifier():
|
||||
continue
|
||||
|
||||
matched_redirect = False
|
||||
for redir in sorted(_WRITE_REDIRECTS, key=len, reverse=True):
|
||||
if tok == redir:
|
||||
skip_next = True
|
||||
matched_redirect = True
|
||||
break
|
||||
if tok.startswith(redir) and len(tok) > len(redir):
|
||||
op.write_targets.append(tok[len(redir):])
|
||||
matched_redirect = True
|
||||
break
|
||||
# Forms like 2> or 1>> (fd-prefixed redirect).
|
||||
if redir.startswith(">"):
|
||||
stripped = tok.lstrip("0123456789")
|
||||
if stripped == redir and stripped != tok:
|
||||
skip_next = True
|
||||
matched_redirect = True
|
||||
break
|
||||
if stripped.startswith(redir) and len(stripped) > len(redir) and stripped != tok:
|
||||
dest = stripped[len(redir):]
|
||||
# Skip fd-to-fd redirections like ``2>&1`` (dest is ``&N``),
|
||||
# which alias a file descriptor rather than writing a path.
|
||||
if not dest.startswith("&"):
|
||||
op.write_targets.append(dest)
|
||||
matched_redirect = True
|
||||
break
|
||||
if matched_redirect:
|
||||
continue
|
||||
|
||||
# Ignore input redirects and their target (consume the next token so a
|
||||
# filename like ``< /dev/null`` is never mistaken for the executable).
|
||||
if tok in ("<", "<<", "<<<"):
|
||||
skip_input_target = True
|
||||
continue
|
||||
stripped_input = tok.lstrip("0123456789")
|
||||
if stripped_input in ("<", "<<", "<<<") and stripped_input != tok:
|
||||
skip_input_target = True
|
||||
continue
|
||||
if tok.startswith("<") or (
|
||||
stripped_input.startswith("<") and stripped_input != tok
|
||||
):
|
||||
# Inline form like ``<file`` or ``<<<word`` — target is attached.
|
||||
continue
|
||||
|
||||
if op.executable == "":
|
||||
op.executable = tok
|
||||
else:
|
||||
args.append(tok)
|
||||
|
||||
op.args = args
|
||||
|
||||
if op.executable or op.write_targets:
|
||||
ops.append(op)
|
||||
|
||||
return ops
|
||||
|
||||
|
||||
def parse_command(cmd: str) -> List[ShellOp]:
|
||||
"""Parse a shell command string into a list of ShellOp operations.
|
||||
|
||||
Args:
|
||||
cmd: The raw shell command (without the ``bash:``/``shell:`` prefix).
|
||||
|
||||
Returns:
|
||||
A list of ShellOp. On parse failure, returns a single best-effort
|
||||
ShellOp wrapping the whole command so existing behaviour is preserved.
|
||||
"""
|
||||
if not cmd or not cmd.strip():
|
||||
return []
|
||||
|
||||
try:
|
||||
segments = _split_simple_commands(cmd)
|
||||
ops: List[ShellOp] = []
|
||||
for seg in segments:
|
||||
ops.extend(_parse_segment(seg))
|
||||
|
||||
if not ops:
|
||||
# Nothing extracted — fall back to whole command.
|
||||
return [_fallback_op(cmd)]
|
||||
return ops
|
||||
except Exception: # noqa: BLE001 - conservative: never weaken a rule on parse failure
|
||||
# Any unexpected failure must not weaken the rule: fall back.
|
||||
return [_fallback_op(cmd)]
|
||||
|
||||
|
||||
def _fallback_op(cmd: str) -> ShellOp:
|
||||
"""Build a single ShellOp representing the whole command (legacy path)."""
|
||||
tokens = cmd.split()
|
||||
if not tokens:
|
||||
return ShellOp()
|
||||
return ShellOp(executable=tokens[0], args=tokens[1:])
|
||||
@@ -196,6 +196,10 @@ class PermissionManager:
|
||||
if r.agent_name is None or r.agent_name == agent_name
|
||||
]
|
||||
|
||||
# Prefixes that should be decomposed into command-structure-aware
|
||||
# sub-targets before matching (command-aware permission matching).
|
||||
_SHELL_PREFIXES = ("bash:", "shell:")
|
||||
|
||||
def check(self, target: str, agent_name: Optional[str] = None) -> PermissionResult:
|
||||
"""
|
||||
Check permission for a target.
|
||||
@@ -208,7 +212,23 @@ class PermissionManager:
|
||||
PermissionResult with the decision
|
||||
"""
|
||||
agent = agent_name or self.agent_name
|
||||
|
||||
|
||||
# Command-aware matching: shell targets are decomposed into their
|
||||
# constituent operations so deny rules fire regardless of where the
|
||||
# operation appears in a compound command (&&, ;, |, $(), redirects).
|
||||
prefix = next(
|
||||
(p for p in self._SHELL_PREFIXES if target.startswith(p)), None
|
||||
)
|
||||
if prefix is not None:
|
||||
command = target[len(prefix):]
|
||||
structured = self._check_shell_command(prefix, command, agent)
|
||||
if structured is not None:
|
||||
return structured
|
||||
|
||||
return self._check_flat(target, agent)
|
||||
|
||||
def _check_flat(self, target: str, agent: Optional[str]) -> PermissionResult:
|
||||
"""Legacy flat matching against approvals and rules for a target."""
|
||||
with self._lock:
|
||||
# Check persistent approvals first
|
||||
for approval in self._approvals:
|
||||
@@ -239,7 +259,75 @@ class PermissionManager:
|
||||
target=target,
|
||||
reason="No matching rule, requires approval",
|
||||
)
|
||||
|
||||
|
||||
def _check_shell_command(
|
||||
self, prefix: str, command: str, agent: Optional[str]
|
||||
) -> Optional[PermissionResult]:
|
||||
"""Command-structure-aware check for a shell command target.
|
||||
|
||||
Decomposes the command into its constituent operations and evaluates
|
||||
each as its own sub-target. Aggregates with deny-wins, then ask,
|
||||
then allow semantics. Returns ``None`` to defer to flat matching when
|
||||
decomposition yields a single operation identical to the original
|
||||
target (no compound structure), preserving legacy behaviour exactly.
|
||||
"""
|
||||
original_target = prefix + command
|
||||
try:
|
||||
from .command_parser import parse_command
|
||||
ops = parse_command(command)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Build the list of sub-targets to evaluate.
|
||||
sub_targets: List[str] = []
|
||||
for op in ops:
|
||||
cmd_str = op.command_string
|
||||
if cmd_str:
|
||||
sub_targets.append(prefix + cmd_str)
|
||||
for path in op.write_targets:
|
||||
sub_targets.append(f"write:{path}")
|
||||
|
||||
# No structured decomposition possible, or it collapses to exactly the
|
||||
# original target — defer to the existing flat matcher.
|
||||
if not sub_targets or sub_targets == [original_target]:
|
||||
return None
|
||||
|
||||
results = [(t, self._check_flat(t, agent)) for t in sub_targets]
|
||||
|
||||
# Also fold in an explicit match on the *original* compound target so a
|
||||
# legacy flat rule/approval (e.g. ``deny: bash:cd /tmp && rm *``) still
|
||||
# participates. The default "No matching rule" ASK is intentionally
|
||||
# excluded so it does not override allowed sub-operations.
|
||||
original_res = self._check_flat(original_target, agent)
|
||||
if (original_res.rule is not None) or original_res.approved is not None:
|
||||
results.append((original_target, original_res))
|
||||
|
||||
# Deny wins.
|
||||
for sub_target, res in results:
|
||||
if res.action == PermissionAction.DENY:
|
||||
res.reason = (
|
||||
f"Denied sub-operation '{sub_target}' ({res.reason})"
|
||||
)
|
||||
res.target = original_target
|
||||
return res
|
||||
|
||||
# Then ask: if any sub-operation requires approval.
|
||||
for sub_target, res in results:
|
||||
if res.action == PermissionAction.ASK:
|
||||
res.reason = (
|
||||
f"Sub-operation '{sub_target}' requires approval "
|
||||
f"({res.reason})"
|
||||
)
|
||||
res.target = original_target
|
||||
return res
|
||||
|
||||
# All allowed.
|
||||
return PermissionResult(
|
||||
action=PermissionAction.ALLOW,
|
||||
target=original_target,
|
||||
reason="All shell sub-operations allowed",
|
||||
)
|
||||
|
||||
def approve(
|
||||
self,
|
||||
target: str,
|
||||
|
||||
@@ -407,6 +407,16 @@ class PluginManager:
|
||||
Returns:
|
||||
Number of plugins loaded
|
||||
"""
|
||||
import os
|
||||
|
||||
if os.environ.get("PRAISONAI_ALLOW_PLUGIN_DISCOVERY", "").strip().lower() not in (
|
||||
"true", "1", "yes",
|
||||
):
|
||||
logger.debug(
|
||||
"Plugin auto-discovery disabled; set PRAISONAI_ALLOW_PLUGIN_DISCOVERY=true"
|
||||
)
|
||||
return 0
|
||||
|
||||
from .discovery import discover_and_load_plugins
|
||||
|
||||
loaded = discover_and_load_plugins(plugin_dirs=None, include_defaults=True)
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
"""
|
||||
Runtime execution components for PraisonAI agents.
|
||||
Runtime execution components and capability system for PraisonAI agents.
|
||||
|
||||
This module provides runtime execution abstractions and protocols for
|
||||
standardizing agent execution across different harness types.
|
||||
This module provides runtime execution abstractions, protocols, and capability
|
||||
validation for standardizing agent execution across different harness types.
|
||||
|
||||
Key Components:
|
||||
- Runtime execution contexts: PreparedTurnContext, TurnRuntimeProtocol
|
||||
- Agent runtime registry: AgentRuntimeProtocol, RuntimeRegistry
|
||||
- Tool result middleware: RuntimeToolResultMiddleware, NormalizedToolResult
|
||||
- Registry patterns for runtime resolution and middleware management
|
||||
- Doctor migration protocol: DoctorContractProtocol for config migration
|
||||
- Capability validation: RuntimeCapability, RuntimeCapabilityMatrix, validate_capabilities
|
||||
- Runtime protocols: AgentRuntimeProtocol for capability reporting
|
||||
- Turn-time runtime resolution for handoffs and sub-agents
|
||||
|
||||
Features:
|
||||
- Turn-based runtime execution with PreparedTurnContext
|
||||
- RuntimeCapability flags for feature detection
|
||||
- RuntimeCapabilityMatrix for runtime capability sets
|
||||
- Capability validation at runtime selection time
|
||||
- Support for native, plugin harness, and managed runtimes
|
||||
- Dynamic runtime resolution at handoff boundaries
|
||||
"""
|
||||
|
||||
from .._lazy import create_lazy_getattr_with_groups
|
||||
@@ -47,7 +59,30 @@ __all__ = [
|
||||
'RuntimeRegistry',
|
||||
'register_runtime',
|
||||
'list_runtimes',
|
||||
# Turn-time runtime resolution for handoffs
|
||||
'resolve_runtime',
|
||||
'RuntimeProtocol',
|
||||
'SessionContext',
|
||||
'get_runtime_cache',
|
||||
'clear_runtime_cache',
|
||||
'set_global_resolver',
|
||||
# Doctor migration protocol
|
||||
"DoctorContractProtocol",
|
||||
"Finding",
|
||||
"get_default_registry",
|
||||
"register_rule",
|
||||
"get_rules",
|
||||
"collect_findings",
|
||||
"apply_fixes",
|
||||
# Capability types
|
||||
"RuntimeCapability",
|
||||
"RuntimeCapabilityMatrix",
|
||||
# Validation
|
||||
"validate_capabilities",
|
||||
"CapabilityValidationError",
|
||||
# Built-in runtimes
|
||||
"get_native_runtime_capabilities",
|
||||
"get_reduced_harness_capabilities",
|
||||
]
|
||||
|
||||
# Grouped lazy imports for efficient loading
|
||||
@@ -92,7 +127,33 @@ _LAZY_GROUPS = {
|
||||
'RuntimeRegistry': ('praisonaiagents.runtime.registry', 'RuntimeRegistry'),
|
||||
'register_runtime': ('praisonaiagents.runtime.registry', 'register_runtime'),
|
||||
'list_runtimes': ('praisonaiagents.runtime.registry', 'list_runtimes'),
|
||||
'resolve_runtime': ('praisonaiagents.runtime.registry', 'resolve_runtime'),
|
||||
},
|
||||
'resolve': {
|
||||
'resolve_runtime': ('praisonaiagents.runtime.resolve', 'resolve_runtime'),
|
||||
'RuntimeProtocol': ('praisonaiagents.runtime.resolve', 'RuntimeProtocol'),
|
||||
'SessionContext': ('praisonaiagents.runtime.resolve', 'SessionContext'),
|
||||
'get_runtime_cache': ('praisonaiagents.runtime.resolve', 'get_runtime_cache'),
|
||||
'clear_runtime_cache': ('praisonaiagents.runtime.resolve', 'clear_runtime_cache'),
|
||||
'set_global_resolver': ('praisonaiagents.runtime.resolve', 'set_global_resolver'),
|
||||
},
|
||||
'capabilities': {
|
||||
'RuntimeCapability': ('praisonaiagents.runtime.capabilities', 'RuntimeCapability'),
|
||||
'RuntimeCapabilityMatrix': ('praisonaiagents.runtime.capabilities', 'RuntimeCapabilityMatrix'),
|
||||
'validate_capabilities': ('praisonaiagents.runtime.capabilities', 'validate_capabilities'),
|
||||
'CapabilityValidationError': ('praisonaiagents.runtime.capabilities', 'CapabilityValidationError'),
|
||||
'get_native_runtime_capabilities': ('praisonaiagents.runtime.capabilities', 'get_native_runtime_capabilities'),
|
||||
'get_reduced_harness_capabilities': ('praisonaiagents.runtime.capabilities', 'get_reduced_harness_capabilities'),
|
||||
},
|
||||
'doctor_protocol': {
|
||||
'DoctorContractProtocol': ('praisonaiagents.runtime.doctor_protocol', 'DoctorContractProtocol'),
|
||||
'Finding': ('praisonaiagents.runtime.doctor_protocol', 'Finding'),
|
||||
},
|
||||
'doctor_registry': {
|
||||
'get_default_registry': ('praisonaiagents.runtime.doctor_registry', 'get_default_registry'),
|
||||
'register_rule': ('praisonaiagents.runtime.doctor_registry', 'register_rule'),
|
||||
'get_rules': ('praisonaiagents.runtime.doctor_registry', 'get_rules'),
|
||||
'collect_findings': ('praisonaiagents.runtime.doctor_registry', 'collect_findings'),
|
||||
'apply_fixes': ('praisonaiagents.runtime.doctor_registry', 'apply_fixes'),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -189,4 +189,4 @@ class PraisonAIRuntime:
|
||||
type="error",
|
||||
content=str(e),
|
||||
metadata={'runtime': self.runtime_id}
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Built-in migration rules for doctor.
|
||||
|
||||
Contains core migration rules that ship with PraisonAI,
|
||||
including the cli_backend migration rule.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
from .doctor_protocol import DoctorContractProtocol, Finding
|
||||
|
||||
|
||||
class CliBackendMigrationRule:
|
||||
"""
|
||||
Built-in rule to migrate cli_backend to models.<default>.runtime.
|
||||
|
||||
Maps known cli_backend values to runtime configuration under
|
||||
the default model configuration.
|
||||
"""
|
||||
|
||||
# Mapping table for known cli_backend values
|
||||
CLI_BACKEND_MAPPING = {
|
||||
"claude-code": "claude-code",
|
||||
"openai-gpt": "openai-gpt",
|
||||
"anthropic": "anthropic",
|
||||
"gemini": "gemini",
|
||||
# Add more mappings as needed
|
||||
}
|
||||
|
||||
@property
|
||||
def rule_id(self) -> str:
|
||||
return "cli_backend_migration"
|
||||
|
||||
def collect_findings(self, config: Dict[str, Any]) -> List[Finding]:
|
||||
"""Detect cli_backend usage in configuration."""
|
||||
findings = []
|
||||
|
||||
# Check for cli_backend in top-level config
|
||||
if "cli_backend" in config:
|
||||
backend_value = config["cli_backend"]
|
||||
findings.append(Finding(
|
||||
rule_id=self.rule_id,
|
||||
severity="warning",
|
||||
message=f"Legacy field 'cli_backend: {backend_value}' should be migrated to model-scoped runtime configuration",
|
||||
fix_description=f"Move cli_backend to models.<default>.runtime: {self._map_backend(backend_value)}",
|
||||
context={"field": "cli_backend", "value": backend_value, "location": "root"}
|
||||
))
|
||||
|
||||
# Check for cli_backend in roles
|
||||
if "roles" in config and isinstance(config["roles"], dict):
|
||||
for role_name, role_config in config["roles"].items():
|
||||
if isinstance(role_config, dict) and "cli_backend" in role_config:
|
||||
backend_value = role_config["cli_backend"]
|
||||
findings.append(Finding(
|
||||
rule_id=self.rule_id,
|
||||
severity="warning",
|
||||
message=f"Legacy field 'cli_backend: {backend_value}' in role '{role_name}' should be migrated",
|
||||
fix_description=f"Move cli_backend to models.<default>.runtime: {self._map_backend(backend_value)}",
|
||||
context={"field": "cli_backend", "value": backend_value, "location": f"roles.{role_name}"}
|
||||
))
|
||||
|
||||
return findings
|
||||
|
||||
def apply_fix(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Apply cli_backend migration to configuration."""
|
||||
import copy
|
||||
result = copy.deepcopy(config)
|
||||
|
||||
# Migrate top-level cli_backend
|
||||
if "cli_backend" in result:
|
||||
backend_value = result.pop("cli_backend")
|
||||
runtime_value = self._map_backend(backend_value)
|
||||
|
||||
# Ensure models structure exists
|
||||
if "models" not in result:
|
||||
result["models"] = {}
|
||||
if "default" not in result["models"]:
|
||||
result["models"]["default"] = {}
|
||||
|
||||
result["models"]["default"]["runtime"] = runtime_value
|
||||
|
||||
# Migrate cli_backend in roles
|
||||
if "roles" in result and isinstance(result["roles"], dict):
|
||||
for role_name, role_config in result["roles"].items():
|
||||
if isinstance(role_config, dict) and "cli_backend" in role_config:
|
||||
backend_value = role_config.pop("cli_backend")
|
||||
runtime_value = self._map_backend(backend_value)
|
||||
|
||||
# Ensure models structure exists in role
|
||||
if "models" not in role_config:
|
||||
role_config["models"] = {}
|
||||
if "default" not in role_config["models"]:
|
||||
role_config["models"]["default"] = {}
|
||||
|
||||
role_config["models"]["default"]["runtime"] = runtime_value
|
||||
|
||||
return result
|
||||
|
||||
def _map_backend(self, backend_value: str) -> str:
|
||||
"""Map legacy cli_backend value to runtime id."""
|
||||
return self.CLI_BACKEND_MAPPING.get(backend_value, backend_value)
|
||||
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Runtime Capability System for PraisonAI Agents.
|
||||
|
||||
Defines capability flags and validation logic to detect runtime incompatibilities
|
||||
at config/preflight time rather than during agent execution.
|
||||
|
||||
This addresses the issue where hooks, tools, streaming, and context compaction
|
||||
behave differently across native and plugin harness runtimes, causing users
|
||||
to discover incompatibilities mid-turn instead of at startup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import Set, Optional, Dict, Any, Union
|
||||
|
||||
|
||||
class RuntimeCapability(Enum):
|
||||
"""
|
||||
Enumeration of runtime capabilities that agents may require.
|
||||
|
||||
Used to declare what features a runtime supports and what features
|
||||
an agent/team configuration requires.
|
||||
"""
|
||||
# Hook system capabilities
|
||||
NATIVE_HOOKS = auto() # Supports in-process hook execution around tool/LLM events
|
||||
|
||||
# Core execution capabilities
|
||||
TOOL_LOOP = auto() # Supports full agent tool execution loop
|
||||
STREAMING_DELTAS = auto() # Supports real-time streaming of incremental responses
|
||||
CONTEXT_COMPACTION = auto() # Supports automatic context size management
|
||||
|
||||
# Tool and integration capabilities
|
||||
MCP_TOOLS = auto() # Supports Model Context Protocol tools
|
||||
CODE_EXECUTION = auto() # Supports safe code execution in sandboxes
|
||||
|
||||
# Advanced capabilities
|
||||
MULTI_MODAL = auto() # Supports images, audio, video processing
|
||||
ASYNC_EXECUTION = auto() # Supports asynchronous operation
|
||||
SESSION_PERSISTENCE = auto() # Supports persistent sessions across executions
|
||||
MEMORY_MANAGEMENT = auto() # Supports advanced memory features
|
||||
|
||||
# Plugin harness capabilities (typically reduced subset)
|
||||
BASIC_CHAT = auto() # Basic text chat only
|
||||
SIMPLE_TOOLS = auto() # Simple synchronous tool execution only
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeCapabilityMatrix:
|
||||
"""
|
||||
Matrix of capabilities supported by a specific runtime implementation.
|
||||
|
||||
Each runtime (native, plugin harness, managed service) declares its
|
||||
capability set using this matrix. Agent configurations can specify
|
||||
required capabilities, and validation occurs at runtime selection.
|
||||
|
||||
Example:
|
||||
# Full native runtime
|
||||
native_matrix = RuntimeCapabilityMatrix(
|
||||
native_hooks=True,
|
||||
tool_loop=True,
|
||||
streaming_deltas=True,
|
||||
context_compaction=True,
|
||||
mcp_tools=True,
|
||||
code_execution=True,
|
||||
multi_modal=True,
|
||||
async_execution=True,
|
||||
session_persistence=True,
|
||||
memory_management=True
|
||||
)
|
||||
|
||||
# Limited plugin harness
|
||||
plugin_matrix = RuntimeCapabilityMatrix(
|
||||
basic_chat=True,
|
||||
simple_tools=True,
|
||||
# All other capabilities default to False
|
||||
)
|
||||
"""
|
||||
|
||||
# Hook system capabilities
|
||||
native_hooks: bool = False
|
||||
|
||||
# Core execution capabilities
|
||||
tool_loop: bool = False
|
||||
streaming_deltas: bool = False
|
||||
context_compaction: bool = False
|
||||
|
||||
# Tool and integration capabilities
|
||||
mcp_tools: bool = False
|
||||
code_execution: bool = False
|
||||
|
||||
# Advanced capabilities
|
||||
multi_modal: bool = False
|
||||
async_execution: bool = False
|
||||
session_persistence: bool = False
|
||||
memory_management: bool = False
|
||||
|
||||
# Basic capabilities (typically for constrained environments)
|
||||
basic_chat: bool = False
|
||||
simple_tools: bool = False
|
||||
|
||||
# Additional metadata
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_capability_set(self) -> Set[RuntimeCapability]:
|
||||
"""Convert matrix to set of enabled capabilities."""
|
||||
capabilities = set()
|
||||
|
||||
if self.native_hooks:
|
||||
capabilities.add(RuntimeCapability.NATIVE_HOOKS)
|
||||
if self.tool_loop:
|
||||
capabilities.add(RuntimeCapability.TOOL_LOOP)
|
||||
if self.streaming_deltas:
|
||||
capabilities.add(RuntimeCapability.STREAMING_DELTAS)
|
||||
if self.context_compaction:
|
||||
capabilities.add(RuntimeCapability.CONTEXT_COMPACTION)
|
||||
if self.mcp_tools:
|
||||
capabilities.add(RuntimeCapability.MCP_TOOLS)
|
||||
if self.code_execution:
|
||||
capabilities.add(RuntimeCapability.CODE_EXECUTION)
|
||||
if self.multi_modal:
|
||||
capabilities.add(RuntimeCapability.MULTI_MODAL)
|
||||
if self.async_execution:
|
||||
capabilities.add(RuntimeCapability.ASYNC_EXECUTION)
|
||||
if self.session_persistence:
|
||||
capabilities.add(RuntimeCapability.SESSION_PERSISTENCE)
|
||||
if self.memory_management:
|
||||
capabilities.add(RuntimeCapability.MEMORY_MANAGEMENT)
|
||||
if self.basic_chat:
|
||||
capabilities.add(RuntimeCapability.BASIC_CHAT)
|
||||
if self.simple_tools:
|
||||
capabilities.add(RuntimeCapability.SIMPLE_TOOLS)
|
||||
|
||||
return capabilities
|
||||
|
||||
def supports(self, capability: RuntimeCapability) -> bool:
|
||||
"""Check if this runtime supports a specific capability."""
|
||||
return capability in self.to_capability_set()
|
||||
|
||||
def supports_all(self, required_capabilities: Set[RuntimeCapability]) -> bool:
|
||||
"""Check if this runtime supports all required capabilities."""
|
||||
supported = self.to_capability_set()
|
||||
return required_capabilities.issubset(supported)
|
||||
|
||||
def missing_capabilities(self, required_capabilities: Set[RuntimeCapability]) -> Set[RuntimeCapability]:
|
||||
"""Return set of required capabilities that this runtime doesn't support."""
|
||||
supported = self.to_capability_set()
|
||||
return required_capabilities - supported
|
||||
|
||||
|
||||
class CapabilityValidationError(Exception):
|
||||
"""
|
||||
Raised when runtime capability validation fails.
|
||||
|
||||
Indicates that the selected runtime lacks capabilities required
|
||||
by the agent or team configuration.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runtime_name: str,
|
||||
missing_capabilities: Set[RuntimeCapability],
|
||||
available_capabilities: Optional[Set[RuntimeCapability]] = None
|
||||
):
|
||||
self.runtime_name = runtime_name
|
||||
self.missing_capabilities = missing_capabilities
|
||||
self.available_capabilities = available_capabilities or set()
|
||||
|
||||
missing_names = sorted(cap.name for cap in missing_capabilities)
|
||||
available_names = sorted(cap.name for cap in self.available_capabilities)
|
||||
message = (
|
||||
f"Runtime '{runtime_name}' lacks required capabilities: {missing_names}. "
|
||||
f"Available capabilities: {available_names}. "
|
||||
"Remediation: select a runtime that supports the missing capabilities "
|
||||
"or remove unsupported entries from runtime.required_capabilities."
|
||||
)
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def validate_capabilities(
|
||||
runtime_matrix: RuntimeCapabilityMatrix,
|
||||
required_capabilities: Union[Set[RuntimeCapability], RuntimeCapabilityMatrix],
|
||||
runtime_name: str = "unknown"
|
||||
) -> bool:
|
||||
"""
|
||||
Validate that a runtime supports all required capabilities.
|
||||
|
||||
Args:
|
||||
runtime_matrix: Capability matrix of the runtime
|
||||
required_capabilities: Required capabilities (set or matrix)
|
||||
runtime_name: Name of runtime for error messages
|
||||
|
||||
Returns:
|
||||
True if validation passes
|
||||
|
||||
Raises:
|
||||
CapabilityValidationError: If validation fails
|
||||
"""
|
||||
# Handle empty or None required capabilities
|
||||
if not required_capabilities:
|
||||
return True
|
||||
|
||||
if isinstance(required_capabilities, RuntimeCapabilityMatrix):
|
||||
required_set = required_capabilities.to_capability_set()
|
||||
else:
|
||||
required_set = required_capabilities
|
||||
|
||||
if runtime_matrix.supports_all(required_set):
|
||||
return True
|
||||
|
||||
missing = runtime_matrix.missing_capabilities(required_set)
|
||||
available = runtime_matrix.to_capability_set()
|
||||
|
||||
raise CapabilityValidationError(
|
||||
runtime_name=runtime_name,
|
||||
missing_capabilities=missing,
|
||||
available_capabilities=available
|
||||
)
|
||||
|
||||
|
||||
def get_native_runtime_capabilities() -> RuntimeCapabilityMatrix:
|
||||
"""
|
||||
Get the capability matrix for the built-in native runtime.
|
||||
|
||||
The native runtime supports all capabilities since it runs
|
||||
in-process with full access to the agent system.
|
||||
|
||||
Returns:
|
||||
Full capability matrix for native runtime
|
||||
"""
|
||||
return RuntimeCapabilityMatrix(
|
||||
# Hook system - native runtime supports full hook system
|
||||
native_hooks=True,
|
||||
|
||||
# Core execution - native runtime supports all core features
|
||||
tool_loop=True,
|
||||
streaming_deltas=True,
|
||||
context_compaction=True,
|
||||
|
||||
# Tools and integrations - native runtime supports all
|
||||
mcp_tools=True,
|
||||
code_execution=True,
|
||||
|
||||
# Advanced features - native runtime supports all
|
||||
multi_modal=True,
|
||||
async_execution=True,
|
||||
session_persistence=True,
|
||||
memory_management=True,
|
||||
|
||||
# Basic features - native runtime also supports basic modes
|
||||
basic_chat=True,
|
||||
simple_tools=True,
|
||||
|
||||
metadata={
|
||||
"runtime_type": "native",
|
||||
"description": "Built-in PraisonAI native runtime with full capabilities"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_reduced_harness_capabilities() -> RuntimeCapabilityMatrix:
|
||||
"""
|
||||
Get an example capability matrix for a reduced plugin harness.
|
||||
|
||||
This represents a typical external harness that only supports
|
||||
basic functionality without advanced native features.
|
||||
|
||||
Returns:
|
||||
Reduced capability matrix for plugin harness example
|
||||
"""
|
||||
return RuntimeCapabilityMatrix(
|
||||
# No native hooks - external harness can't hook into native events
|
||||
native_hooks=False,
|
||||
|
||||
# Limited execution capabilities
|
||||
tool_loop=True, # Can execute tools but without native hooks
|
||||
streaming_deltas=False, # May not support real-time streaming
|
||||
context_compaction=False, # May not have context management
|
||||
|
||||
# Limited tool support
|
||||
mcp_tools=False, # May not support MCP protocol
|
||||
code_execution=False, # May not have safe execution environment
|
||||
|
||||
# Basic features only
|
||||
multi_modal=False,
|
||||
async_execution=False,
|
||||
session_persistence=False,
|
||||
memory_management=False,
|
||||
|
||||
# Supports basic operations
|
||||
basic_chat=True,
|
||||
simple_tools=True,
|
||||
|
||||
metadata={
|
||||
"runtime_type": "plugin_harness",
|
||||
"description": "Example reduced plugin harness with limited capabilities"
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
DoctorContractProtocol for runtime config migration.
|
||||
|
||||
Provides a protocol for third-party migration rules and built-in rules
|
||||
for migrating legacy configuration fields like cli_backend to the new
|
||||
model-scoped runtime configuration.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
"""Represents a migration finding from a doctor rule."""
|
||||
|
||||
rule_id: str
|
||||
severity: str # "warning", "error", "info"
|
||||
message: str
|
||||
fix_description: Optional[str] = None
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DoctorContractProtocol(Protocol):
|
||||
"""
|
||||
Protocol for doctor migration rules.
|
||||
|
||||
Third-party plugins can implement this protocol to provide custom
|
||||
migration rules for agent configurations.
|
||||
"""
|
||||
|
||||
@property
|
||||
def rule_id(self) -> str:
|
||||
"""Unique identifier for this migration rule."""
|
||||
...
|
||||
|
||||
def collect_findings(self, config: Dict[str, Any]) -> List[Finding]:
|
||||
"""
|
||||
Analyze configuration and collect migration findings.
|
||||
|
||||
Args:
|
||||
config: Agent configuration dictionary (from YAML or Python)
|
||||
|
||||
Returns:
|
||||
List of findings for this configuration
|
||||
"""
|
||||
...
|
||||
|
||||
def apply_fix(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Apply migration fixes to configuration.
|
||||
|
||||
Args:
|
||||
config: Agent configuration dictionary
|
||||
|
||||
Returns:
|
||||
Updated configuration dictionary
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Doctor rules registry with lazy plugin discovery.
|
||||
|
||||
Manages built-in and plugin-provided migration rules.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import warnings
|
||||
from typing import Any, Dict, List, Optional
|
||||
from .doctor_protocol import DoctorContractProtocol, Finding
|
||||
from .builtin_rules import CliBackendMigrationRule
|
||||
|
||||
|
||||
class DoctorRulesRegistry:
|
||||
"""Registry for doctor migration rules with lazy plugin discovery."""
|
||||
|
||||
def __init__(self):
|
||||
self._rules: List[DoctorContractProtocol] = []
|
||||
self._plugins_loaded = False
|
||||
|
||||
def register_rule(self, rule: DoctorContractProtocol) -> None:
|
||||
"""Register a migration rule."""
|
||||
if not isinstance(rule, DoctorContractProtocol):
|
||||
raise TypeError("Rule must implement DoctorContractProtocol")
|
||||
|
||||
# Check for duplicate rule IDs
|
||||
for existing_rule in self._rules:
|
||||
if existing_rule.rule_id == rule.rule_id:
|
||||
warnings.warn(f"Duplicate rule ID '{rule.rule_id}' - replacing existing rule")
|
||||
self._rules.remove(existing_rule)
|
||||
break
|
||||
|
||||
self._rules.append(rule)
|
||||
|
||||
def get_rules(self) -> List[DoctorContractProtocol]:
|
||||
"""Get all registered rules, loading plugins if not already loaded."""
|
||||
if not self._plugins_loaded:
|
||||
self._load_builtin_rules()
|
||||
self._load_plugin_rules()
|
||||
self._plugins_loaded = True
|
||||
|
||||
return self._rules.copy()
|
||||
|
||||
def get_rule(self, rule_id: str) -> Optional[DoctorContractProtocol]:
|
||||
"""Get a specific rule by ID."""
|
||||
for rule in self.get_rules():
|
||||
if rule.rule_id == rule_id:
|
||||
return rule
|
||||
return None
|
||||
|
||||
def collect_all_findings(self, config: Dict[str, Any]) -> List[Finding]:
|
||||
"""Collect findings from all registered rules."""
|
||||
all_findings = []
|
||||
for rule in self.get_rules():
|
||||
try:
|
||||
findings = rule.collect_findings(config)
|
||||
all_findings.extend(findings)
|
||||
except Exception as e:
|
||||
# Log error but continue with other rules
|
||||
warnings.warn(f"Error in rule '{rule.rule_id}': {e}")
|
||||
|
||||
return all_findings
|
||||
|
||||
def apply_all_fixes(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Apply fixes from all rules that have findings."""
|
||||
result = copy.deepcopy(config)
|
||||
|
||||
for rule in self.get_rules():
|
||||
try:
|
||||
findings = rule.collect_findings(result)
|
||||
if findings:
|
||||
result = rule.apply_fix(result)
|
||||
except Exception as e:
|
||||
warnings.warn(f"Error applying fix for rule '{rule.rule_id}': {e}")
|
||||
|
||||
return result
|
||||
|
||||
def _load_builtin_rules(self) -> None:
|
||||
"""Load built-in migration rules."""
|
||||
# Register built-in cli_backend migration rule
|
||||
self.register_rule(CliBackendMigrationRule())
|
||||
|
||||
def _load_plugin_rules(self) -> None:
|
||||
"""Discover and load plugin-provided rules via entry points."""
|
||||
# Prioritize importlib.metadata over pkg_resources for better performance
|
||||
try:
|
||||
from importlib.metadata import entry_points
|
||||
eps = entry_points()
|
||||
if hasattr(eps, 'select'):
|
||||
doctor_eps = eps.select(group='praisonai.doctor_contracts')
|
||||
else:
|
||||
doctor_eps = eps.get('praisonai.doctor_contracts', [])
|
||||
|
||||
for entry_point in doctor_eps:
|
||||
try:
|
||||
rule_class = entry_point.load()
|
||||
if callable(rule_class):
|
||||
rule_instance = rule_class()
|
||||
self.register_rule(rule_instance)
|
||||
except Exception as e:
|
||||
warnings.warn(f"Failed to load doctor rule from {entry_point.name}: {e}")
|
||||
except ImportError:
|
||||
# Fallback to pkg_resources if importlib.metadata not available
|
||||
try:
|
||||
import pkg_resources
|
||||
for entry_point in pkg_resources.iter_entry_points('praisonai.doctor_contracts'):
|
||||
try:
|
||||
rule_class = entry_point.load()
|
||||
if callable(rule_class):
|
||||
rule_instance = rule_class()
|
||||
self.register_rule(rule_instance)
|
||||
except Exception as e:
|
||||
warnings.warn(f"Failed to load doctor rule from {entry_point.name}: {e}")
|
||||
except ImportError:
|
||||
pass # No plugin system available
|
||||
|
||||
|
||||
# Global registry singleton
|
||||
_default_registry: Optional[DoctorRulesRegistry] = None
|
||||
|
||||
|
||||
def get_default_registry() -> DoctorRulesRegistry:
|
||||
"""Get the default doctor rules registry (singleton)."""
|
||||
global _default_registry
|
||||
if _default_registry is None:
|
||||
_default_registry = DoctorRulesRegistry()
|
||||
return _default_registry
|
||||
|
||||
|
||||
def register_rule(rule: DoctorContractProtocol) -> None:
|
||||
"""Register a rule with the default registry."""
|
||||
get_default_registry().register_rule(rule)
|
||||
|
||||
|
||||
def get_rules() -> List[DoctorContractProtocol]:
|
||||
"""Get all rules from the default registry."""
|
||||
return get_default_registry().get_rules()
|
||||
|
||||
|
||||
def collect_findings(config: Dict[str, Any]) -> List[Finding]:
|
||||
"""Collect findings from all rules in the default registry."""
|
||||
return get_default_registry().collect_all_findings(config)
|
||||
|
||||
|
||||
def apply_fixes(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Apply fixes from all rules in the default registry."""
|
||||
return get_default_registry().apply_all_fixes(config)
|
||||
@@ -6,7 +6,8 @@ must implement. These protocols standardize the execution contract
|
||||
across different runtime types (native, plugin, managed, etc.).
|
||||
|
||||
Includes both turn-context protocols and agent runtime protocols to support
|
||||
different execution patterns.
|
||||
different execution patterns, along with capability reporting protocols
|
||||
for runtime compatibility validation.
|
||||
|
||||
Protocol-driven design following AGENTS.md:
|
||||
- Lightweight protocols only (no heavy implementations)
|
||||
@@ -21,6 +22,7 @@ from typing import Any, AsyncIterator, Dict, List, Optional, Protocol, runtime_c
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .turn_context import PreparedTurnContext, RuntimeMode
|
||||
from .capabilities import RuntimeCapabilityMatrix
|
||||
from ..agent.protocols import AgentProtocol
|
||||
|
||||
|
||||
@@ -121,7 +123,7 @@ class TurnContextBuilderProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
# Agent runtime protocols and dataclasses from this branch
|
||||
# Agent runtime protocols and dataclasses
|
||||
|
||||
@dataclass
|
||||
class RuntimeConfig:
|
||||
@@ -155,14 +157,48 @@ class RuntimeDelta:
|
||||
|
||||
@runtime_checkable
|
||||
class AgentRuntimeProtocol(Protocol):
|
||||
"""Protocol for agent runtime implementations.
|
||||
|
||||
Any object implementing these methods can serve as an Agent runtime.
|
||||
Follows typing.Protocol pattern from AGENTS.md.
|
||||
|
||||
This is a simpler contract than CliBackendProtocol, focused on
|
||||
the minimal interface needed for runtime execution.
|
||||
"""
|
||||
Protocol that all agent runtime implementations must follow.
|
||||
|
||||
This protocol defines the interface for different runtime environments:
|
||||
- Native runtime (built-in praisonai runtime)
|
||||
- Plugin harness runtimes (external CLI tools, docker containers)
|
||||
- Managed service runtimes (Anthropic, E2B, Modal, etc.)
|
||||
|
||||
Combines turn-based execution with capability reporting to enable
|
||||
both runtime execution and compatibility validation.
|
||||
"""
|
||||
|
||||
@property
|
||||
def runtime_name(self) -> str:
|
||||
"""
|
||||
Human-readable name of this runtime implementation.
|
||||
|
||||
Examples: "native", "claude-code", "e2b-managed", "docker-harness"
|
||||
"""
|
||||
...
|
||||
|
||||
@property
|
||||
def runtime_version(self) -> str:
|
||||
"""
|
||||
Version string of this runtime implementation.
|
||||
|
||||
Used for compatibility tracking and debugging.
|
||||
"""
|
||||
...
|
||||
|
||||
def capabilities(self) -> "RuntimeCapabilityMatrix":
|
||||
"""
|
||||
Report the capability matrix for this runtime.
|
||||
|
||||
This is the key method for the capability validation system.
|
||||
Each runtime must honestly declare what features it supports
|
||||
to enable fail-fast validation at config/selection time.
|
||||
|
||||
Returns:
|
||||
RuntimeCapabilityMatrix with supported capabilities
|
||||
"""
|
||||
...
|
||||
|
||||
def supports(self, model_ref: Optional[str] = None) -> bool:
|
||||
"""Check if this runtime supports the given model reference.
|
||||
@@ -210,4 +246,72 @@ class AgentRuntimeProtocol(Protocol):
|
||||
Yields:
|
||||
RuntimeDelta objects with incremental response content
|
||||
"""
|
||||
...
|
||||
...
|
||||
|
||||
async def execute_agent(
|
||||
self,
|
||||
agent_config: Dict[str, Any],
|
||||
prompt: str,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute agent with the given configuration and prompt.
|
||||
|
||||
Args:
|
||||
agent_config: Agent configuration dict
|
||||
prompt: User prompt to process
|
||||
**kwargs: Additional execution parameters
|
||||
|
||||
Returns:
|
||||
Execution result dict with response, metadata, etc.
|
||||
"""
|
||||
...
|
||||
|
||||
async def stream_agent(
|
||||
self,
|
||||
agent_config: Dict[str, Any],
|
||||
prompt: str,
|
||||
**kwargs
|
||||
) -> Any: # AsyncIterator but avoid import
|
||||
"""
|
||||
Execute agent with streaming responses.
|
||||
|
||||
Only required if runtime declares streaming_deltas capability.
|
||||
|
||||
Args:
|
||||
agent_config: Agent configuration dict
|
||||
prompt: User prompt to process
|
||||
**kwargs: Additional execution parameters
|
||||
|
||||
Yields:
|
||||
Streaming response chunks
|
||||
"""
|
||||
...
|
||||
|
||||
async def validate_config(
|
||||
self,
|
||||
agent_config: Dict[str, Any]
|
||||
) -> List[str]:
|
||||
"""
|
||||
Validate agent configuration against runtime capabilities.
|
||||
|
||||
Args:
|
||||
agent_config: Agent configuration to validate
|
||||
|
||||
Returns:
|
||||
List of validation error messages (empty if valid)
|
||||
"""
|
||||
...
|
||||
|
||||
async def health_check(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Perform runtime health check.
|
||||
|
||||
Returns:
|
||||
Health status dict with status, latency, errors, etc.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# CliBackendProtocol removed - use the canonical definition from cli_backend.protocols instead
|
||||
# to avoid duplicate incompatible protocol definitions
|
||||
@@ -420,4 +420,4 @@ def _discover_entry_point_runtimes() -> None:
|
||||
|
||||
|
||||
# Initialize on module load
|
||||
_discover_entry_point_runtimes()
|
||||
_discover_entry_point_runtimes()
|
||||
@@ -0,0 +1,378 @@
|
||||
"""
|
||||
Turn-time runtime resolution API for handoffs and sub-agents.
|
||||
|
||||
This module addresses the issue where handoffs and sub-agents inherit
|
||||
construction-time runtime pins from the parent agent, causing wrong
|
||||
harness execution when the delegate uses a different model_ref or provider.
|
||||
|
||||
The solution provides turn-time resolution that re-resolves runtime
|
||||
from (agent_id, model_ref) at each handoff/sub-agent invocation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Protocol, Tuple, Union
|
||||
from dataclasses import dataclass
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RuntimeProtocol(Protocol):
|
||||
"""Protocol for agent runtime implementations."""
|
||||
|
||||
def execute(self, prompt: str, **kwargs) -> Any:
|
||||
"""Execute a prompt with the runtime."""
|
||||
...
|
||||
|
||||
async def aexecute(self, prompt: str, **kwargs) -> Any:
|
||||
"""Async execute a prompt with the runtime."""
|
||||
...
|
||||
|
||||
@property
|
||||
def model_ref(self) -> str:
|
||||
"""Get the model reference for this runtime."""
|
||||
...
|
||||
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
"""Get the provider for this runtime."""
|
||||
...
|
||||
|
||||
class AgentRuntimeProtocol(RuntimeProtocol):
|
||||
"""Extended protocol for agent-specific runtime features."""
|
||||
|
||||
@property
|
||||
def supports_streaming(self) -> bool:
|
||||
"""Whether this runtime supports streaming responses."""
|
||||
...
|
||||
|
||||
@property
|
||||
def supports_tools(self) -> bool:
|
||||
"""Whether this runtime supports tool calling."""
|
||||
...
|
||||
|
||||
@dataclass
|
||||
class SessionContext:
|
||||
"""Context for runtime resolution within a session."""
|
||||
session_id: str
|
||||
timestamp: float
|
||||
parent_agent_id: Optional[str] = None
|
||||
handoff_depth: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
if self.timestamp <= 0:
|
||||
self.timestamp = time.time()
|
||||
|
||||
class RuntimeResolver(ABC):
|
||||
"""Abstract base class for runtime resolution strategies."""
|
||||
|
||||
@abstractmethod
|
||||
def resolve(
|
||||
self,
|
||||
agent_id: str,
|
||||
model_ref: str,
|
||||
session_ctx: SessionContext,
|
||||
**kwargs
|
||||
) -> AgentRuntimeProtocol:
|
||||
"""Resolve runtime for the given agent and model."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def supports_model(self, model_ref: str) -> bool:
|
||||
"""Check if this resolver can handle the given model."""
|
||||
...
|
||||
|
||||
class DefaultRuntimeResolver(RuntimeResolver):
|
||||
"""Default runtime resolver that creates appropriate runtime instances."""
|
||||
|
||||
def __init__(self):
|
||||
self._model_mapping = {
|
||||
# OpenAI models
|
||||
'gpt-4o': 'openai',
|
||||
'gpt-4o-mini': 'openai',
|
||||
'gpt-4': 'openai',
|
||||
'gpt-3.5-turbo': 'openai',
|
||||
# Anthropic models
|
||||
'claude-3-sonnet': 'anthropic',
|
||||
'claude-3-haiku': 'anthropic',
|
||||
'claude-3-opus': 'anthropic',
|
||||
# Add more mappings as needed
|
||||
}
|
||||
|
||||
def supports_model(self, model_ref: str) -> bool:
|
||||
"""Check if we can resolve this model."""
|
||||
return model_ref in self._model_mapping or model_ref.startswith(('gpt-', 'claude-'))
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
agent_id: str,
|
||||
model_ref: str,
|
||||
session_ctx: SessionContext,
|
||||
**kwargs
|
||||
) -> AgentRuntimeProtocol:
|
||||
"""Resolve runtime based on model reference."""
|
||||
try:
|
||||
# Try to import and use the existing LLM class
|
||||
from ..llm.llm import LLM
|
||||
|
||||
# Create a runtime wrapper around the LLM
|
||||
# Pass through any configuration kwargs
|
||||
llm_kwargs = {'model': model_ref}
|
||||
llm_kwargs.update(kwargs)
|
||||
return LLMRuntimeWrapper(
|
||||
llm=LLM(**llm_kwargs),
|
||||
model_ref=model_ref,
|
||||
agent_id=agent_id
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback to a basic implementation
|
||||
logger.warning(f"LLM class not available, using fallback runtime for {model_ref}")
|
||||
return FallbackRuntime(model_ref=model_ref, agent_id=agent_id)
|
||||
|
||||
class LLMRuntimeWrapper(AgentRuntimeProtocol):
|
||||
"""Wrapper around existing LLM class to provide runtime protocol."""
|
||||
|
||||
def __init__(self, llm: Any, model_ref: str, agent_id: str):
|
||||
self.llm = llm
|
||||
self._model_ref = model_ref
|
||||
self._agent_id = agent_id
|
||||
|
||||
def execute(self, prompt: str, **kwargs) -> Any:
|
||||
"""Execute prompt using the LLM."""
|
||||
return self.llm.chat(prompt, **kwargs)
|
||||
|
||||
async def aexecute(self, prompt: str, **kwargs) -> Any:
|
||||
"""Async execute prompt using the LLM."""
|
||||
if hasattr(self.llm, 'achat'):
|
||||
return await self.llm.achat(prompt, **kwargs)
|
||||
else:
|
||||
# Run sync version in executor
|
||||
import asyncio
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, lambda: self.llm.chat(prompt, **kwargs))
|
||||
|
||||
@property
|
||||
def model_ref(self) -> str:
|
||||
return self._model_ref
|
||||
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
# Extract provider from model_ref or LLM instance
|
||||
if hasattr(self.llm, 'provider'):
|
||||
return self.llm.provider
|
||||
elif self._model_ref.startswith('gpt-'):
|
||||
return 'openai'
|
||||
elif self._model_ref.startswith('claude-'):
|
||||
return 'anthropic'
|
||||
else:
|
||||
return 'unknown'
|
||||
|
||||
@property
|
||||
def supports_streaming(self) -> bool:
|
||||
return hasattr(self.llm, 'stream') or hasattr(self.llm, '_stream_chat')
|
||||
|
||||
@property
|
||||
def supports_tools(self) -> bool:
|
||||
return True # Most modern LLMs support tools
|
||||
|
||||
class FallbackRuntime(AgentRuntimeProtocol):
|
||||
"""Fallback runtime implementation that raises errors instead of returning stubs."""
|
||||
|
||||
def __init__(self, model_ref: str, agent_id: str):
|
||||
self._model_ref = model_ref
|
||||
self._agent_id = agent_id
|
||||
|
||||
def execute(self, prompt: str, **kwargs) -> Any:
|
||||
raise RuntimeError(
|
||||
f"No LLM runtime available for model {self._model_ref}. "
|
||||
"Please ensure the LLM module is properly installed."
|
||||
)
|
||||
|
||||
async def aexecute(self, prompt: str, **kwargs) -> Any:
|
||||
raise RuntimeError(
|
||||
f"No LLM runtime available for model {self._model_ref}. "
|
||||
"Please ensure the LLM module is properly installed."
|
||||
)
|
||||
|
||||
@property
|
||||
def model_ref(self) -> str:
|
||||
return self._model_ref
|
||||
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return 'fallback'
|
||||
|
||||
@property
|
||||
def supports_streaming(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def supports_tools(self) -> bool:
|
||||
return False
|
||||
|
||||
# Global runtime cache - thread-safe and request-scoped
|
||||
_runtime_cache_lock = threading.RLock()
|
||||
_runtime_cache: Dict[str, Dict[str, Tuple[AgentRuntimeProtocol, float]]] = {}
|
||||
_cache_ttl_seconds = 300 # 5 minutes
|
||||
_global_resolver: Optional[RuntimeResolver] = None
|
||||
|
||||
def set_global_resolver(resolver: RuntimeResolver) -> None:
|
||||
"""Set the global runtime resolver."""
|
||||
global _global_resolver
|
||||
_global_resolver = resolver
|
||||
|
||||
def get_global_resolver() -> RuntimeResolver:
|
||||
"""Get the global runtime resolver, creating default if needed."""
|
||||
global _global_resolver
|
||||
if _global_resolver is None:
|
||||
_global_resolver = DefaultRuntimeResolver()
|
||||
return _global_resolver
|
||||
|
||||
def resolve_runtime(
|
||||
agent_id: str,
|
||||
model_ref: str,
|
||||
session_ctx: SessionContext,
|
||||
**kwargs
|
||||
) -> AgentRuntimeProtocol:
|
||||
"""
|
||||
Resolve runtime for the given agent and model at turn-time.
|
||||
|
||||
This is the main API that handoffs and sub-agent invocations should call
|
||||
to get the appropriate runtime instead of using construction-time pins.
|
||||
|
||||
Args:
|
||||
agent_id: Unique identifier for the agent
|
||||
model_ref: Model reference (e.g., 'gpt-4o', 'claude-3-sonnet')
|
||||
session_ctx: Session context for caching and tracking
|
||||
|
||||
Returns:
|
||||
AgentRuntimeProtocol instance configured for the model
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no suitable runtime can be resolved
|
||||
|
||||
Example:
|
||||
```python
|
||||
session_ctx = SessionContext(
|
||||
session_id="session_123",
|
||||
timestamp=time.time(),
|
||||
handoff_depth=1
|
||||
)
|
||||
runtime = resolve_runtime("agent_1", "gpt-4o", session_ctx)
|
||||
response = runtime.execute("Hello, world!")
|
||||
```
|
||||
"""
|
||||
cache_key = f"{session_ctx.session_id}:{agent_id}:{model_ref}"
|
||||
current_time = time.time()
|
||||
|
||||
# Check cache first
|
||||
with _runtime_cache_lock:
|
||||
session_cache = _runtime_cache.get(session_ctx.session_id, {})
|
||||
|
||||
if cache_key in session_cache:
|
||||
runtime, cached_time = session_cache[cache_key]
|
||||
# Check if cache entry is still valid
|
||||
if current_time - cached_time < _cache_ttl_seconds:
|
||||
logger.debug(f"Using cached runtime for {cache_key}")
|
||||
return runtime
|
||||
else:
|
||||
# Remove expired entry
|
||||
del session_cache[cache_key]
|
||||
logger.debug(f"Cache expired for {cache_key}")
|
||||
|
||||
# Resolve new runtime
|
||||
logger.info(f"Resolving runtime for agent_id={agent_id}, model_ref={model_ref}")
|
||||
|
||||
resolver = get_global_resolver()
|
||||
if not resolver.supports_model(model_ref):
|
||||
raise RuntimeError(f"No runtime resolver available for model: {model_ref}")
|
||||
|
||||
try:
|
||||
runtime = resolver.resolve(agent_id, model_ref, session_ctx, **kwargs)
|
||||
|
||||
# Cache the resolved runtime
|
||||
with _runtime_cache_lock:
|
||||
if session_ctx.session_id not in _runtime_cache:
|
||||
_runtime_cache[session_ctx.session_id] = {}
|
||||
_runtime_cache[session_ctx.session_id][cache_key] = (runtime, current_time)
|
||||
|
||||
logger.info(f"Successfully resolved runtime for {cache_key}")
|
||||
return runtime
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to resolve runtime for {cache_key}: {e}")
|
||||
raise RuntimeError(f"Runtime resolution failed for model {model_ref}: {e}") from e
|
||||
|
||||
def get_runtime_cache() -> Dict[str, Dict[str, Tuple[AgentRuntimeProtocol, float]]]:
|
||||
"""Get a copy of the current runtime cache (for debugging/testing)."""
|
||||
with _runtime_cache_lock:
|
||||
return {
|
||||
session_id: {
|
||||
key: (runtime, timestamp)
|
||||
for key, (runtime, timestamp) in cache.items()
|
||||
}
|
||||
for session_id, cache in _runtime_cache.items()
|
||||
}
|
||||
|
||||
def clear_runtime_cache(session_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
Clear runtime cache for a specific session or all sessions.
|
||||
|
||||
Args:
|
||||
session_id: If provided, only clear cache for this session.
|
||||
If None, clear all cached runtimes.
|
||||
"""
|
||||
with _runtime_cache_lock:
|
||||
if session_id is None:
|
||||
_runtime_cache.clear()
|
||||
logger.info("Cleared all runtime cache")
|
||||
elif session_id in _runtime_cache:
|
||||
del _runtime_cache[session_id]
|
||||
logger.info(f"Cleared runtime cache for session: {session_id}")
|
||||
|
||||
def _cleanup_expired_cache() -> None:
|
||||
"""Clean up expired cache entries (called periodically)."""
|
||||
current_time = time.time()
|
||||
total_expired = 0
|
||||
|
||||
with _runtime_cache_lock:
|
||||
for session_id, session_cache in list(_runtime_cache.items()):
|
||||
expired_keys = []
|
||||
for key, (runtime, cached_time) in session_cache.items():
|
||||
if current_time - cached_time >= _cache_ttl_seconds:
|
||||
expired_keys.append(key)
|
||||
|
||||
for key in expired_keys:
|
||||
del session_cache[key]
|
||||
total_expired += len(expired_keys)
|
||||
|
||||
# Remove empty session caches
|
||||
if not session_cache:
|
||||
del _runtime_cache[session_id]
|
||||
|
||||
if total_expired:
|
||||
logger.debug(f"Cleaned up {total_expired} expired runtime cache entries")
|
||||
|
||||
# Background cleanup (optional - only if needed)
|
||||
_cleanup_thread = None
|
||||
_cleanup_interval = 600 # 10 minutes
|
||||
|
||||
def _start_cleanup_thread():
|
||||
"""Start background cache cleanup thread."""
|
||||
global _cleanup_thread
|
||||
if _cleanup_thread is None or not _cleanup_thread.is_alive():
|
||||
def cleanup_worker():
|
||||
while True:
|
||||
try:
|
||||
time.sleep(_cleanup_interval)
|
||||
_cleanup_expired_cache()
|
||||
except Exception as e:
|
||||
logger.error(f"Cache cleanup error: {e}")
|
||||
|
||||
_cleanup_thread = threading.Thread(target=cleanup_worker, daemon=True)
|
||||
_cleanup_thread.start()
|
||||
logger.debug("Started runtime cache cleanup thread")
|
||||
|
||||
# Initialize cleanup thread when module is imported
|
||||
_start_cleanup_thread()
|
||||
Loaded 100 of 364 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user