mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 01:08:39 -10:00
Compare commits
194
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,
|
||||
};
|
||||
@@ -16,6 +16,9 @@ on:
|
||||
types: [submitted]
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
schedule:
|
||||
- cron: '30 3,9,15,21 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
MAX_POLL_ATTEMPTS: 20
|
||||
@@ -190,6 +193,7 @@ jobs:
|
||||
'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, 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});
|
||||
@@ -212,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
|
||||
@@ -319,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\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\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'
|
||||
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)
|
||||
# ================================================================
|
||||
@@ -338,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
|
||||
@@ -426,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\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\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'
|
||||
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.
|
||||
@@ -480,7 +518,7 @@ 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\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\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'
|
||||
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'
|
||||
});
|
||||
|
||||
# ================================================================
|
||||
@@ -496,30 +534,41 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Post missing FINAL Claude review after rebase
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Re-post FINAL Claude review when stale or missing
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
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 { data: comments } = await github.rest.issues.listComments({
|
||||
issue_number: pr, owner, repo, per_page: 100,
|
||||
});
|
||||
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 hasFinalReview = comments.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');
|
||||
});
|
||||
if (hasFinalReview) {
|
||||
core.info('FINAL Claude review already posted, skipping recovery');
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -533,11 +582,62 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, 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\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\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'
|
||||
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,
|
||||
});
|
||||
core.info(`Posted recovery FINAL Claude review on PR #${pr}`);
|
||||
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.
|
||||
@@ -556,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();
|
||||
}
|
||||
@@ -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]' ||
|
||||
@@ -277,9 +279,10 @@ 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.
|
||||
|
||||
@@ -333,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}`);
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: merge-conflict-scan-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# PAT posts as MervinPraison so issue_comment triggers claude.yml (GITHUB_TOKEN cannot chain workflows)
|
||||
@@ -35,7 +35,6 @@ jobs:
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const baseRepo = `${owner}/${repo}`;
|
||||
const isMainPush = context.eventName === 'push';
|
||||
const COOLDOWN_MS = 12 * 60 * 60 * 1000;
|
||||
const AUTO_ACTORS = ['github-actions[bot]', 'MervinPraison'];
|
||||
const LABEL = 'claude-conflict-pending';
|
||||
@@ -43,7 +42,8 @@ jobs:
|
||||
'@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.'
|
||||
'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));
|
||||
@@ -130,6 +130,9 @@ jobs:
|
||||
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,
|
||||
@@ -137,7 +140,7 @@ jobs:
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
if (!isMainPush && hasRecentAutoComment(comments)) {
|
||||
if (hasRecentAutoComment(comments)) {
|
||||
return { skipped: true, reason: 'recent auto comment' };
|
||||
}
|
||||
|
||||
@@ -145,20 +148,18 @@ jobs:
|
||||
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,
|
||||
});
|
||||
if (!labels.includes(LABEL)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: [LABEL],
|
||||
});
|
||||
}
|
||||
return { triggered: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -815,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>
|
||||
@@ -857,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>
|
||||
@@ -886,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>
|
||||
@@ -1167,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>
|
||||
@@ -1362,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.74" \
|
||||
"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.74" \
|
||||
"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.74" \
|
||||
"praisonai>=4.6.80" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -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.
|
||||
@@ -18,22 +18,22 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 20 | 40 | 10767 |
|
||||
| ✅ Agent | 21 | 42 | 11340 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2880 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 575 |
|
||||
| ✅ Approval | 1 | 3 | 1344 |
|
||||
| ✅ Audio | 2 | 10 | 649 |
|
||||
| ✅ Auto Generation | 5 | 6 | 1637 |
|
||||
| ✅ Autonomy | 3 | 5 | 1919 |
|
||||
| ✅ Bots | 7 | 21 | 6676 |
|
||||
| ✅ Bots | 7 | 23 | 7714 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 106 | 26315 |
|
||||
| ✅ CLI | 5 | 108 | 26975 |
|
||||
| ✅ Chunking | 2 | 2 | 288 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 2 | 13 | 3551 |
|
||||
| ✅ Code Execution | 2 | 13 | 3554 |
|
||||
| ✅ Conditions | 1 | 3 | 1085 |
|
||||
| ✅ Configuration | 3 | 7 | 2069 |
|
||||
| ✅ Context Management | 16 | 33 | 10206 |
|
||||
| ✅ Configuration | 3 | 7 | 2160 |
|
||||
| ✅ Context Management | 16 | 33 | 10280 |
|
||||
| ✅ Database | 1 | 40 | 5373 |
|
||||
| ✅ Deep Research | 8 | 2 | 437 |
|
||||
| ✅ Display | 6 | 2 | 1175 |
|
||||
@@ -42,20 +42,20 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| ✅ Events | 1 | 2 | 764 |
|
||||
| ✅ Execution | 3 | 3 | 1099 |
|
||||
| ✅ Failover | 2 | 1 | 372 |
|
||||
| ✅ Files | 2 | 6 | 1361 |
|
||||
| ✅ Files | 2 | 6 | 1632 |
|
||||
| ✅ Flow | 1 | 2 | 435 |
|
||||
| ✅ Gateway | 7 | 18 | 6400 |
|
||||
| ✅ Gateway | 7 | 21 | 7477 |
|
||||
| ✅ Guardrails | 4 | 4 | 1619 |
|
||||
| ✅ Handoffs | 11 | 6 | 2499 |
|
||||
| ✅ Hooks | 2 | 8 | 3100 |
|
||||
| ✅ Hooks | 2 | 8 | 3150 |
|
||||
| ✅ Image | 1 | 11 | 1096 |
|
||||
| ✅ Knowledge | 4 | 23 | 4282 |
|
||||
| ✅ Knowledge | 4 | 24 | 4525 |
|
||||
| ✅ LLM | 3 | 10 | 3674 |
|
||||
| ✅ Loops | 4 | 3 | 774 |
|
||||
| ✅ MCP | 1 | 51 | 10933 |
|
||||
| ✅ MCP | 1 | 51 | 10939 |
|
||||
| ✅ Memory | 6 | 16 | 5879 |
|
||||
| ✅ OCR | 2 | 1 | 226 |
|
||||
| ✅ Observability | 2 | 23 | 2652 |
|
||||
| ✅ Observability | 2 | 23 | 2754 |
|
||||
| ✅ Optimizer | 1 | 2 | 653 |
|
||||
| ✅ Output | 3 | 4 | 860 |
|
||||
| ✅ Parallel Execution | 3 | 2 | 399 |
|
||||
@@ -64,24 +64,24 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| ✅ 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 | 477 |
|
||||
| ✅ Retrieval | 2 | 5 | 944 |
|
||||
| ✅ Retrieval | 2 | 5 | 945 |
|
||||
| ✅ Routing | 1 | 2 | 597 |
|
||||
| ✅ Sandbox | 5 | 6 | 2478 |
|
||||
| ✅ Security | 1 | 3 | 2550 |
|
||||
| ✅ Sessions | 4 | 11 | 3423 |
|
||||
| ✅ Skills | 6 | 9 | 2983 |
|
||||
| ✅ Sandbox | 5 | 6 | 2512 |
|
||||
| ✅ Security | 1 | 3 | 2563 |
|
||||
| ✅ Sessions | 4 | 12 | 3793 |
|
||||
| ✅ Skills | 6 | 10 | 3663 |
|
||||
| ✅ Tasks | 2 | 6 | 3064 |
|
||||
| ✅ Telemetry | 1 | 2 | 560 |
|
||||
| ✅ Templates | 1 | 7 | 1411 |
|
||||
| ✅ Tools | 12 | 118 | 25868 |
|
||||
| ✅ Tools | 12 | 118 | 25895 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Video | 2 | 6 | 510 |
|
||||
| ✅ Vision | 2 | 1 | 283 |
|
||||
| ✅ Web | 3 | 6 | 1352 |
|
||||
| ✅ Workflows | 4 | 14 | 5810 |
|
||||
| ✅ Web | 3 | 7 | 1537 |
|
||||
| ✅ Workflows | 4 | 14 | 5893 |
|
||||
|
||||
## Undocumented Categories (Need Documentation)
|
||||
|
||||
|
||||
@@ -235,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'),
|
||||
|
||||
@@ -4063,37 +4063,6 @@ 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.
|
||||
|
||||
Args:
|
||||
profile: Agent profile name
|
||||
context: Additional context
|
||||
|
||||
Returns:
|
||||
Configured Agent instance
|
||||
"""
|
||||
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,
|
||||
)
|
||||
else:
|
||||
# Default subagent
|
||||
return Agent(
|
||||
name=f"subagent_{profile}",
|
||||
instructions=f"You are a {profile} assistant.",
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Runtime Capability Management
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -4224,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.
|
||||
@@ -5644,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()
|
||||
@@ -5692,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()
|
||||
|
||||
|
||||
@@ -614,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.
|
||||
@@ -786,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
|
||||
@@ -812,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)"
|
||||
)
|
||||
|
||||
@@ -906,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.
|
||||
@@ -994,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)
|
||||
@@ -1672,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.
|
||||
|
||||
@@ -35,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
|
||||
@@ -918,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'):
|
||||
|
||||
@@ -626,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)
|
||||
|
||||
@@ -393,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
|
||||
@@ -813,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:
|
||||
|
||||
@@ -35,22 +35,6 @@ class UnifiedExecutionMixin:
|
||||
with a single async-first implementation plus sync bridge.
|
||||
"""
|
||||
|
||||
def _resolve_cli_backend_at_turn_time(self):
|
||||
"""
|
||||
Resolve CLI backend at turn-time instead of construction time.
|
||||
|
||||
This allows for runtime-specific backend selection based on model changes
|
||||
or other runtime conditions.
|
||||
|
||||
Returns:
|
||||
The resolved CLI backend instance, or current backend if no override needed.
|
||||
"""
|
||||
# For now, this is a placeholder for future backend override logic
|
||||
# The runtime resolution infrastructure is in place, but the policy
|
||||
# decisions about when to switch backends are not yet implemented
|
||||
# Always return the current backend until override logic is implemented
|
||||
return getattr(self, '_cli_backend', None)
|
||||
|
||||
async def _unified_chat_impl(
|
||||
self,
|
||||
prompt: str,
|
||||
|
||||
@@ -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,
|
||||
@@ -44,12 +45,19 @@ 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
|
||||
|
||||
@@ -83,13 +91,19 @@ __all__ = [
|
||||
"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
|
||||
|
||||
@@ -28,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):
|
||||
@@ -92,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:
|
||||
@@ -224,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."""
|
||||
@@ -471,9 +526,22 @@ def _adapt_button(button: PresentationButton, limits: PresentationLimits) -> Pre
|
||||
|
||||
action = button.action
|
||||
url = button.url
|
||||
if action is not None and not limits.supports_web_apps:
|
||||
if action is not None:
|
||||
action_type = action.type.value if isinstance(action.type, ActionType) else action.type
|
||||
if action_type == ActionType.WEB_APP.value and action.web_app_url:
|
||||
# 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
|
||||
@@ -489,6 +557,40 @@ def _adapt_button(button: PresentationButton, limits: PresentationLimits) -> Pre
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -499,7 +601,7 @@ def _encode_select_callback(action_id: str, value: str) -> str:
|
||||
collision-resistant hash so distinct options stay distinct after truncation.
|
||||
"""
|
||||
raw = f"select:{action_id}:{value}"
|
||||
if len(raw) <= _MAX_CALLBACK_LEN:
|
||||
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}:"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -145,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)
|
||||
|
||||
@@ -166,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,
|
||||
}
|
||||
|
||||
@@ -739,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
|
||||
@@ -833,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')
|
||||
@@ -861,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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,7 @@ from .protocols import (
|
||||
GatewayMessage,
|
||||
EventType,
|
||||
OperatorScope,
|
||||
GatewayCloseCode,
|
||||
# Push protocols and dataclasses
|
||||
PushChannelProtocol,
|
||||
PresenceProtocol,
|
||||
@@ -38,10 +39,19 @@ from .protocols import (
|
||||
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,
|
||||
@@ -51,6 +61,13 @@ from .protocols import (
|
||||
GapInfo,
|
||||
ResumeSnapshot,
|
||||
)
|
||||
from .hooks import (
|
||||
HookAction,
|
||||
HookConfig,
|
||||
InboundTriggerProtocol,
|
||||
render_template,
|
||||
compute_idempotency_key,
|
||||
)
|
||||
from .config import (
|
||||
GatewayConfig,
|
||||
SessionConfig,
|
||||
@@ -112,6 +129,7 @@ __all__ = [
|
||||
"GatewayMessage",
|
||||
"EventType",
|
||||
"OperatorScope",
|
||||
"GatewayCloseCode",
|
||||
# Push protocols (always available)
|
||||
"PushChannelProtocol",
|
||||
"PresenceProtocol",
|
||||
@@ -131,10 +149,19 @@ __all__ = [
|
||||
"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",
|
||||
@@ -143,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,6 +229,9 @@ 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
|
||||
@@ -251,6 +254,7 @@ 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
|
||||
|
||||
@@ -262,6 +266,10 @@ 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:
|
||||
@@ -335,6 +343,7 @@ 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,
|
||||
}
|
||||
@@ -429,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":
|
||||
@@ -510,7 +520,8 @@ 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,
|
||||
)
|
||||
|
||||
@@ -533,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]:
|
||||
@@ -547,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,
|
||||
@@ -52,6 +53,23 @@ class ConnectErrorCode(str, Enum):
|
||||
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.
|
||||
|
||||
@@ -1010,6 +1028,105 @@ 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1034,6 +1151,13 @@ class RouteBinding:
|
||||
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
|
||||
@@ -1043,6 +1167,9 @@ class RouteBinding:
|
||||
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.
|
||||
@@ -1054,6 +1181,24 @@ class RouteBinding:
|
||||
"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."""
|
||||
@@ -1079,6 +1224,29 @@ class RouteBinding:
|
||||
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.
|
||||
@@ -1094,6 +1262,9 @@ class RouteBinding:
|
||||
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")),
|
||||
)
|
||||
|
||||
|
||||
@@ -1138,6 +1309,24 @@ def _as_opt_str(value: Any) -> Optional[str]:
|
||||
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,
|
||||
@@ -1692,6 +1881,156 @@ class SendPolicy:
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -189,4 +189,4 @@ class PraisonAIRuntime:
|
||||
type="error",
|
||||
content=str(e),
|
||||
metadata={'runtime': self.runtime_id}
|
||||
)
|
||||
)
|
||||
@@ -35,6 +35,8 @@ __all__ = [
|
||||
"to_prompt",
|
||||
"generate_skills_xml",
|
||||
"format_skill_for_prompt",
|
||||
# Learn-from-sources
|
||||
"build_learn_prompt",
|
||||
# Discovery
|
||||
"discover_skills",
|
||||
"get_default_skill_dirs",
|
||||
@@ -82,6 +84,10 @@ def __getattr__(name: str):
|
||||
if name in ("to_prompt", "generate_skills_xml", "format_skill_for_prompt"):
|
||||
from .prompt import to_prompt, generate_skills_xml, format_skill_for_prompt
|
||||
return locals()[name]
|
||||
|
||||
if name == "build_learn_prompt":
|
||||
from .learn import build_learn_prompt
|
||||
return build_learn_prompt
|
||||
|
||||
if name in ("discover_skills", "get_default_skill_dirs"):
|
||||
from .discovery import discover_skills, get_default_skill_dirs
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Grounded skill-authoring prompt builder for the "learn from sources" flow.
|
||||
|
||||
This module hosts :func:`build_learn_prompt`, a curated authoring directive that
|
||||
turns a user request like *"read ./my-repo and the docs in ./docs/*.pdf and make
|
||||
a 'deploy-flow' skill"* into a single-turn instruction. The agent then gathers
|
||||
the named sources with the tools it already has (file read, fetch, PDF read) and
|
||||
authors **one grounded SKILL.md** via the existing ``skill_manage`` tool.
|
||||
|
||||
It is intentionally prompt-only (no heavy imports, no new engine) so it stays in
|
||||
the core SDK alongside the skills subsystem.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
_LEARN_HOUSE_STYLE = """You are authoring a single, reusable Agent Skill by learning from real source material.
|
||||
|
||||
The user's request describes the sources to learn from and the skill to produce:
|
||||
<request>
|
||||
{request}
|
||||
</request>
|
||||
|
||||
Follow this procedure exactly:
|
||||
|
||||
1. GATHER the named sources using the tools you already have:
|
||||
- Read local files and directories of code, configs, or notes.
|
||||
- Read PDFs/manuals if PDF reading is available.
|
||||
- Fetch API docs / URLs if a fetch tool is available.
|
||||
- If the request says "what we just did" or "this chat", use the conversation so far as the source.
|
||||
Only use sources the user actually named. Do not browse unrelated material.
|
||||
|
||||
2. DISTIL one grounded SKILL.md. House-style rules that keep it trustworthy:
|
||||
- Use ONLY flags, paths, commands, environment variables, and API names that appear
|
||||
VERBATIM in the gathered sources. Never invent or guess names that are not in the source.
|
||||
- If something is not present in the source, omit it rather than fabricating it.
|
||||
- Keep SKILL.md tight: roughly 100-200 lines. Move long reference material
|
||||
(large code, full API tables, transcripts) into supporting files and reference
|
||||
them by RELATIVE path from SKILL.md.
|
||||
- Include a YAML frontmatter block with at least `name` and `description`.
|
||||
- Write a clear, procedural body: when to use the skill, the concrete steps,
|
||||
and any commands/scripts the user runs, drawn from the real sources.
|
||||
|
||||
3. WRITE the skill with the skill_manage tool in a single create call:
|
||||
skill_manage(action="create", name="<kebab-case-name>", content="<full SKILL.md>")
|
||||
- Derive the skill name from the request (kebab-case). If the request names one
|
||||
explicitly (e.g. a 'deploy-flow' skill), use that name.
|
||||
- For any supporting files, use skill_manage(action="write_file", name=..., file_path=..., file_content=...).
|
||||
|
||||
4. Do not perform any work beyond gathering the sources and authoring this one skill.
|
||||
When the skill is created, briefly summarize the skill name and what it covers.
|
||||
"""
|
||||
|
||||
|
||||
def build_learn_prompt(request: str) -> str:
|
||||
"""Build a grounded skill-authoring directive from a user request.
|
||||
|
||||
This is the **skill authoring** path: distil a reusable ``SKILL.md`` from
|
||||
real sources. It is complementary to — and distinct from — the memory
|
||||
``learn=`` recall path (conversation→memory extraction) and the automatic
|
||||
``self_improve`` loop.
|
||||
|
||||
Args:
|
||||
request: A 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"``.
|
||||
|
||||
Returns:
|
||||
A prompt string that instructs an agent to gather the named sources with
|
||||
its existing tools and author one grounded ``SKILL.md`` via
|
||||
``skill_manage``.
|
||||
"""
|
||||
request = (request or "").strip()
|
||||
return _LEARN_HOUSE_STYLE.replace("{request}", request)
|
||||
@@ -73,6 +73,7 @@ TOOL_MAPPINGS = {
|
||||
|
||||
# Python Tools
|
||||
'execute_code': ('.python_tools', None),
|
||||
'execute_code_with_tools': ('.python_tools', None),
|
||||
'analyze_code': ('.python_tools', None),
|
||||
'format_code': ('.python_tools', None),
|
||||
'lint_code': ('.python_tools', None),
|
||||
@@ -243,8 +244,17 @@ _PROFILE_EXPORTS = frozenset({
|
||||
'register_profile', 'get_profile', 'resolve_profiles', 'list_profiles'
|
||||
})
|
||||
|
||||
# Code-tools bridge exports (lazy loaded to keep import-time cost off the
|
||||
# default path; only resolved when the opt-in code mode is used).
|
||||
_TOOL_PROXY_EXPORTS = frozenset({'ToolProxy', 'build_tool_namespace'})
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Smart lazy loading of tools and profiles."""
|
||||
# Handle code-tools bridge exports
|
||||
if name in _TOOL_PROXY_EXPORTS:
|
||||
from .tool_proxy import ToolProxy, build_tool_namespace
|
||||
return {'ToolProxy': ToolProxy, 'build_tool_namespace': build_tool_namespace}[name]
|
||||
|
||||
# Handle circuit breaker imports first
|
||||
if name in _CIRCUIT_BREAKER_EXPORTS:
|
||||
from .circuit_breaker import (
|
||||
@@ -339,7 +349,7 @@ def __getattr__(name: str) -> Any:
|
||||
if name in [
|
||||
'duckduckgo', 'internet_search', 'searxng_search', 'searxng',
|
||||
'scrape_page', 'extract_links', 'crawl', 'extract_text',
|
||||
'execute_command', 'execute_code', 'analyze_code', 'format_code', 'lint_code', 'disassemble_code', 'list_processes', 'kill_process', 'get_system_info',
|
||||
'execute_command', 'execute_code', 'execute_code_with_tools', 'analyze_code', 'format_code', 'lint_code', 'disassemble_code', 'list_processes', 'kill_process', 'get_system_info',
|
||||
'tavily', 'tavily_search', 'tavily_extract', 'tavily_crawl', 'tavily_map',
|
||||
'tavily_search_async', 'tavily_extract_async',
|
||||
'ydc', 'ydc_search', 'ydc_contents', 'ydc_news', 'ydc_images',
|
||||
@@ -380,6 +390,7 @@ __all__ = list(TOOL_MAPPINGS.keys()) + [
|
||||
'tool', 'FunctionTool',
|
||||
'get_registry', 'register_tool', 'get_tool', 'add_tool', 'has_tool', 'remove_tool',
|
||||
'list_tools', 'list_available_tools', 'list_tools_with_allowed_filter', 'list_tools_with_hermes_filter', 'ToolRegistry',
|
||||
'ToolProxy', 'build_tool_namespace',
|
||||
'Tools',
|
||||
# Validation and retry protocols
|
||||
'ValidationResult', 'ToolValidatorProtocol', 'AsyncToolValidatorProtocol', 'PassthroughValidator',
|
||||
|
||||
@@ -15,7 +15,7 @@ Usage:
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Callable, Dict, List, Optional, Type, get_type_hints
|
||||
from typing import Any, Callable, Dict, List, Optional, get_type_hints
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
@@ -134,18 +134,6 @@ class BaseTool(ABC):
|
||||
func_name=self.name
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _python_type_to_json(python_type: Type) -> str:
|
||||
"""Convert Python type to JSON Schema type.
|
||||
|
||||
DEPRECATED: Use annotation_to_json_schema() from schema.py instead.
|
||||
This method is kept for backward compatibility but will be removed.
|
||||
"""
|
||||
# Legacy fallback - delegate to new schema utility and extract type
|
||||
from .schema import annotation_to_json_schema
|
||||
schema = annotation_to_json_schema(python_type)
|
||||
return schema.get("type", "string")
|
||||
|
||||
@abstractmethod
|
||||
def run(self, **kwargs) -> Any:
|
||||
"""Execute the tool with given arguments.
|
||||
|
||||
@@ -25,6 +25,22 @@ _DIAGNOSTICS_MAX_CHARS = 2000
|
||||
# Seconds before a diagnostics subprocess is abandoned.
|
||||
_DIAGNOSTICS_TIMEOUT = 10
|
||||
|
||||
# File extension -> LSP language id, used to pick a language server for the
|
||||
# edited file. Mirrors the languages with a default server in ``lsp/config.py``
|
||||
# (DEFAULT_SERVERS); anything not listed falls back to the per-language checker.
|
||||
_LSP_LANGUAGE_BY_EXT = {
|
||||
".py": "python",
|
||||
".pyi": "python",
|
||||
".js": "javascript",
|
||||
".jsx": "javascript",
|
||||
".mjs": "javascript",
|
||||
".cjs": "javascript",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescript",
|
||||
".rs": "rust",
|
||||
".go": "go",
|
||||
}
|
||||
|
||||
|
||||
class EditTools:
|
||||
"""Tools for file editing and patching operations."""
|
||||
@@ -213,15 +229,161 @@ class EditTools:
|
||||
return tool_name, argv
|
||||
return None
|
||||
|
||||
def _run_diagnostics(self, safe_path: str, display_path: str) -> str:
|
||||
"""Run a checker on ``safe_path`` and return a bounded diagnostics block.
|
||||
@staticmethod
|
||||
def _bound_output(output: str) -> str:
|
||||
"""Truncate ``output`` to the shared diagnostics character budget."""
|
||||
if len(output) > _DIAGNOSTICS_MAX_CHARS:
|
||||
return output[:_DIAGNOSTICS_MAX_CHARS] + "\n... (diagnostics truncated)"
|
||||
return output
|
||||
|
||||
Returns an empty string when diagnostics are disabled, no checker is
|
||||
available, or (in ``auto`` mode) the checker reports no problems. Any
|
||||
exception is logged and swallowed so the edit result is never lost.
|
||||
def _try_lsp_diagnostics(self, safe_path: str, display_path: str) -> Optional[str]:
|
||||
"""Query the existing LSP client for diagnostics on ``safe_path``.
|
||||
|
||||
Returns a formatted, bounded diagnostics block (matching the shape used
|
||||
by the per-language checker) when a language server is available for the
|
||||
file's language, ``None`` when no server is configured/installed so the
|
||||
caller can fall back to the legacy checker. Everything is lazy: the LSP
|
||||
client is only imported and spawned when a server actually exists, so
|
||||
Python-only/silent runs pay no startup cost. Any failure returns
|
||||
``None`` so a missing/slow server never blocks a successful edit.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
ext = os.path.splitext(safe_path)[1].lower()
|
||||
language = _LSP_LANGUAGE_BY_EXT.get(ext)
|
||||
if language is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
from ..lsp.config import DEFAULT_SERVERS
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
server = DEFAULT_SERVERS.get(language)
|
||||
if not server:
|
||||
return None
|
||||
# Only spawn a server whose command is actually installed; otherwise
|
||||
# fall back to the zero-config checker without importing the client.
|
||||
if not shutil.which(server["command"]):
|
||||
return None
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
from ..lsp.client import LSPClient
|
||||
from ..lsp.types import DiagnosticSeverity
|
||||
except Exception as e:
|
||||
logger.debug("LSP diagnostics unavailable for %s: %s", display_path, e)
|
||||
return None
|
||||
|
||||
async def _collect() -> Optional[list]:
|
||||
client = LSPClient(language=language)
|
||||
# Keep the whole exchange inside the shared diagnostics budget so a
|
||||
# slow server cannot stall the edit result.
|
||||
client.config.timeout = min(client.config.timeout, _DIAGNOSTICS_TIMEOUT)
|
||||
try:
|
||||
if not await client.start():
|
||||
return None
|
||||
if not await client.open_document(safe_path):
|
||||
return None
|
||||
# Diagnostics arrive asynchronously via publishDiagnostics; poll
|
||||
# briefly within the timeout budget for the server to report.
|
||||
# ``get_diagnostics`` returns ``[]`` both before any publish and
|
||||
# after a clean publish, so also watch the client's diagnostics
|
||||
# store keyed by URI to tell "not reported yet" from "reported,
|
||||
# no problems" — that lets a clean file return promptly instead
|
||||
# of sleeping the full timeout, and distinguishes a real
|
||||
# all-clear (``[]``) from "never published" (``None``) so the
|
||||
# caller can fall back to the legacy checker only in the latter.
|
||||
loop = asyncio.get_event_loop()
|
||||
uri = f"file://{os.path.abspath(safe_path)}"
|
||||
published = getattr(client, "_diagnostics", None)
|
||||
deadline = loop.time() + _DIAGNOSTICS_TIMEOUT
|
||||
diags: Optional[list] = None
|
||||
while loop.time() < deadline:
|
||||
diags = await client.get_diagnostics(safe_path)
|
||||
if diags or (isinstance(published, dict) and uri in published):
|
||||
break
|
||||
diags = None
|
||||
await asyncio.sleep(0.2)
|
||||
return diags
|
||||
finally:
|
||||
try:
|
||||
await client.close_document(safe_path)
|
||||
except Exception:
|
||||
pass
|
||||
await client.stop()
|
||||
|
||||
try:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
running = False
|
||||
else:
|
||||
running = True
|
||||
if running:
|
||||
# An event loop is already running (e.g. async agent context);
|
||||
# don't risk re-entrancy — defer to the checker fallback.
|
||||
return None
|
||||
diagnostics = asyncio.run(
|
||||
asyncio.wait_for(_collect(), timeout=_DIAGNOSTICS_TIMEOUT + 5)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("LSP diagnostics error for %s: %s", display_path, e)
|
||||
return None
|
||||
|
||||
if diagnostics is None:
|
||||
return None
|
||||
|
||||
if not diagnostics:
|
||||
if self._post_edit_diagnostics == "on":
|
||||
return f"\n\nDiagnostics (lsp:{language}): no problems found"
|
||||
return ""
|
||||
|
||||
_severity_label = {
|
||||
DiagnosticSeverity.ERROR: "error",
|
||||
DiagnosticSeverity.WARNING: "warning",
|
||||
DiagnosticSeverity.INFORMATION: "info",
|
||||
DiagnosticSeverity.HINT: "hint",
|
||||
}
|
||||
lines = []
|
||||
for d in diagnostics:
|
||||
try:
|
||||
line = d.range.start.line + 1
|
||||
char = d.range.start.character + 1
|
||||
label = _severity_label.get(d.severity, "error")
|
||||
message = " ".join((d.message or "").split())
|
||||
lines.append(f"{display_path}:{line}:{char}: {label}: {message}")
|
||||
except Exception:
|
||||
continue
|
||||
if not lines:
|
||||
if self._post_edit_diagnostics == "on":
|
||||
return f"\n\nDiagnostics (lsp:{language}): no problems found"
|
||||
return ""
|
||||
|
||||
output = self._bound_output("\n".join(lines))
|
||||
return f"\n\nDiagnostics (lsp:{language}):\n{output}"
|
||||
|
||||
def _run_diagnostics(self, safe_path: str, display_path: str) -> str:
|
||||
"""Run diagnostics on ``safe_path`` and return a bounded diagnostics block.
|
||||
|
||||
Tries the wired-in LSP client first (for any language the user has a
|
||||
language server installed for) and falls back to the zero-config
|
||||
per-language checker when no server is available. Returns an empty
|
||||
string when diagnostics are disabled, no checker is available, or (in
|
||||
``auto`` mode) no problems are found. Any exception is logged and
|
||||
swallowed so the edit result is never lost.
|
||||
"""
|
||||
if self._post_edit_diagnostics == "off":
|
||||
return ""
|
||||
# Prefer the existing LSP client when a language server is available so
|
||||
# multi-language edits get language-aware diagnostics; ``None`` means no
|
||||
# server, so we fall through to the legacy checker below.
|
||||
try:
|
||||
lsp_block = self._try_lsp_diagnostics(safe_path, display_path)
|
||||
if lsp_block is not None:
|
||||
return lsp_block
|
||||
except Exception as e: # never let LSP wiring break a successful edit
|
||||
logger.debug("LSP diagnostics skipped for %s: %s", display_path, e)
|
||||
try:
|
||||
resolved = self._diagnostics_command(safe_path)
|
||||
if resolved is None:
|
||||
@@ -255,8 +417,7 @@ class EditTools:
|
||||
|
||||
if not output:
|
||||
output = f"check failed (exit code {proc.returncode})"
|
||||
if len(output) > _DIAGNOSTICS_MAX_CHARS:
|
||||
output = output[:_DIAGNOSTICS_MAX_CHARS] + "\n... (diagnostics truncated)"
|
||||
output = self._bound_output(output)
|
||||
return f"\n\nDiagnostics ({tool_name}):\n{output}"
|
||||
except Exception as e: # never let diagnostics break a successful edit
|
||||
logger.debug("Diagnostics error for %s: %s", display_path, e)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Path containment helpers for tool and context safety."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def resolve_within_root(path: str, root: Optional[str] = None) -> Optional[str]:
|
||||
"""Resolve *path* under *root* and reject traversal outside the root."""
|
||||
base = os.path.realpath(root or os.getcwd())
|
||||
expanded = os.path.expanduser(path)
|
||||
candidate = (
|
||||
os.path.join(base, expanded)
|
||||
if not os.path.isabs(expanded)
|
||||
else expanded
|
||||
)
|
||||
resolved = os.path.realpath(os.path.normpath(candidate))
|
||||
try:
|
||||
if os.path.commonpath([resolved, base]) != base:
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
return resolved
|
||||
@@ -388,12 +388,63 @@ def execute_code(
|
||||
)
|
||||
|
||||
|
||||
def execute_code_with_tools(
|
||||
code: str,
|
||||
allowed_tools: Optional[List[str]] = None,
|
||||
registry: Optional[Any] = None,
|
||||
timeout: int = 30,
|
||||
max_output_size: int = 10000,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute model-generated code that may call the agent's registered tools.
|
||||
|
||||
This is the *code-execution-with-tools* (code mode) bridge. Unlike the
|
||||
subprocess sandbox (which runs in a clean process and therefore cannot see
|
||||
the agent's tools), this runs in-process with restricted builtins and
|
||||
injects thin proxies for the tools on ``allowed_tools``. Each proxy call
|
||||
resolves against the :class:`ToolRegistry`, enforces the allow-list, and
|
||||
passes through the existing ``require_approval`` gate. Only the script's
|
||||
stdout / last-expression value is returned — intermediate tool results stay
|
||||
out of the caller's context.
|
||||
|
||||
Args:
|
||||
code: Python code to execute. May call allowed tools by bare name
|
||||
(e.g. ``fetch(...)``) or via ``tools.fetch(...)``.
|
||||
allowed_tools: Explicit per-run allow-list of tool names callable from
|
||||
code. Empty/None means no tools are exposed.
|
||||
registry: Optional ToolRegistry; defaults to the global registry.
|
||||
timeout: Maximum execution time in seconds.
|
||||
max_output_size: Maximum output size in characters.
|
||||
|
||||
Returns:
|
||||
Dictionary with result, stdout, stderr, and success status.
|
||||
"""
|
||||
from .tool_proxy import ToolProxy, build_tool_namespace
|
||||
|
||||
allowed = list(allowed_tools or [])
|
||||
if "tools" in allowed:
|
||||
raise ValueError(
|
||||
"'tools' is a reserved name in code mode and cannot be an "
|
||||
"allow-listed tool; rename the tool."
|
||||
)
|
||||
injected: Dict[str, Any] = {}
|
||||
if allowed:
|
||||
injected.update(build_tool_namespace(allowed, registry=registry))
|
||||
injected["tools"] = ToolProxy(allowed, registry=registry)
|
||||
|
||||
return _execute_code_direct(
|
||||
code,
|
||||
globals_dict=injected or None,
|
||||
timeout=timeout,
|
||||
max_output_size=max_output_size,
|
||||
)
|
||||
|
||||
|
||||
def _execute_code_direct(
|
||||
code: str,
|
||||
globals_dict: Optional[Dict[str, Any]] = None,
|
||||
locals_dict: Optional[Dict[str, Any]] = None,
|
||||
timeout: int = 30,
|
||||
max_output_size: int = 10000
|
||||
max_output_size: int = 10000,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute Python code directly in current process with restricted builtins.
|
||||
|
||||
|
||||
@@ -72,6 +72,13 @@ class SkillTools:
|
||||
if not os.path.isabs(script_path):
|
||||
script_path = os.path.join(self._working_directory, script_path)
|
||||
script_path = os.path.abspath(script_path)
|
||||
|
||||
root = os.path.realpath(self._working_directory)
|
||||
try:
|
||||
if os.path.commonpath([script_path, root]) != root:
|
||||
return f"Error: Script path outside working directory: {script_path}"
|
||||
except ValueError:
|
||||
return f"Error: Script path outside working directory: {script_path}"
|
||||
|
||||
if not os.path.exists(script_path):
|
||||
return f"Error: Script not found at {script_path}"
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Bridge that lets model-generated code call registered tools (code mode).
|
||||
|
||||
This connects three pieces that already live in core but were not wired
|
||||
together:
|
||||
|
||||
* the in-process code executor (``tools/python_tools.py``)
|
||||
* the runtime tool registry (``tools/registry.py``)
|
||||
* the approval framework (``approval/__init__.py``)
|
||||
|
||||
When enabled, the sandbox namespace is populated with thin proxies for the
|
||||
agent's *allowed* tools. Each proxy call resolves the name against the
|
||||
``ToolRegistry``, enforces an explicit per-run allow-list, passes through the
|
||||
existing ``require_approval`` gate, runs the real tool, and returns the result
|
||||
into the running script. Only the script's stdout / return value flows back to
|
||||
the model — intermediate tool results never enter the context window.
|
||||
|
||||
Safe by default: tools are callable from code only when the developer turns the
|
||||
mode on and only for tools on the allow-list; every call still passes the
|
||||
approval gate.
|
||||
|
||||
Security note: the proxy must never expose the underlying ``ToolRegistry`` or
|
||||
the allow-list to sandboxed code. Storing them as instance attributes is unsafe
|
||||
because plain ``obj._registry`` attribute access bypasses ``__getattr__`` (the
|
||||
attribute is found in ``__dict__`` first) and the sandbox's ``getattr`` guard
|
||||
(direct attribute access compiles to ``LOAD_ATTR``, not a ``getattr`` call). We
|
||||
therefore keep all state in a closure that is unreachable from any attribute.
|
||||
"""
|
||||
|
||||
from typing import Any, Callable, Dict, Iterable, Optional
|
||||
|
||||
from .registry import ToolRegistry, get_registry
|
||||
|
||||
|
||||
def _resolve_callable(tool: Any) -> Callable[..., Any]:
|
||||
"""Return the underlying callable for a registered tool/BaseTool."""
|
||||
callable_tool = tool.run if hasattr(tool, "run") and not callable(tool) else tool
|
||||
if not callable(callable_tool):
|
||||
callable_tool = getattr(tool, "run", tool)
|
||||
return callable_tool
|
||||
|
||||
|
||||
def _invoke_with_approval(
|
||||
name: str, tool: Any, args: tuple, kwargs: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""Run a registered tool honouring the existing approval framework.
|
||||
|
||||
The ``require_approval`` decorator (when applied to the tool) already gates
|
||||
the call. For tools that are not decorated but are registered as requiring
|
||||
approval, we apply the same gate here so code-mode calls cannot bypass it.
|
||||
|
||||
Positional arguments are bound to their parameter names before being sent to
|
||||
the approval backend so the human approver sees every argument value and can
|
||||
rewrite any of them via ``decision.modified_args``.
|
||||
"""
|
||||
from ..approval import (
|
||||
is_approval_required,
|
||||
is_yaml_approved,
|
||||
is_env_auto_approve,
|
||||
request_approval,
|
||||
)
|
||||
|
||||
callable_tool = _resolve_callable(tool)
|
||||
|
||||
# Note: unlike the regular agent path we deliberately do NOT honour the
|
||||
# per-session ``is_already_approved`` sticky flag, nor do we call
|
||||
# ``mark_approved`` after approval. In code mode the model controls the whole
|
||||
# script, so a single approved call must not silently unlock every later call
|
||||
# to the same tool with attacker-chosen arguments. Each code-mode call is
|
||||
# gated independently (YAML / env auto-approve still apply as configured).
|
||||
needs_gate = is_approval_required(name) and not (
|
||||
is_yaml_approved(name)
|
||||
or is_env_auto_approve()
|
||||
)
|
||||
|
||||
if needs_gate:
|
||||
from ..utils.async_bridge import (
|
||||
is_async_context,
|
||||
run_coroutine_from_any_context,
|
||||
)
|
||||
|
||||
if is_async_context():
|
||||
raise PermissionError(
|
||||
f"Tool '{name}' requires approval but cannot prompt from an "
|
||||
f"async context. Configure a non-console approval backend."
|
||||
)
|
||||
|
||||
# Bind positional args to parameter names so the approval backend sees
|
||||
# (and can modify) every argument, not just the keyword ones.
|
||||
approval_args: Dict[str, Any] = dict(kwargs)
|
||||
bound = None
|
||||
try:
|
||||
import inspect
|
||||
|
||||
signature = inspect.signature(callable_tool)
|
||||
bound = signature.bind_partial(*args, **kwargs)
|
||||
approval_args = dict(bound.arguments)
|
||||
except (TypeError, ValueError):
|
||||
# Signature unavailable/unbindable: fall back to positional preview.
|
||||
for index, value in enumerate(args):
|
||||
approval_args.setdefault(f"arg{index}", value)
|
||||
|
||||
decision = run_coroutine_from_any_context(
|
||||
request_approval(name, approval_args)
|
||||
)
|
||||
if not decision.approved:
|
||||
raise PermissionError(
|
||||
f"Execution of {name} denied: {decision.reason}"
|
||||
)
|
||||
if decision.modified_args:
|
||||
approval_args.update(decision.modified_args)
|
||||
if bound is not None:
|
||||
try:
|
||||
rebound = signature.bind_partial(**approval_args)
|
||||
return callable_tool(*rebound.args, **rebound.kwargs)
|
||||
except TypeError:
|
||||
pass
|
||||
# Fall back: apply only keyword modifications.
|
||||
kwargs.update(
|
||||
{k: v for k, v in decision.modified_args.items() if k in kwargs}
|
||||
)
|
||||
|
||||
return callable_tool(*args, **kwargs)
|
||||
|
||||
|
||||
def _make_proxy(
|
||||
name: str,
|
||||
allowed: frozenset,
|
||||
registry: ToolRegistry,
|
||||
) -> Callable[..., Any]:
|
||||
"""Build a single tool proxy enforcing the allow-list and approval gate."""
|
||||
if name not in allowed:
|
||||
raise PermissionError(f"tool '{name}' is not allowed from code")
|
||||
tool = registry.get(name)
|
||||
if tool is None:
|
||||
raise NameError(f"tool '{name}' is not registered")
|
||||
|
||||
def _proxy(*args: Any, **kwargs: Any) -> Any:
|
||||
return _invoke_with_approval(name, tool, args, kwargs)
|
||||
|
||||
_proxy.__name__ = name
|
||||
return _proxy
|
||||
|
||||
|
||||
class ToolProxy:
|
||||
"""Namespace object exposed to sandboxed code as ``tools``.
|
||||
|
||||
Resolves tool names against a :class:`ToolRegistry`, enforces an
|
||||
allow-list, and routes each call through the approval gate.
|
||||
|
||||
The registry and allow-list are held in a closure (``__getattribute__``
|
||||
override) rather than as instance attributes, so sandboxed code cannot reach
|
||||
them through plain attribute access (e.g. ``tools._registry``) to bypass the
|
||||
allow-list or approval gate.
|
||||
|
||||
Example (inside model-generated code)::
|
||||
|
||||
best = min(extract_price(fetch(u)) for u in urls)
|
||||
print(best)
|
||||
|
||||
where ``fetch`` / ``extract_price`` are exposed as proxies.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
allowed: Iterable[str],
|
||||
registry: Optional[ToolRegistry] = None,
|
||||
) -> None:
|
||||
allowed_set = frozenset(allowed)
|
||||
resolved_registry = registry or get_registry()
|
||||
|
||||
def _getter(name: str) -> Callable[..., Any]:
|
||||
return _make_proxy(name, allowed_set, resolved_registry)
|
||||
|
||||
# Stash all state inside a closure; never as an instance attribute.
|
||||
object.__setattr__(self, "_ToolProxy__getter", _getter)
|
||||
object.__setattr__(self, "_ToolProxy__names", sorted(allowed_set))
|
||||
|
||||
def __getattribute__(self, name: str) -> Any:
|
||||
# Allow a tiny set of dunder methods needed for normal object use;
|
||||
# everything else is treated as a tool-name lookup.
|
||||
# NB: ``__init__`` is deliberately excluded — exposing it would let
|
||||
# sandboxed code call ``tools.__init__([...])`` to re-bind the proxy's
|
||||
# closure to the global registry with an attacker-chosen allow-list,
|
||||
# bypassing the per-run allow-list entirely.
|
||||
if name in ("__class__", "__dict__", "__repr__", "__dir__"):
|
||||
return object.__getattribute__(self, name)
|
||||
if name.startswith("_"):
|
||||
raise AttributeError(name)
|
||||
getter = object.__getattribute__(self, "_ToolProxy__getter")
|
||||
return getter(name)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
raise AttributeError("ToolProxy is read-only")
|
||||
|
||||
def __delattr__(self, name: str) -> None:
|
||||
raise AttributeError("ToolProxy is read-only")
|
||||
|
||||
def __dir__(self):
|
||||
return list(object.__getattribute__(self, "_ToolProxy__names"))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
names = object.__getattribute__(self, "_ToolProxy__names")
|
||||
return f"ToolProxy(allowed={names})"
|
||||
|
||||
|
||||
def build_tool_namespace(
|
||||
allowed: Iterable[str],
|
||||
registry: Optional[ToolRegistry] = None,
|
||||
) -> Dict[str, Callable[..., Any]]:
|
||||
"""Build a dict of ``{tool_name: proxy_callable}`` for the allow-list.
|
||||
|
||||
The returned mapping can be merged directly into the executor globals so
|
||||
the model can call ``fetch(...)`` by bare name, in addition to the
|
||||
``tools.fetch(...)`` form via :class:`ToolProxy`.
|
||||
"""
|
||||
allowed_set = frozenset(allowed)
|
||||
resolved_registry = registry or get_registry()
|
||||
namespace: Dict[str, Callable[..., Any]] = {}
|
||||
for name in sorted(allowed_set):
|
||||
try:
|
||||
namespace[name] = _make_proxy(name, allowed_set, resolved_registry)
|
||||
except NameError:
|
||||
# Tool on the allow-list but not registered yet — skip silently;
|
||||
# calling it would raise NameError at runtime anyway.
|
||||
continue
|
||||
return namespace
|
||||
@@ -99,6 +99,14 @@ def _crawl_with_crawl4ai(urls: List[str]) -> List[Dict[str, Any]]:
|
||||
results = []
|
||||
async with AsyncWebCrawler() as crawler:
|
||||
for url in urls:
|
||||
if not _is_safe_crawl_url(url):
|
||||
results.append({
|
||||
"url": url,
|
||||
"content": "",
|
||||
"error": "URL blocked by SSRF policy",
|
||||
"provider": "crawl4ai",
|
||||
})
|
||||
continue
|
||||
try:
|
||||
result = await crawler.arun(url=url)
|
||||
results.append({
|
||||
@@ -132,32 +140,9 @@ def _crawl_with_crawl4ai(urls: List[str]) -> List[Dict[str, Any]]:
|
||||
|
||||
def _is_safe_crawl_url(url: str) -> bool:
|
||||
"""Return True when a URL passes SSRF checks for crawling."""
|
||||
import urllib.parse
|
||||
import socket
|
||||
import ipaddress
|
||||
from praisonaiagents.tools.url_safety import is_safe_http_url
|
||||
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
return False
|
||||
if os.environ.get("ALLOW_LOCAL_CRAWL") == "true":
|
||||
return True
|
||||
for info in socket.getaddrinfo(hostname, None):
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
if (
|
||||
ip.is_loopback
|
||||
or ip.is_private
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
or ip.is_unspecified
|
||||
):
|
||||
return False
|
||||
return True
|
||||
except (socket.gaierror, ValueError, OSError):
|
||||
return False
|
||||
return is_safe_http_url(url)
|
||||
|
||||
|
||||
def _crawl_with_httpx(urls: List[str]) -> List[Dict[str, Any]]:
|
||||
@@ -193,6 +178,8 @@ def _crawl_with_httpx(urls: List[str]) -> List[Dict[str, Any]]:
|
||||
except ImportError:
|
||||
# Fallback to urllib
|
||||
import urllib.request
|
||||
if not _is_safe_crawl_url(url):
|
||||
raise ValueError("URL blocked by SSRF policy")
|
||||
with urllib.request.urlopen(url, timeout=30) as response:
|
||||
content = response.read().decode('utf-8', errors='ignore')
|
||||
|
||||
|
||||
@@ -104,6 +104,29 @@ def _parse_json_output(output: Any, step_name: str = "step") -> Any:
|
||||
logger.debug(f"Could not parse JSON from step '{step_name}' output")
|
||||
return output
|
||||
|
||||
def _substitute_action_variables(action: str, all_variables: dict, previous_output: Any, task_input: str) -> str:
|
||||
"""
|
||||
Substitute templating placeholders in a prompt/action string.
|
||||
|
||||
Performs, in order:
|
||||
- ``{{key}}`` substitution for each key/value in ``all_variables``
|
||||
- ``{{previous_output}}`` in-place replacement when present, otherwise
|
||||
appending a "Context from previous step" section (only if there is a
|
||||
previous output)
|
||||
- ``{{input}}`` substitution
|
||||
|
||||
Returns the rendered action string.
|
||||
"""
|
||||
for key, value in all_variables.items():
|
||||
action = action.replace(f"{{{{{key}}}}}", str(value))
|
||||
if previous_output:
|
||||
if "{{previous_output}}" in action:
|
||||
action = action.replace("{{previous_output}}", str(previous_output))
|
||||
else:
|
||||
# Auto-append context if not explicitly referenced
|
||||
action = f"{action}\n\nContext from previous step:\n{previous_output}"
|
||||
return action.replace("{{input}}", task_input)
|
||||
|
||||
def _extract_from_schema_echo(data: dict) -> Any:
|
||||
"""
|
||||
Extract actual data when LLM echoes the JSON schema structure.
|
||||
@@ -865,6 +888,15 @@ class AgentFlow:
|
||||
tools_path = workflow_dir / "tools.py"
|
||||
|
||||
if tools_path.exists():
|
||||
_local_ok = os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").strip().lower() in (
|
||||
"true", "1", "yes",
|
||||
)
|
||||
if not _local_ok:
|
||||
logger.debug(
|
||||
"Skipping tools.py load for %s: set PRAISONAI_ALLOW_LOCAL_TOOLS=true",
|
||||
class_name,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("tools", tools_path)
|
||||
@@ -1209,15 +1241,7 @@ class AgentFlow:
|
||||
|
||||
# Substitute variables in action
|
||||
action = step.action or input
|
||||
for key, value in all_variables.items():
|
||||
action = action.replace(f"{{{{{key}}}}}", str(value))
|
||||
if previous_output:
|
||||
if "{{previous_output}}" in action:
|
||||
action = action.replace("{{previous_output}}", str(previous_output))
|
||||
else:
|
||||
# Auto-append context if not explicitly referenced
|
||||
action = f"{action}\n\nContext from previous step:\n{previous_output}"
|
||||
action = action.replace("{{input}}", input)
|
||||
action = _substitute_action_variables(action, all_variables, previous_output, input)
|
||||
|
||||
# Add reasoning prompt if enabled
|
||||
if self.reasoning:
|
||||
@@ -1317,15 +1341,7 @@ class AgentFlow:
|
||||
)
|
||||
# Substitute variables in action
|
||||
action = step.action
|
||||
for key, value in all_variables.items():
|
||||
action = action.replace(f"{{{{{key}}}}}", str(value))
|
||||
if previous_output:
|
||||
if "{{previous_output}}" in action:
|
||||
action = action.replace("{{previous_output}}", str(previous_output))
|
||||
else:
|
||||
# Auto-append context if not explicitly referenced
|
||||
action = f"{action}\n\nContext from previous step:\n{previous_output}"
|
||||
action = action.replace("{{input}}", input)
|
||||
action = _substitute_action_variables(action, all_variables, previous_output, input)
|
||||
|
||||
# Add reasoning prompt if enabled
|
||||
if self.reasoning:
|
||||
@@ -2170,15 +2186,7 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
|
||||
|
||||
action = normalized.action or input
|
||||
# Substitute variables
|
||||
for key, value in all_variables.items():
|
||||
action = action.replace(f"{{{{{key}}}}}", str(value))
|
||||
if previous_output:
|
||||
if "{{previous_output}}" in action:
|
||||
action = action.replace("{{previous_output}}", str(previous_output))
|
||||
else:
|
||||
# Auto-append context if not explicitly referenced
|
||||
action = f"{action}\n\nContext from previous step:\n{previous_output}"
|
||||
action = action.replace("{{input}}", input)
|
||||
action = _substitute_action_variables(action, all_variables, previous_output, input)
|
||||
|
||||
# Check if this is a specialized agent (AudioAgent, VideoAgent, ImageAgent, OCRAgent)
|
||||
agent_class_name = normalized.agent.__class__.__name__
|
||||
@@ -2213,14 +2221,7 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
|
||||
hooks=self.hooks, # Propagate hooks/callbacks
|
||||
)
|
||||
action = normalized.action
|
||||
for key, value in all_variables.items():
|
||||
action = action.replace(f"{{{{{key}}}}}", str(value))
|
||||
if previous_output:
|
||||
if "{{previous_output}}" in action:
|
||||
action = action.replace("{{previous_output}}", str(previous_output))
|
||||
else:
|
||||
# Auto-append context if not explicitly referenced
|
||||
action = f"{action}\n\nContext from previous step:\n{previous_output}"
|
||||
action = _substitute_action_variables(action, all_variables, previous_output, input)
|
||||
|
||||
output = temp_agent.chat(action, stream=stream)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.74"
|
||||
version = "1.6.80"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Tests for runtime MCP server management on Agent.
|
||||
|
||||
These tests use a lightweight fake MCP object so they do not require a live
|
||||
MCP server or the optional ``mcp`` package to be installed.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
|
||||
class FakeMCP:
|
||||
"""Minimal stand-in for an MCP instance (iterable of tool callables)."""
|
||||
|
||||
def __init__(self, name):
|
||||
self._name = name
|
||||
self.shutdown_called = False
|
||||
|
||||
def _tool():
|
||||
return f"{name}-result"
|
||||
|
||||
_tool.__name__ = f"{name}_tool"
|
||||
self._tools = [_tool]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._tools)
|
||||
|
||||
def get_tools(self):
|
||||
return self._tools
|
||||
|
||||
def shutdown(self):
|
||||
self.shutdown_called = True
|
||||
|
||||
|
||||
def _make_agent():
|
||||
return Agent(instructions="test", llm="gpt-4o-mini")
|
||||
|
||||
|
||||
def test_add_mcp_server_makes_tools_available():
|
||||
agent = _make_agent()
|
||||
mcp = FakeMCP("notion")
|
||||
|
||||
agent.add_mcp_server("notion", mcp)
|
||||
|
||||
assert "notion" in agent.list_mcp_servers()
|
||||
assert mcp in agent.tools
|
||||
|
||||
|
||||
def test_add_duplicate_name_raises():
|
||||
agent = _make_agent()
|
||||
agent.add_mcp_server("notion", FakeMCP("notion"))
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
agent.add_mcp_server("notion", FakeMCP("notion2"))
|
||||
|
||||
|
||||
def test_add_empty_name_raises():
|
||||
agent = _make_agent()
|
||||
with pytest.raises(ValueError):
|
||||
agent.add_mcp_server("", FakeMCP("x"))
|
||||
|
||||
|
||||
def test_remove_mcp_server_disconnects_and_deregisters():
|
||||
agent = _make_agent()
|
||||
mcp = FakeMCP("notion")
|
||||
agent.add_mcp_server("notion", mcp)
|
||||
|
||||
removed = agent.remove_mcp_server("notion")
|
||||
|
||||
assert removed is True
|
||||
assert mcp.shutdown_called is True
|
||||
assert mcp not in agent.tools
|
||||
assert "notion" not in agent.list_mcp_servers()
|
||||
|
||||
|
||||
def test_remove_unknown_returns_false():
|
||||
agent = _make_agent()
|
||||
assert agent.remove_mcp_server("nope") is False
|
||||
|
||||
|
||||
def test_refresh_tools_returns_current_tools():
|
||||
agent = _make_agent()
|
||||
mcp = FakeMCP("notion")
|
||||
agent.add_mcp_server("notion", mcp)
|
||||
|
||||
tools = agent.refresh_tools()
|
||||
assert mcp in tools
|
||||
|
||||
|
||||
def test_add_normalizes_non_list_tools():
|
||||
agent = Agent(instructions="test", llm="gpt-4o-mini")
|
||||
# Force a non-list tools attribute to exercise normalization
|
||||
agent.tools = ()
|
||||
mcp = FakeMCP("notion")
|
||||
|
||||
agent.add_mcp_server("notion", mcp)
|
||||
|
||||
assert isinstance(agent.tools, list)
|
||||
assert mcp in agent.tools
|
||||
|
||||
|
||||
def test_add_remove_cycle_allows_reattach():
|
||||
agent = _make_agent()
|
||||
agent.add_mcp_server("notion", FakeMCP("notion"))
|
||||
agent.remove_mcp_server("notion")
|
||||
# Should be able to attach again under the same name
|
||||
agent.add_mcp_server("notion", FakeMCP("notion"))
|
||||
assert "notion" in agent.list_mcp_servers()
|
||||
|
||||
|
||||
def test_close_shuts_down_runtime_mcp_servers():
|
||||
agent = _make_agent()
|
||||
mcp = FakeMCP("notion")
|
||||
agent.add_mcp_server("notion", mcp)
|
||||
|
||||
agent.close()
|
||||
|
||||
assert mcp.shutdown_called is True
|
||||
assert agent.list_mcp_servers() == []
|
||||
|
||||
|
||||
def test_aclose_shuts_down_runtime_mcp_servers():
|
||||
agent = _make_agent()
|
||||
mcp = FakeMCP("notion")
|
||||
agent.add_mcp_server("notion", mcp)
|
||||
|
||||
asyncio.run(agent.aclose())
|
||||
|
||||
assert mcp.shutdown_called is True
|
||||
assert agent.list_mcp_servers() == []
|
||||
|
||||
|
||||
def test_close_continues_after_one_shutdown_failure():
|
||||
agent = _make_agent()
|
||||
failing = FakeMCP("bad")
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("shutdown failed")
|
||||
|
||||
failing.shutdown = _boom
|
||||
good = FakeMCP("good")
|
||||
|
||||
agent.add_mcp_server("bad", failing)
|
||||
agent.add_mcp_server("good", good)
|
||||
|
||||
# One failure must not abort the rest, and the registry is cleared.
|
||||
agent.close()
|
||||
|
||||
assert good.shutdown_called is True
|
||||
assert agent.list_mcp_servers() == []
|
||||
@@ -334,6 +334,29 @@ class TestKnowledgePresets:
|
||||
)
|
||||
assert isinstance(result, MockKnowledgeConfig)
|
||||
assert "docs/" in result.sources
|
||||
|
||||
def test_knowledge_windows_path_string(self):
|
||||
"""Windows-style directory paths should be treated as sources."""
|
||||
from praisonaiagents.config.param_resolver import resolve
|
||||
|
||||
result = resolve(
|
||||
value="C:\\knowledge",
|
||||
param_name="knowledge",
|
||||
config_class=MockKnowledgeConfig,
|
||||
string_mode="path_as_source",
|
||||
)
|
||||
assert isinstance(result, MockKnowledgeConfig)
|
||||
assert result.sources == ["C:\\knowledge"]
|
||||
|
||||
for windows_path in ("\\knowledge", "~\\knowledge", "knowledge\\docs"):
|
||||
result = resolve(
|
||||
value=windows_path,
|
||||
param_name="knowledge",
|
||||
config_class=MockKnowledgeConfig,
|
||||
string_mode="path_as_source",
|
||||
)
|
||||
assert isinstance(result, MockKnowledgeConfig)
|
||||
assert result.sources == [windows_path]
|
||||
|
||||
def test_knowledge_array_sources(self):
|
||||
"""Array of paths should be sources list."""
|
||||
|
||||
@@ -478,3 +478,176 @@ class TestNoCortexParam:
|
||||
|
||||
spec = importlib.util.find_spec("praisonaiagents.cortex")
|
||||
assert spec is None, "praisonaiagents.cortex module should not exist"
|
||||
|
||||
|
||||
class TestUsageTelemetry:
|
||||
"""Test use_count/last_used usage telemetry on retrieval."""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.store_path = os.path.join(self.temp_dir, "persona.json")
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_new_entry_defaults(self):
|
||||
"""New entries start with use_count 0 and no last_used."""
|
||||
store = PersonaStore(store_path=self.store_path, user_id="u")
|
||||
entry = store.add("User likes Python")
|
||||
assert entry.use_count == 0
|
||||
assert entry.last_used is None
|
||||
|
||||
def test_search_bumps_use_count(self):
|
||||
"""search() records access on returned entries."""
|
||||
store = PersonaStore(store_path=self.store_path, user_id="u")
|
||||
store.add("User likes Python")
|
||||
results = store.search("Python")
|
||||
assert len(results) == 1
|
||||
assert results[0].use_count == 1
|
||||
assert results[0].last_used is not None
|
||||
|
||||
def test_list_all_bumps_use_count(self):
|
||||
"""list_all() records access on returned entries."""
|
||||
store = PersonaStore(store_path=self.store_path, user_id="u")
|
||||
store.add("User likes Python")
|
||||
store.list_all()
|
||||
store.list_all()
|
||||
entry = list(store._entries.values())[0]
|
||||
assert entry.use_count == 2
|
||||
|
||||
def test_telemetry_persisted(self):
|
||||
"""Usage telemetry survives reload."""
|
||||
store = PersonaStore(store_path=self.store_path, user_id="u")
|
||||
store.add("User likes Python")
|
||||
store.search("Python")
|
||||
reloaded = PersonaStore(store_path=self.store_path, user_id="u")
|
||||
entry = list(reloaded._entries.values())[0]
|
||||
assert entry.use_count == 1
|
||||
assert entry.last_used is not None
|
||||
|
||||
def test_entry_roundtrip_preserves_telemetry(self):
|
||||
"""LearnEntry to_dict/from_dict preserves telemetry fields."""
|
||||
entry = LearnEntry(id="x", content="c", use_count=3, last_used="2025-01-01T00:00:00")
|
||||
restored = LearnEntry.from_dict(entry.to_dict())
|
||||
assert restored.use_count == 3
|
||||
assert restored.last_used == "2025-01-01T00:00:00"
|
||||
|
||||
|
||||
class TestBoundedRetention:
|
||||
"""Test max_entries / retention_days pruning and archival."""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.store_path = os.path.join(self.temp_dir, "insights.json")
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_unbounded_by_default(self):
|
||||
"""No limits configured -> behaviour unchanged, prune is a no-op."""
|
||||
store = InsightStore(store_path=self.store_path, user_id="u")
|
||||
for i in range(20):
|
||||
store.add(f"insight {i}")
|
||||
assert len(store._entries) == 20
|
||||
assert store.prune() == 0
|
||||
|
||||
def test_max_entries_evicts_on_add(self):
|
||||
"""Exceeding max_entries archives least-used/oldest on write."""
|
||||
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=5)
|
||||
for i in range(10):
|
||||
store.add(f"insight {i}")
|
||||
assert len(store._entries) == 5
|
||||
|
||||
def test_max_entries_keeps_most_used(self):
|
||||
"""Least-used entries are evicted first."""
|
||||
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=3)
|
||||
store.add("keep me")
|
||||
for i in range(2):
|
||||
store.add(f"filler {i}")
|
||||
# Bump usage on the entry we want to keep via the public retrieval path
|
||||
for _ in range(5):
|
||||
assert store.search("keep me")
|
||||
for i in range(5):
|
||||
store.add(f"new {i}")
|
||||
contents = [e.content for e in store._entries.values()]
|
||||
assert "keep me" in contents
|
||||
assert len(store._entries) == 3
|
||||
|
||||
def test_new_entry_not_evicted_when_full(self):
|
||||
"""A freshly added entry is never the one pruned away on write."""
|
||||
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=3)
|
||||
for i in range(3):
|
||||
store.add(f"old {i}")
|
||||
# Give existing entries usage so the newcomer is the least-used
|
||||
for _ in range(3):
|
||||
store.search("old")
|
||||
new = store.add("brand new")
|
||||
assert store.get(new.id) is not None
|
||||
contents = [e.content for e in store._entries.values()]
|
||||
assert "brand new" in contents
|
||||
assert len(store._entries) == 3
|
||||
|
||||
def test_archive_failure_restores_entries(self):
|
||||
"""If archival cannot persist, pruned entries stay in the active store."""
|
||||
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=2)
|
||||
for i in range(5):
|
||||
store.add(f"insight {i}")
|
||||
# Force the archive write to fail; entries must not be lost
|
||||
store._archive = lambda entries: False
|
||||
before = dict(store._entries)
|
||||
store.add("trigger prune")
|
||||
# No entries dropped beyond the new add (archival failed -> restore)
|
||||
assert set(before).issubset(set(store._entries))
|
||||
|
||||
def test_archive_is_recoverable(self):
|
||||
"""Evicted entries are written to a recoverable archive file."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=2)
|
||||
for i in range(6):
|
||||
store.add(f"insight {i}")
|
||||
archive = Path(self.store_path).with_suffix(".archive.json")
|
||||
assert archive.exists()
|
||||
with open(archive) as f:
|
||||
archived = json.load(f)
|
||||
assert len(archived) == 4
|
||||
|
||||
def test_retention_days_archives_stale(self):
|
||||
"""Entries unused beyond retention_days are archived by prune()."""
|
||||
from datetime import datetime, timedelta
|
||||
store = InsightStore(store_path=self.store_path, user_id="u", retention_days=30)
|
||||
store.add("fresh")
|
||||
stale = store.add("stale")
|
||||
old = (datetime.utcnow() - timedelta(days=60)).isoformat()
|
||||
stale.last_used = old
|
||||
stale.updated_at = old
|
||||
stale.created_at = old
|
||||
store._save()
|
||||
pruned = store.prune()
|
||||
assert pruned == 1
|
||||
contents = [e.content for e in store._entries.values()]
|
||||
assert "fresh" in contents
|
||||
assert "stale" not in contents
|
||||
|
||||
def test_manager_prune_propagates_config(self):
|
||||
"""LearnManager.prune() runs across stores with config-driven limits."""
|
||||
from datetime import datetime, timedelta
|
||||
config = LearnConfig(persona=True, insights=False, thread=False, retention_days=30)
|
||||
manager = LearnManager(config=config, user_id="u", store_path=self.temp_dir)
|
||||
manager.capture_persona("fresh")
|
||||
stale = manager.capture_persona("stale")
|
||||
old = (datetime.utcnow() - timedelta(days=60)).isoformat()
|
||||
stale.last_used = old
|
||||
stale.updated_at = old
|
||||
stale.created_at = old
|
||||
manager._stores["persona"]._save()
|
||||
result = manager.prune()
|
||||
assert result["persona"] == 1
|
||||
assert manager.get_stats()["persona"] == 1
|
||||
|
||||
def test_config_to_dict_has_retention(self):
|
||||
"""LearnConfig exposes retention fields via to_dict."""
|
||||
config = LearnConfig(max_entries=100, retention_days=90)
|
||||
d = config.to_dict()
|
||||
assert d["max_entries"] == 100
|
||||
assert d["retention_days"] == 90
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Tests for agent-authored interactive replies (presentation emit + reply route).
|
||||
|
||||
Covers the core seam added so an agent can emit a portable MessagePresentation
|
||||
as a normal reply and have a button/select click routed back into the next turn.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from praisonaiagents.bots import (
|
||||
MessagePresentation,
|
||||
PresentationBlock,
|
||||
PresentationButton,
|
||||
PresentationAction,
|
||||
PresentationLimits,
|
||||
ActionType,
|
||||
BlockType,
|
||||
AgentReply,
|
||||
extract_presentation,
|
||||
adapt_presentation,
|
||||
encode_action,
|
||||
decode_callback,
|
||||
create_registry,
|
||||
make_reply_handler,
|
||||
InteractiveContext,
|
||||
REPLY_NAMESPACE,
|
||||
)
|
||||
|
||||
|
||||
def test_reply_action_factory():
|
||||
action = PresentationAction.reply("env=staging")
|
||||
assert action.type == ActionType.REPLY
|
||||
assert action.value == "env=staging"
|
||||
|
||||
|
||||
def test_block_make_helpers():
|
||||
assert PresentationBlock.make_text("hi").type == BlockType.TEXT
|
||||
assert PresentationBlock.make_context("note").type == BlockType.CONTEXT
|
||||
assert PresentationBlock.make_buttons([]).type == BlockType.BUTTONS
|
||||
|
||||
|
||||
def test_quick_replies_builds_reply_buttons():
|
||||
block = PresentationBlock.quick_replies([("Staging", "env=staging"), "Prod"])
|
||||
assert block.type == BlockType.BUTTONS
|
||||
assert block.buttons[0].action.type == ActionType.REPLY
|
||||
assert block.buttons[0].action.value == "env=staging"
|
||||
# Plain string choice uses itself as both label and value.
|
||||
assert block.buttons[1].label == "Prod"
|
||||
assert block.buttons[1].action.value == "Prod"
|
||||
|
||||
|
||||
def test_encode_reply_action():
|
||||
enc = encode_action("ignored", PresentationAction.reply("pick=a"))
|
||||
assert enc == "reply:pick=a"
|
||||
namespace, payload = decode_callback(enc)
|
||||
assert namespace == REPLY_NAMESPACE
|
||||
assert payload["value"] == "pick=a"
|
||||
|
||||
|
||||
def test_adapt_degrades_reply_to_callback():
|
||||
p = MessagePresentation([
|
||||
PresentationBlock.make_buttons([
|
||||
PresentationButton(label="Staging", action=PresentationAction.reply("env=staging")),
|
||||
])
|
||||
])
|
||||
adapted = adapt_presentation(p, PresentationLimits.telegram())
|
||||
btn = adapted.blocks[0].buttons[0]
|
||||
# Renderers only know command/callback/url/web_app; reply degrades to callback.
|
||||
assert btn.action.type == ActionType.CALLBACK
|
||||
assert btn.action.value == "reply:env=staging"
|
||||
|
||||
|
||||
def test_adapt_reply_callback_respects_length_cap():
|
||||
long_value = "x" * 200
|
||||
p = MessagePresentation([
|
||||
PresentationBlock.make_buttons([
|
||||
PresentationButton(label="L", action=PresentationAction.reply(long_value)),
|
||||
])
|
||||
])
|
||||
adapted = adapt_presentation(p, PresentationLimits.telegram())
|
||||
val = adapted.blocks[0].buttons[0].action.value
|
||||
# Cap is a byte limit; an over-cap value becomes a collision-resistant hash
|
||||
# marked with ``#`` (not a lossy prefix) so distinct choices stay distinct.
|
||||
assert len(val.encode("utf-8")) <= 64
|
||||
assert val.startswith("reply:#")
|
||||
|
||||
|
||||
def test_adapt_long_replies_do_not_collide():
|
||||
# Two distinct long values that share a prefix must NOT collapse to the same
|
||||
# callback payload (the prefix-truncation bug). Hashing keeps them distinct.
|
||||
a = "shared-prefix-" + "a" * 200
|
||||
b = "shared-prefix-" + "b" * 200
|
||||
p = MessagePresentation([
|
||||
PresentationBlock.make_buttons([
|
||||
PresentationButton(label="A", action=PresentationAction.reply(a)),
|
||||
PresentationButton(label="B", action=PresentationAction.reply(b)),
|
||||
])
|
||||
])
|
||||
adapted = adapt_presentation(p, PresentationLimits.telegram())
|
||||
va = adapted.blocks[0].buttons[0].action.value
|
||||
vb = adapted.blocks[0].buttons[1].action.value
|
||||
assert va != vb
|
||||
|
||||
|
||||
def test_adapt_reply_callback_byte_cap_with_non_ascii():
|
||||
# Non-ASCII values can stay under 64 *chars* yet exceed 64 *bytes*; the
|
||||
# cap must be enforced in UTF-8 bytes.
|
||||
value = "é" * 40 # 40 chars, 80 bytes
|
||||
p = MessagePresentation([
|
||||
PresentationBlock.make_buttons([
|
||||
PresentationButton(label="L", action=PresentationAction.reply(value)),
|
||||
])
|
||||
])
|
||||
adapted = adapt_presentation(p, PresentationLimits.telegram())
|
||||
val = adapted.blocks[0].buttons[0].action.value
|
||||
assert len(val.encode("utf-8")) <= 64
|
||||
# Over-cap -> hashed marker form, which is pure ASCII (no split codepoint).
|
||||
assert val.startswith("reply:#")
|
||||
|
||||
|
||||
def test_reply_handler_declines_hashed_value():
|
||||
# A hashed (non-routable) reply value must NOT be fed into the turn — the
|
||||
# agent never authored the digest. The handler declines instead.
|
||||
called = {"hit": False}
|
||||
|
||||
async def continue_turn(value, context):
|
||||
called["hit"] = True
|
||||
return "should not run"
|
||||
|
||||
registry = create_registry()
|
||||
registry.register(REPLY_NAMESPACE, make_reply_handler(continue_turn))
|
||||
ctx = InteractiveContext(callback_data="reply:#deadbeefdeadbeef", user_id="u1")
|
||||
handled = asyncio.run(registry.dispatch(ctx))
|
||||
assert handled is False
|
||||
assert called["hit"] is False
|
||||
|
||||
|
||||
def test_encode_empty_string_reply_preserved():
|
||||
# PresentationAction.reply("") is a valid choice; the encoder must keep the
|
||||
# reserved reply: prefix instead of falling back to the caller namespace.
|
||||
enc = encode_action("ns", PresentationAction.reply(""))
|
||||
assert enc == "reply:"
|
||||
namespace, payload = decode_callback(enc)
|
||||
assert namespace == REPLY_NAMESPACE
|
||||
assert payload["value"] == ""
|
||||
|
||||
|
||||
def test_extract_presentation_plain_str():
|
||||
text, pres = extract_presentation("hello")
|
||||
assert text == "hello"
|
||||
assert pres is None
|
||||
|
||||
|
||||
def test_extract_presentation_from_presentation():
|
||||
p = MessagePresentation([
|
||||
PresentationBlock.make_text("Which environment?"),
|
||||
PresentationBlock.quick_replies([("Staging", "staging")]),
|
||||
])
|
||||
text, pres = extract_presentation(p)
|
||||
assert pres is p
|
||||
assert "Which environment?" in text
|
||||
|
||||
|
||||
def test_extract_presentation_from_agent_reply():
|
||||
p = MessagePresentation([PresentationBlock.quick_replies([("A", "a")])])
|
||||
reply = AgentReply(text="Pick one", presentation=p)
|
||||
text, pres = extract_presentation(reply)
|
||||
assert text == "Pick one"
|
||||
assert pres is p
|
||||
|
||||
|
||||
def test_extract_presentation_from_dict():
|
||||
p = MessagePresentation([PresentationBlock.make_text("hi")])
|
||||
data = AgentReply(text="t", presentation=p).to_dict()
|
||||
text, pres = extract_presentation(data)
|
||||
assert text == "t"
|
||||
assert isinstance(pres, MessagePresentation)
|
||||
assert pres.blocks[0].text == "hi"
|
||||
|
||||
|
||||
def test_agent_reply_roundtrip():
|
||||
p = MessagePresentation([PresentationBlock.quick_replies([("A", "a")])])
|
||||
reply = AgentReply(text="hi", presentation=p)
|
||||
restored = AgentReply.from_dict(reply.to_dict())
|
||||
assert restored.text == "hi"
|
||||
assert restored.presentation.blocks[0].buttons[0].action.value == "a"
|
||||
|
||||
|
||||
def test_reply_handler_routes_value_back_into_turn():
|
||||
seen = {}
|
||||
|
||||
async def continue_turn(value, context):
|
||||
seen["value"] = value
|
||||
seen["user"] = context.user_id
|
||||
return f"ran with {value}"
|
||||
|
||||
registry = create_registry()
|
||||
registry.register(REPLY_NAMESPACE, make_reply_handler(continue_turn))
|
||||
|
||||
ctx = InteractiveContext(callback_data="reply:env=prod", user_id="u1")
|
||||
handled = asyncio.run(registry.dispatch(ctx))
|
||||
|
||||
assert handled is True
|
||||
assert seen["value"] == "env=prod"
|
||||
assert seen["user"] == "u1"
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Tests for code-execution-with-tools (code mode) bridge.
|
||||
|
||||
Verifies that model-generated code can call registered tools through the
|
||||
ToolProxy bridge, honouring the allow-list and the approval framework, and
|
||||
that disallowed/unregistered tools are rejected.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.tools.registry import ToolRegistry
|
||||
from praisonaiagents.tools.tool_proxy import ToolProxy, build_tool_namespace
|
||||
from praisonaiagents.tools.python_tools import execute_code_with_tools
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry():
|
||||
reg = ToolRegistry()
|
||||
|
||||
def fetch(url):
|
||||
return {"a": 1, "b": 2, "c": 3}[url]
|
||||
|
||||
def double(x):
|
||||
return x * 2
|
||||
|
||||
reg.register(fetch, name="fetch")
|
||||
reg.register(double, name="double")
|
||||
return reg
|
||||
|
||||
|
||||
def test_proxy_calls_allowed_tool(registry):
|
||||
proxy = ToolProxy(["fetch"], registry=registry)
|
||||
assert proxy.fetch(url="a") == 1
|
||||
|
||||
|
||||
def test_proxy_rejects_disallowed_tool(registry):
|
||||
proxy = ToolProxy(["fetch"], registry=registry)
|
||||
with pytest.raises(PermissionError):
|
||||
proxy.double(x=2)
|
||||
|
||||
|
||||
def test_proxy_rejects_unregistered_tool(registry):
|
||||
proxy = ToolProxy(["ghost"], registry=registry)
|
||||
with pytest.raises(NameError):
|
||||
proxy.ghost()
|
||||
|
||||
|
||||
def test_proxy_is_read_only(registry):
|
||||
proxy = ToolProxy(["fetch"], registry=registry)
|
||||
with pytest.raises(AttributeError):
|
||||
proxy.fetch = lambda **kw: None
|
||||
|
||||
|
||||
def test_build_tool_namespace_skips_unregistered(registry):
|
||||
ns = build_tool_namespace(["fetch", "ghost"], registry=registry)
|
||||
assert "fetch" in ns
|
||||
assert "ghost" not in ns
|
||||
|
||||
|
||||
def test_multi_step_pipeline_in_one_call(registry):
|
||||
code = (
|
||||
"vals = [fetch(u) for u in ['a', 'b', 'c']]\n"
|
||||
"best = max(double(v) for v in vals)\n"
|
||||
"best\n"
|
||||
)
|
||||
result = execute_code_with_tools(
|
||||
code, allowed_tools=["fetch", "double"], registry=registry
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["result"] == 6
|
||||
|
||||
|
||||
def test_positional_and_keyword_args(registry):
|
||||
code = "fetch('a')\n"
|
||||
result = execute_code_with_tools(
|
||||
code, allowed_tools=["fetch"], registry=registry
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["result"] == 1
|
||||
|
||||
|
||||
def test_tools_namespace_form(registry):
|
||||
code = "tools.fetch(url='b')\n"
|
||||
result = execute_code_with_tools(
|
||||
code, allowed_tools=["fetch"], registry=registry
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["result"] == 2
|
||||
|
||||
|
||||
def test_disallowed_tool_from_code_fails(registry):
|
||||
code = "print(double(x=1))\n"
|
||||
result = execute_code_with_tools(
|
||||
code, allowed_tools=["fetch"], registry=registry
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
def test_no_tools_when_empty_allowlist(registry):
|
||||
code = "print(fetch(url='a'))\n"
|
||||
result = execute_code_with_tools(code, allowed_tools=[], registry=registry)
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
def test_imports_still_blocked(registry):
|
||||
code = "import os\nprint(os.getcwd())\n"
|
||||
result = execute_code_with_tools(
|
||||
code, allowed_tools=["fetch"], registry=registry
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
def test_approval_gate_denies(registry):
|
||||
from praisonaiagents.approval import (
|
||||
add_approval_requirement,
|
||||
remove_approval_requirement,
|
||||
set_approval_callback,
|
||||
ApprovalDecision,
|
||||
)
|
||||
|
||||
add_approval_requirement("fetch", "high")
|
||||
set_approval_callback(
|
||||
lambda function_name, arguments, risk_level: ApprovalDecision(
|
||||
approved=False, reason="denied by test"
|
||||
)
|
||||
)
|
||||
try:
|
||||
proxy = ToolProxy(["fetch"], registry=registry)
|
||||
with pytest.raises(PermissionError):
|
||||
proxy.fetch(url="a")
|
||||
finally:
|
||||
set_approval_callback(None)
|
||||
remove_approval_requirement("fetch")
|
||||
|
||||
|
||||
def test_registry_not_exposed_via_attribute(registry):
|
||||
proxy = ToolProxy(["fetch"], registry=registry)
|
||||
with pytest.raises(AttributeError):
|
||||
_ = proxy._registry
|
||||
with pytest.raises(AttributeError):
|
||||
_ = proxy._allowed
|
||||
|
||||
|
||||
def test_registry_bypass_blocked_from_code(registry):
|
||||
code = "r = tools._registry\nr.get('double')()\n"
|
||||
result = execute_code_with_tools(
|
||||
code, allowed_tools=["fetch"], registry=registry
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
def test_reserved_tools_name_rejected(registry):
|
||||
with pytest.raises(ValueError):
|
||||
execute_code_with_tools(
|
||||
"1\n", allowed_tools=["tools"], registry=registry
|
||||
)
|
||||
|
||||
|
||||
def test_positional_args_visible_to_approval(registry):
|
||||
from praisonaiagents.approval import (
|
||||
add_approval_requirement,
|
||||
remove_approval_requirement,
|
||||
set_approval_callback,
|
||||
ApprovalDecision,
|
||||
)
|
||||
|
||||
seen = {}
|
||||
|
||||
def _cb(function_name, arguments, risk_level):
|
||||
seen["args"] = dict(arguments)
|
||||
return ApprovalDecision(approved=True, reason="ok")
|
||||
|
||||
add_approval_requirement("fetch", "high")
|
||||
set_approval_callback(_cb)
|
||||
try:
|
||||
proxy = ToolProxy(["fetch"], registry=registry)
|
||||
assert proxy.fetch("a") == 1
|
||||
assert seen["args"].get("url") == "a"
|
||||
finally:
|
||||
set_approval_callback(None)
|
||||
remove_approval_requirement("fetch")
|
||||
|
||||
|
||||
def test_init_reinitialization_bypass_blocked(registry):
|
||||
# Sandboxed code must not be able to call tools.__init__([...]) to re-bind
|
||||
# the proxy's allow-list/registry and reach a disallowed tool.
|
||||
code = (
|
||||
"tools.__init__(['double'])\n"
|
||||
"tools.double(x=2)\n"
|
||||
)
|
||||
result = execute_code_with_tools(
|
||||
code, allowed_tools=["fetch"], registry=registry
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
def test_init_not_exposed_on_proxy(registry):
|
||||
proxy = ToolProxy(["fetch"], registry=registry)
|
||||
# __init__ access is treated as a tool-name lookup and rejected, not the
|
||||
# bound initializer.
|
||||
with pytest.raises((AttributeError, PermissionError, NameError)):
|
||||
proxy.__init__(["double"])
|
||||
|
||||
|
||||
def test_approval_gate_required_for_every_call(registry):
|
||||
# In code mode a first approval must NOT silently unlock later calls to the
|
||||
# same tool. The approval callback must fire for every invocation.
|
||||
from praisonaiagents.approval import (
|
||||
add_approval_requirement,
|
||||
remove_approval_requirement,
|
||||
set_approval_callback,
|
||||
clear_approval_context,
|
||||
ApprovalDecision,
|
||||
)
|
||||
|
||||
calls = {"count": 0}
|
||||
|
||||
def _cb(function_name, arguments, risk_level):
|
||||
calls["count"] += 1
|
||||
return ApprovalDecision(approved=True, reason="ok")
|
||||
|
||||
add_approval_requirement("fetch", "high")
|
||||
set_approval_callback(_cb)
|
||||
try:
|
||||
proxy = ToolProxy(["fetch"], registry=registry)
|
||||
proxy.fetch(url="a")
|
||||
proxy.fetch(url="b")
|
||||
assert calls["count"] == 2
|
||||
finally:
|
||||
set_approval_callback(None)
|
||||
remove_approval_requirement("fetch")
|
||||
clear_approval_context()
|
||||
|
||||
|
||||
def test_execution_config_flags():
|
||||
from praisonaiagents.config.feature_configs import ExecutionConfig
|
||||
|
||||
cfg = ExecutionConfig(code_tools=True, code_tools_allow=["fetch"])
|
||||
assert cfg.code_tools is True
|
||||
assert cfg.code_tools_allow == ["fetch"]
|
||||
d = cfg.to_dict()
|
||||
assert d["code_tools"] is True
|
||||
assert d["code_tools_allow"] == ["fetch"]
|
||||
restored = ExecutionConfig.from_dict(d)
|
||||
assert restored.code_tools is True
|
||||
assert restored.code_tools_allow == ["fetch"]
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Unit tests for gateway idle-dormancy / scale-to-zero (Issue #2332).
|
||||
|
||||
Covers the pure, core-side decision predicates of ScaleToZeroPolicy and
|
||||
conformance with the GatewayIdlePolicy protocol.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.gateway import (
|
||||
GatewayIdlePolicy,
|
||||
GatewayIdlePolicyProtocol,
|
||||
IdleDecision,
|
||||
ScaleToZeroPolicy,
|
||||
)
|
||||
|
||||
|
||||
def test_protocol_conformance():
|
||||
policy = ScaleToZeroPolicy(idle_timeout_minutes=5)
|
||||
assert isinstance(policy, GatewayIdlePolicyProtocol)
|
||||
|
||||
|
||||
def test_backward_compat_alias():
|
||||
# The old name remains importable and points at the canonical protocol.
|
||||
assert GatewayIdlePolicy is GatewayIdlePolicyProtocol
|
||||
assert isinstance(ScaleToZeroPolicy(idle_timeout_minutes=5), GatewayIdlePolicy)
|
||||
|
||||
|
||||
def test_invalid_timeout_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ScaleToZeroPolicy(idle_timeout_minutes=0)
|
||||
with pytest.raises(ValueError):
|
||||
ScaleToZeroPolicy(idle_timeout_minutes=-1)
|
||||
|
||||
|
||||
def test_idle_timeout_seconds():
|
||||
assert ScaleToZeroPolicy(idle_timeout_minutes=5).idle_timeout_seconds == 300.0
|
||||
|
||||
|
||||
def test_idle_when_quiescent_past_timeout():
|
||||
policy = ScaleToZeroPolicy(idle_timeout_minutes=5)
|
||||
decision = policy.is_idle(
|
||||
running_turns=0,
|
||||
last_inbound_ts=0.0,
|
||||
has_background_work=False,
|
||||
now=301.0,
|
||||
)
|
||||
assert isinstance(decision, IdleDecision)
|
||||
assert decision.idle is True
|
||||
|
||||
|
||||
def test_not_idle_before_timeout():
|
||||
policy = ScaleToZeroPolicy(idle_timeout_minutes=5)
|
||||
decision = policy.is_idle(
|
||||
running_turns=0,
|
||||
last_inbound_ts=0.0,
|
||||
has_background_work=False,
|
||||
now=120.0,
|
||||
)
|
||||
assert decision.idle is False
|
||||
assert "to idle" in decision.reason
|
||||
|
||||
|
||||
def test_in_flight_turn_blocks_dormancy():
|
||||
policy = ScaleToZeroPolicy(idle_timeout_minutes=5)
|
||||
decision = policy.is_idle(
|
||||
running_turns=1,
|
||||
last_inbound_ts=0.0,
|
||||
has_background_work=False,
|
||||
now=10_000.0,
|
||||
)
|
||||
assert decision.idle is False
|
||||
assert "in flight" in decision.reason
|
||||
|
||||
|
||||
def test_background_work_blocks_dormancy():
|
||||
policy = ScaleToZeroPolicy(idle_timeout_minutes=5)
|
||||
decision = policy.is_idle(
|
||||
running_turns=0,
|
||||
last_inbound_ts=0.0,
|
||||
has_background_work=True,
|
||||
now=10_000.0,
|
||||
)
|
||||
assert decision.idle is False
|
||||
assert "background" in decision.reason
|
||||
|
||||
|
||||
def test_disabled_policy_never_idle():
|
||||
policy = ScaleToZeroPolicy(idle_timeout_minutes=5, enabled=False)
|
||||
decision = policy.is_idle(
|
||||
running_turns=0,
|
||||
last_inbound_ts=0.0,
|
||||
has_background_work=False,
|
||||
now=10_000.0,
|
||||
)
|
||||
assert decision.idle is False
|
||||
|
||||
|
||||
def test_should_arm_requires_wake_and_quiescable():
|
||||
policy = ScaleToZeroPolicy(idle_timeout_minutes=5, wake_url="https://x/_wake")
|
||||
assert policy.should_arm(transports_quiescable=True, wake_registered=True) is True
|
||||
assert policy.should_arm(transports_quiescable=False, wake_registered=True) is False
|
||||
assert policy.should_arm(transports_quiescable=True, wake_registered=False) is False
|
||||
|
||||
|
||||
def test_disabled_policy_never_arms():
|
||||
policy = ScaleToZeroPolicy(idle_timeout_minutes=5, enabled=False)
|
||||
assert policy.should_arm(transports_quiescable=True, wake_registered=True) is False
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for actor-authorization enforcement in the interactive registry."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.bots.interactive import (
|
||||
InteractiveContext,
|
||||
InteractiveRegistry,
|
||||
)
|
||||
|
||||
|
||||
def _ctx(user_id: str, callback_data: str = "approval:1a2b3c:allow") -> InteractiveContext:
|
||||
return InteractiveContext(callback_data=callback_data, user_id=user_id)
|
||||
|
||||
|
||||
def test_dispatch_without_authorizer_allows_any_actor():
|
||||
registry = InteractiveRegistry()
|
||||
seen = []
|
||||
|
||||
async def handler(ctx):
|
||||
seen.append(ctx.user_id)
|
||||
return "ok"
|
||||
|
||||
registry.register("approval", handler)
|
||||
handled = asyncio.run(registry.dispatch(_ctx("999")))
|
||||
|
||||
assert handled is True
|
||||
assert seen == ["999"]
|
||||
|
||||
|
||||
def test_dispatch_rejects_unauthorized_actor():
|
||||
registry = InteractiveRegistry()
|
||||
allowed = {"requester", "admin"}
|
||||
seen = []
|
||||
|
||||
async def handler(ctx):
|
||||
seen.append(ctx.user_id)
|
||||
return "ok"
|
||||
|
||||
registry.register(
|
||||
"approval", handler, authorize=lambda ctx: ctx.user_id in allowed
|
||||
)
|
||||
|
||||
handled = asyncio.run(registry.dispatch(_ctx("999")))
|
||||
|
||||
assert handled is False
|
||||
assert seen == [] # handler must never run for unauthorized actor
|
||||
|
||||
|
||||
def test_dispatch_allows_authorized_actor():
|
||||
registry = InteractiveRegistry()
|
||||
allowed = {"requester", "admin"}
|
||||
seen = []
|
||||
|
||||
async def handler(ctx):
|
||||
seen.append(ctx.user_id)
|
||||
return "ok"
|
||||
|
||||
registry.register(
|
||||
"approval", handler, authorize=lambda ctx: ctx.user_id in allowed
|
||||
)
|
||||
|
||||
handled = asyncio.run(registry.dispatch(_ctx("admin")))
|
||||
|
||||
assert handled is True
|
||||
assert seen == ["admin"]
|
||||
|
||||
|
||||
def test_authorizer_exception_denies():
|
||||
registry = InteractiveRegistry()
|
||||
seen = []
|
||||
|
||||
async def handler(ctx):
|
||||
seen.append(ctx.user_id)
|
||||
return "ok"
|
||||
|
||||
def boom(ctx):
|
||||
raise RuntimeError("authorizer failure")
|
||||
|
||||
registry.register("approval", handler, authorize=boom)
|
||||
|
||||
handled = asyncio.run(registry.dispatch(_ctx("admin")))
|
||||
|
||||
assert handled is False
|
||||
assert seen == []
|
||||
|
||||
|
||||
def test_unregister_clears_authorizer():
|
||||
registry = InteractiveRegistry()
|
||||
|
||||
async def handler(ctx):
|
||||
return "ok"
|
||||
|
||||
registry.register("approval", handler, authorize=lambda ctx: False)
|
||||
registry.unregister("approval")
|
||||
# Re-register with no authorizer -> should allow again
|
||||
registry.register("approval", handler)
|
||||
|
||||
handled = asyncio.run(registry.dispatch(_ctx("999")))
|
||||
assert handled is True
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Unit tests for per-route, trust-tiered toolset scoping (Issue #2298).
|
||||
|
||||
Covers the core ``ToolPolicy`` filter contract and ``RouteBinding`` declaring a
|
||||
policy via ``trust`` / ``allow_tools`` / ``deny_tools`` (plus YAML ``from_dict``
|
||||
parsing), in praisonaiagents.gateway.protocols.
|
||||
"""
|
||||
|
||||
from praisonaiagents.gateway import (
|
||||
RouteBinding,
|
||||
ToolPolicy,
|
||||
UNTRUSTED_DENY_SUBSTRINGS,
|
||||
TRUST_TIERS,
|
||||
)
|
||||
|
||||
|
||||
def _named(name):
|
||||
"""A simple callable tool with a resolvable name."""
|
||||
def _tool(): # pragma: no cover - never invoked
|
||||
return None
|
||||
_tool.__name__ = name
|
||||
return _tool
|
||||
|
||||
|
||||
class TestToolPolicyFilter:
|
||||
def test_noop_policy_keeps_everything(self):
|
||||
p = ToolPolicy()
|
||||
assert p.is_noop
|
||||
tools = [_named("shell"), _named("read_file")]
|
||||
assert p.filter_tools(tools) == tools
|
||||
|
||||
def test_deny_tools_exact(self):
|
||||
p = ToolPolicy(deny_tools={"shell"})
|
||||
kept = p.filter_tools([_named("shell"), _named("read_file")])
|
||||
assert [t.__name__ for t in kept] == ["read_file"]
|
||||
|
||||
def test_deny_substrings_case_insensitive(self):
|
||||
p = ToolPolicy(deny_substrings=["shell"])
|
||||
kept = p.filter_tools([_named("RunShellCommand"), _named("search")])
|
||||
assert [t.__name__ for t in kept] == ["search"]
|
||||
|
||||
def test_allow_tools_is_a_whitelist(self):
|
||||
p = ToolPolicy(allow_tools={"search"})
|
||||
kept = p.filter_tools(
|
||||
[_named("search"), _named("write_file"), _named("delete_file")]
|
||||
)
|
||||
assert [t.__name__ for t in kept] == ["search"]
|
||||
|
||||
def test_empty_input_returns_empty(self):
|
||||
assert ToolPolicy(deny_tools={"x"}).filter_tools(None) == []
|
||||
assert ToolPolicy(deny_tools={"x"}).filter_tools([]) == []
|
||||
|
||||
def test_dict_style_tool_name_resolution(self):
|
||||
p = ToolPolicy(deny_tools={"shell"})
|
||||
tool = {"type": "function", "function": {"name": "shell"}}
|
||||
keep = {"type": "function", "function": {"name": "search"}}
|
||||
kept = p.filter_tools([tool, keep])
|
||||
assert kept == [keep]
|
||||
|
||||
def test_top_level_name_dict_is_resolved(self):
|
||||
# Plain schema dicts (no OpenAI ``function`` wrapper) must still resolve
|
||||
# by name so exact denies / untrusted substrings remove them.
|
||||
p = ToolPolicy(deny_tools={"shell"})
|
||||
tool = {"name": "shell", "description": "run a command"}
|
||||
keep = {"name": "search"}
|
||||
assert p.filter_tools([tool, keep]) == [keep]
|
||||
|
||||
def test_top_level_name_dict_untrusted_substring(self):
|
||||
p = ToolPolicy(deny_substrings=["shell"])
|
||||
tool = {"name": "RunShellCommand"}
|
||||
keep = {"name": "search"}
|
||||
assert p.filter_tools([tool, keep]) == [keep]
|
||||
|
||||
|
||||
class TestRouteBindingToolPolicy:
|
||||
def test_no_constraints_returns_none(self):
|
||||
assert RouteBinding(agent="a").tool_policy() is None
|
||||
|
||||
def test_trusted_tier_is_noop(self):
|
||||
assert RouteBinding(agent="a", trust="trusted").tool_policy() is None
|
||||
|
||||
def test_standard_tier_is_noop(self):
|
||||
assert RouteBinding(agent="a", trust="standard").tool_policy() is None
|
||||
|
||||
def test_untrusted_tier_denies_dangerous_families(self):
|
||||
pol = RouteBinding(agent="a", trust="untrusted").tool_policy()
|
||||
assert pol is not None
|
||||
kept = pol.filter_tools(
|
||||
[
|
||||
_named("run_shell"),
|
||||
_named("write_file"),
|
||||
_named("delete_file"),
|
||||
_named("delegate_task"),
|
||||
_named("schedule_cronjob"),
|
||||
_named("web_search"),
|
||||
_named("read_file"),
|
||||
]
|
||||
)
|
||||
names = [t.__name__ for t in kept]
|
||||
assert "web_search" in names
|
||||
assert "read_file" in names
|
||||
assert "run_shell" not in names
|
||||
assert "write_file" not in names
|
||||
assert "delete_file" not in names
|
||||
assert "delegate_task" not in names
|
||||
assert "schedule_cronjob" not in names
|
||||
|
||||
def test_explicit_allow_tools(self):
|
||||
pol = RouteBinding(agent="a", allow_tools=["search"]).tool_policy()
|
||||
assert pol is not None
|
||||
kept = pol.filter_tools([_named("search"), _named("shell")])
|
||||
assert [t.__name__ for t in kept] == ["search"]
|
||||
|
||||
def test_explicit_deny_tools(self):
|
||||
pol = RouteBinding(agent="a", deny_tools=["shell"]).tool_policy()
|
||||
assert pol is not None
|
||||
kept = pol.filter_tools([_named("search"), _named("shell")])
|
||||
assert [t.__name__ for t in kept] == ["search"]
|
||||
|
||||
def test_untrusted_plus_explicit_deny_layer(self):
|
||||
pol = RouteBinding(
|
||||
agent="a", trust="untrusted", deny_tools=["web_search"]
|
||||
).tool_policy()
|
||||
kept = pol.filter_tools(
|
||||
[_named("web_search"), _named("run_shell"), _named("read_file")]
|
||||
)
|
||||
assert [t.__name__ for t in kept] == ["read_file"]
|
||||
|
||||
|
||||
class TestRouteBindingTrustNormalization:
|
||||
def test_case_and_whitespace_variants_canonicalise(self):
|
||||
assert RouteBinding(agent="a", trust=" Untrusted ").trust == "untrusted"
|
||||
assert RouteBinding(agent="a", trust="TRUSTED").trust == "trusted"
|
||||
|
||||
def test_unknown_trust_fails_closed_to_untrusted(self):
|
||||
# A typo must NOT silently expose the full toolset — it down-tiers to
|
||||
# the most restrictive policy instead.
|
||||
b = RouteBinding(agent="a", trust="untrustd")
|
||||
assert b.trust == "untrusted"
|
||||
pol = b.tool_policy()
|
||||
assert pol is not None
|
||||
kept = pol.filter_tools([_named("run_shell"), _named("read_file")])
|
||||
assert [t.__name__ for t in kept] == ["read_file"]
|
||||
|
||||
def test_empty_trust_string_is_none(self):
|
||||
assert RouteBinding(agent="a", trust=" ").trust is None
|
||||
assert RouteBinding(agent="a", trust=" ").tool_policy() is None
|
||||
|
||||
def test_from_dict_unknown_trust_fails_closed(self):
|
||||
b = RouteBinding.from_dict({"agent": "a", "trust": "Untrusted!"})
|
||||
assert b.trust == "untrusted"
|
||||
assert b.tool_policy() is not None
|
||||
|
||||
|
||||
class TestRouteBindingFromDict:
|
||||
def test_from_dict_parses_trust_and_lists(self):
|
||||
b = RouteBinding.from_dict(
|
||||
{
|
||||
"agent": "assistant",
|
||||
"chat_type": "dm",
|
||||
"trust": "untrusted",
|
||||
"allow_tools": ["search"],
|
||||
"deny_tools": ["shell", "delete_file"],
|
||||
}
|
||||
)
|
||||
assert b.trust == "untrusted"
|
||||
assert b.allow_tools == ["search"]
|
||||
assert b.deny_tools == ["shell", "delete_file"]
|
||||
|
||||
def test_from_dict_scalar_tool_is_wrapped(self):
|
||||
b = RouteBinding.from_dict({"agent": "a", "deny_tools": "shell"})
|
||||
assert b.deny_tools == ["shell"]
|
||||
|
||||
def test_from_dict_absent_keys_stay_none(self):
|
||||
b = RouteBinding.from_dict({"agent": "a", "chat_type": "dm"})
|
||||
assert b.trust is None
|
||||
assert b.allow_tools is None
|
||||
assert b.deny_tools is None
|
||||
assert b.tool_policy() is None
|
||||
|
||||
def test_from_dict_backward_compatible(self):
|
||||
b = RouteBinding.from_dict({"agent": "a", "peer": "123", "priority": 5})
|
||||
assert b.agent == "a"
|
||||
assert b.peer == "123"
|
||||
assert b.priority == 5
|
||||
|
||||
|
||||
class TestModuleConstants:
|
||||
def test_trust_tiers_order(self):
|
||||
assert TRUST_TIERS == ["untrusted", "standard", "trusted"]
|
||||
|
||||
def test_untrusted_deny_substrings_nonempty(self):
|
||||
assert "shell" in UNTRUSTED_DENY_SUBSTRINGS
|
||||
assert len(UNTRUSTED_DENY_SUBSTRINGS) > 0
|
||||
@@ -18,6 +18,112 @@ def test_webhook_insecure_override(monkeypatch):
|
||||
assert webhooks_require_verification() is False
|
||||
|
||||
|
||||
def test_verify_hmac_valid_and_invalid():
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from praisonai.bots.webhook_security import verify_hmac
|
||||
|
||||
secret = "s3cret"
|
||||
body = b"payload"
|
||||
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
|
||||
assert verify_hmac(secret, body, sig) is True
|
||||
assert verify_hmac(secret, body, "deadbeef") is False
|
||||
assert verify_hmac(secret, body, None) is False
|
||||
assert verify_hmac("", body, sig) is False
|
||||
|
||||
|
||||
def test_verify_hmac_prefix_and_algo():
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from praisonai.bots.webhook_security import verify_hmac
|
||||
|
||||
secret = "s3cret"
|
||||
body = b"payload"
|
||||
digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
|
||||
assert verify_hmac(secret, body, f"sha256={digest}", prefix="sha256=") is True
|
||||
assert verify_hmac(secret, body, f"sha256={digest}") is True
|
||||
|
||||
|
||||
def test_verify_hmac_unknown_digest_fails_closed():
|
||||
from praisonai.bots.webhook_security import verify_hmac
|
||||
|
||||
assert verify_hmac("s3cret", b"payload", "deadbeef", digest="not-a-real-algo") is False
|
||||
|
||||
|
||||
def test_hmac_webhook_verifier_case_insensitive_header():
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from praisonai.bots.webhook_security import HmacWebhookVerifier
|
||||
|
||||
secret = "s3cret"
|
||||
body = b"payload"
|
||||
sig = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
|
||||
verifier = HmacWebhookVerifier(
|
||||
secret, signature_headers=["X-Hub-Signature-256"], prefix="sha256="
|
||||
)
|
||||
assert verifier.verify(
|
||||
headers={"x-hub-signature-256": sig}, raw_body=body
|
||||
) is True
|
||||
assert verifier.verify(headers={"x-hub-signature-256": "bad"}, raw_body=body) is False
|
||||
|
||||
|
||||
def test_enforce_webhook_verification_gate(monkeypatch):
|
||||
from praisonai.bots.webhook_security import (
|
||||
HmacWebhookVerifier,
|
||||
enforce_webhook_verification,
|
||||
)
|
||||
|
||||
monkeypatch.delenv("PRAISONAI_INSECURE_WEBHOOKS", raising=False)
|
||||
|
||||
assert enforce_webhook_verification(
|
||||
accepts_webhooks=False, verifier=None, headers={}, raw_body=b""
|
||||
) is True
|
||||
|
||||
assert enforce_webhook_verification(
|
||||
accepts_webhooks=True, verifier=None, headers={}, raw_body=b""
|
||||
) is False
|
||||
|
||||
monkeypatch.setenv("PRAISONAI_INSECURE_WEBHOOKS", "true")
|
||||
assert enforce_webhook_verification(
|
||||
accepts_webhooks=True, verifier=None, headers={}, raw_body=b""
|
||||
) is True
|
||||
monkeypatch.delenv("PRAISONAI_INSECURE_WEBHOOKS", raising=False)
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
secret = "s3cret"
|
||||
body = b"payload"
|
||||
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
verifier = HmacWebhookVerifier(secret, signature_headers=["X-Signature"])
|
||||
assert enforce_webhook_verification(
|
||||
accepts_webhooks=True,
|
||||
verifier=verifier,
|
||||
headers={"X-Signature": sig},
|
||||
raw_body=body,
|
||||
) is True
|
||||
|
||||
|
||||
def test_webhook_verifier_protocol_and_capabilities_roundtrip():
|
||||
from praisonaiagents.bots import (
|
||||
PlatformCapabilities,
|
||||
WebhookVerifierProtocol,
|
||||
)
|
||||
|
||||
assert WebhookVerifierProtocol is not None
|
||||
|
||||
caps = PlatformCapabilities(accepts_webhooks=True, verifies_webhook_signature=True)
|
||||
restored = PlatformCapabilities.from_dict(caps.to_dict())
|
||||
assert restored.accepts_webhooks is True
|
||||
assert restored.verifies_webhook_signature is True
|
||||
|
||||
|
||||
def test_jobs_agent_root_rejects_escape(tmp_path, monkeypatch):
|
||||
from praisonai.jobs.path_validation import validate_agent_file_path
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -31,3 +35,90 @@ def test_workflow_yaml_approve_gated_without_allow_dangerous():
|
||||
|
||||
after = set(registry._yaml_approved_tools.get() or ())
|
||||
assert after == before
|
||||
|
||||
|
||||
def test_search_tools_read_file_blocks_traversal(tmp_path):
|
||||
"""GHSA-4xxv: read_file must stay within workspace."""
|
||||
from praisonaiagents.context.fast.search_tools import read_file
|
||||
|
||||
outside = tmp_path / "secret.txt"
|
||||
outside.write_text("secret")
|
||||
ws = tmp_path / "ws"
|
||||
ws.mkdir()
|
||||
result = read_file(str(outside), workspace_root=str(ws))
|
||||
assert result["success"] is False
|
||||
assert "outside workspace" in result["error"].lower()
|
||||
|
||||
|
||||
def test_memory_output_file_contained(tmp_path, monkeypatch):
|
||||
"""GHSA-qjw5: auto-save output must stay in project root."""
|
||||
from praisonaiagents.agent.memory_mixin import MemoryMixin
|
||||
|
||||
class _Agent(MemoryMixin):
|
||||
def __init__(self):
|
||||
self._output_file = str(tmp_path.parent / "outside.txt")
|
||||
self.name = "t"
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("PRAISONAI_PROJECT_ROOT", raising=False)
|
||||
agent = _Agent()
|
||||
assert agent._save_output_to_file("data") is False
|
||||
|
||||
|
||||
def test_skill_tools_blocks_outside_script(tmp_path, monkeypatch):
|
||||
"""GHSA-c44f: skill script path must stay in working directory."""
|
||||
from praisonaiagents.tools.skill_tools import SkillTools
|
||||
|
||||
monkeypatch.setenv("PRAISONAI_AUTO_APPROVE", "true")
|
||||
outside = tmp_path / "evil.py"
|
||||
outside.write_text("print('x')")
|
||||
tools = SkillTools(working_directory=str(tmp_path / "ws"))
|
||||
(tmp_path / "ws").mkdir()
|
||||
result = tools.run_skill_script(str(outside))
|
||||
assert "outside working directory" in result.lower()
|
||||
|
||||
|
||||
def test_web_crawl_blocks_private_ip():
|
||||
"""GHSA-qg25: web_crawl SSRF blocks private targets."""
|
||||
from praisonaiagents.tools.web_crawl_tools import web_crawl
|
||||
|
||||
result = web_crawl("http://169.254.169.254/latest/meta-data/")
|
||||
assert "error" in result
|
||||
|
||||
|
||||
def test_approval_cache_varies_by_arguments():
|
||||
"""GHSA-29r9: approval cache must include tool arguments."""
|
||||
from praisonaiagents.approval.registry import ApprovalRegistry
|
||||
|
||||
registry = ApprovalRegistry()
|
||||
registry.mark_approved("run", {"cmd": "ls"})
|
||||
assert registry.is_already_approved("run", {"cmd": "ls"})
|
||||
assert not registry.is_already_approved("run", {"cmd": "rm -rf /"})
|
||||
|
||||
|
||||
def test_workflow_tools_py_gated(monkeypatch, tmp_path):
|
||||
"""GHSA-4gfv: tools.py loading requires PRAISONAI_ALLOW_LOCAL_TOOLS."""
|
||||
from praisonaiagents.workflows.workflows import Workflow
|
||||
|
||||
wf_dir = tmp_path / "wf"
|
||||
wf_dir.mkdir()
|
||||
(wf_dir / "tools.py").write_text(
|
||||
"from pydantic import BaseModel\nclass Out(BaseModel):\n x: int\n"
|
||||
)
|
||||
wf = Workflow(steps=[], file_path=str(wf_dir / "flow.yaml"))
|
||||
monkeypatch.delenv("PRAISONAI_ALLOW_LOCAL_TOOLS", raising=False)
|
||||
assert wf._resolve_pydantic_class("Out") is None
|
||||
|
||||
monkeypatch.setenv("PRAISONAI_ALLOW_LOCAL_TOOLS", "true")
|
||||
cls = wf._resolve_pydantic_class("Out")
|
||||
assert cls is not None
|
||||
|
||||
|
||||
def test_plugin_discovery_opt_in(monkeypatch):
|
||||
"""GHSA-m6wp: plugin auto-discovery is opt-in."""
|
||||
from praisonaiagents.plugins.manager import PluginManager
|
||||
|
||||
monkeypatch.delenv("PRAISONAI_ALLOW_PLUGIN_DISCOVERY", raising=False)
|
||||
manager = PluginManager()
|
||||
assert manager.auto_discover_plugins() == 0
|
||||
|
||||
@@ -171,3 +171,84 @@ class TestBudgetNoneOverhead:
|
||||
)
|
||||
# 0.0 is falsy → budget check is skipped (intentional)
|
||||
assert not agent._max_budget
|
||||
|
||||
|
||||
class TestPreCallEstimate:
|
||||
"""Test _estimate_min_call_cost helper used by the pre-call guard."""
|
||||
|
||||
def test_estimate_zero_for_empty_messages(self):
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test")
|
||||
assert agent._estimate_min_call_cost([], None) == 0.0
|
||||
|
||||
def test_estimate_scales_with_input(self):
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test", llm="gpt-4o")
|
||||
small = agent._estimate_min_call_cost(
|
||||
[{"role": "user", "content": "hi"}], None
|
||||
)
|
||||
large = agent._estimate_min_call_cost(
|
||||
[{"role": "user", "content": "x" * 100_000}], None
|
||||
)
|
||||
assert large > small
|
||||
|
||||
def test_estimate_includes_output_reservation(self):
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test", llm="gpt-4o")
|
||||
msgs = [{"role": "user", "content": "hello world"}]
|
||||
# A larger explicit max_tokens reservation costs more than a smaller one.
|
||||
small_out = agent._estimate_min_call_cost(msgs, 100)
|
||||
large_out = agent._estimate_min_call_cost(msgs, 5000)
|
||||
assert large_out > small_out
|
||||
|
||||
def test_estimate_reserves_default_output_when_max_tokens_unset(self):
|
||||
"""Unset max_tokens must still reserve a non-zero output cost so a small
|
||||
prompt with a large provider-default response cannot bypass the guard."""
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test", llm="gpt-4o")
|
||||
msgs = [{"role": "user", "content": "hi"}]
|
||||
# Input alone (2 chars -> 0 tokens) would otherwise be ~free; the default
|
||||
# output reservation guarantees a positive estimate.
|
||||
assert agent._estimate_min_call_cost(msgs, None) > 0.0
|
||||
|
||||
def test_estimate_handles_list_content(self):
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test", llm="gpt-4o")
|
||||
msgs = [{"role": "user", "content": [{"type": "text", "text": "x" * 8000}]}]
|
||||
assert agent._estimate_min_call_cost(msgs, None) > 0.0
|
||||
|
||||
|
||||
class TestPreCallGuard:
|
||||
"""Test that the pre-call guard blocks an over-budget call before dispatch."""
|
||||
|
||||
def test_guard_blocks_before_dispatch(self):
|
||||
"""A huge input that breaches the cap raises before the LLM is called."""
|
||||
from praisonaiagents import Agent, ExecutionConfig, BudgetExceededError
|
||||
agent = Agent(
|
||||
name="test", instructions="test", llm="gpt-4o",
|
||||
execution=ExecutionConfig(max_budget=0.0001),
|
||||
)
|
||||
# Make the retry path explode if it is ever reached.
|
||||
agent._chat_completion_with_retry = MagicMock(
|
||||
side_effect=AssertionError("LLM dispatched despite budget guard")
|
||||
)
|
||||
messages = [{"role": "user", "content": "x" * 200_000}]
|
||||
with pytest.raises(BudgetExceededError):
|
||||
agent._chat_completion(messages)
|
||||
agent._chat_completion_with_retry.assert_not_called()
|
||||
# No spend recorded for the blocked call.
|
||||
assert agent._total_cost == 0.0
|
||||
|
||||
def test_guard_skips_when_warn_mode(self):
|
||||
"""warn mode keeps reactive-only behaviour (no pre-call block)."""
|
||||
from praisonaiagents import Agent, ExecutionConfig
|
||||
agent = Agent(
|
||||
name="test", instructions="test", llm="gpt-4o",
|
||||
execution=ExecutionConfig(max_budget=0.0001, on_budget_exceeded="warn"),
|
||||
)
|
||||
est = agent._estimate_min_call_cost(
|
||||
[{"role": "user", "content": "x" * 200_000}], None
|
||||
)
|
||||
# Estimate alone would exceed the cap, but warn mode must not pre-block.
|
||||
assert est >= agent._max_budget
|
||||
assert agent._on_budget_exceeded == "warn"
|
||||
@@ -410,6 +410,190 @@ class TestPostEditDiagnostics:
|
||||
assert "Diagnostics" in result
|
||||
|
||||
|
||||
class TestLSPDiagnostics:
|
||||
"""The post-edit diagnostics hook is wired to the existing LSP client."""
|
||||
|
||||
def test_off_mode_skips_lsp(self, tmp_path, monkeypatch):
|
||||
# ``off`` short-circuits before any LSP attempt (zero overhead).
|
||||
editor = EditTools(post_edit_diagnostics="off")
|
||||
called = {"n": 0}
|
||||
|
||||
def _spy(self, safe_path, display_path):
|
||||
called["n"] += 1
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(EditTools, "_try_lsp_diagnostics", _spy)
|
||||
p = tmp_path / "x.py"
|
||||
_write(p, "x = 1\n")
|
||||
result = editor.edit_file(str(p), "x = 1", "x = 2")
|
||||
assert "Diagnostics" not in result
|
||||
assert called["n"] == 0
|
||||
|
||||
def test_no_server_falls_back_to_checker(self, tmp_path):
|
||||
# With no language server installed for the extension, LSP returns None
|
||||
# and the legacy per-language checker still surfaces syntax errors.
|
||||
editor = EditTools(post_edit_diagnostics="auto")
|
||||
# No server is configured for an unknown extension -> None.
|
||||
assert editor._try_lsp_diagnostics(str(tmp_path / "f.unknownext"), "f") is None
|
||||
p = tmp_path / "broken.py"
|
||||
_write(p, "x = 1\n")
|
||||
result = editor.edit_file(str(p), "x = 1", "def (:")
|
||||
assert "Success" in result
|
||||
assert "Diagnostics" in result
|
||||
|
||||
def test_lsp_block_used_when_available(self, tmp_path, monkeypatch):
|
||||
# When the LSP path returns a formatted block, it is used verbatim and
|
||||
# the legacy checker is not consulted.
|
||||
editor = EditTools(post_edit_diagnostics="auto")
|
||||
|
||||
def _fake_lsp(self, safe_path, display_path):
|
||||
return f"\n\nDiagnostics (lsp:typescript):\n{display_path}:1:1: error: oops"
|
||||
|
||||
def _boom(self, safe_path):
|
||||
raise AssertionError("checker fallback should not run when LSP returns a block")
|
||||
|
||||
monkeypatch.setattr(EditTools, "_try_lsp_diagnostics", _fake_lsp)
|
||||
monkeypatch.setattr(EditTools, "_diagnostics_command", staticmethod(_boom))
|
||||
p = tmp_path / "a.ts"
|
||||
_write(p, "const x = 1;\n")
|
||||
result = editor.edit_file(str(p), "const x = 1;", "const y = 2;")
|
||||
assert "Success" in result
|
||||
assert "Diagnostics (lsp:typescript)" in result
|
||||
|
||||
def test_lsp_no_problems_auto_is_silent(self, tmp_path, monkeypatch):
|
||||
# An LSP run with no diagnostics in ``auto`` mode appends nothing.
|
||||
editor = EditTools(post_edit_diagnostics="auto")
|
||||
monkeypatch.setattr(
|
||||
EditTools, "_try_lsp_diagnostics",
|
||||
lambda self, safe_path, display_path: "",
|
||||
)
|
||||
p = tmp_path / "a.ts"
|
||||
_write(p, "const x = 1;\n")
|
||||
result = editor.edit_file(str(p), "const x = 1;", "const y = 2;")
|
||||
assert "Success" in result
|
||||
assert "Diagnostics" not in result
|
||||
|
||||
def test_lsp_failure_falls_back_to_checker(self, tmp_path, monkeypatch):
|
||||
# A raising LSP attempt must not break the edit; the checker fallback
|
||||
# still runs and surfaces the Python syntax error.
|
||||
editor = EditTools(post_edit_diagnostics="auto")
|
||||
|
||||
def _raise(self, safe_path, display_path):
|
||||
raise RuntimeError("server crashed")
|
||||
|
||||
monkeypatch.setattr(EditTools, "_try_lsp_diagnostics", _raise)
|
||||
p = tmp_path / "broken.py"
|
||||
_write(p, "x = 1\n")
|
||||
result = editor.edit_file(str(p), "x = 1", "def (:")
|
||||
assert "Success" in result
|
||||
assert "Diagnostics" in result
|
||||
|
||||
def test_lsp_unpublished_falls_back_to_checker(self, tmp_path, monkeypatch):
|
||||
# Regression: when an installed server starts/opens but never publishes
|
||||
# diagnostics within the budget (still indexing, debounced, or URI
|
||||
# mismatch), the LSP path must return None (not "") so the legacy
|
||||
# checker still runs and surfaces a real syntax error rather than
|
||||
# reporting a false all-clear.
|
||||
import asyncio
|
||||
from praisonaiagents.tools import edit_tools as et
|
||||
|
||||
editor = EditTools(post_edit_diagnostics="auto")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *a, **k):
|
||||
self.config = type("C", (), {"timeout": 5})()
|
||||
self._diagnostics = {} # server never publishes
|
||||
|
||||
async def start(self):
|
||||
return True
|
||||
|
||||
async def open_document(self, path):
|
||||
return True
|
||||
|
||||
async def get_diagnostics(self, path):
|
||||
return []
|
||||
|
||||
async def close_document(self, path):
|
||||
return None
|
||||
|
||||
async def stop(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(et, "_DIAGNOSTICS_TIMEOUT", 0.3, raising=False)
|
||||
import shutil
|
||||
monkeypatch.setattr(shutil, "which",
|
||||
lambda cmd: "/usr/bin/" + cmd, raising=False)
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.lsp.config.DEFAULT_SERVERS",
|
||||
{"python": {"command": "pyright-langserver", "args": []}},
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.lsp.client.LSPClient", _FakeClient, raising=False
|
||||
)
|
||||
|
||||
# No running loop -> the sync path uses asyncio.run; force fresh loop.
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
pytest.skip("running loop present; sync LSP path not exercised")
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
block = editor._try_lsp_diagnostics(str(tmp_path / "broken.py"), "broken.py")
|
||||
assert block is None # unpublished -> fall back, not a false all-clear
|
||||
|
||||
def test_lsp_clean_publish_is_authoritative(self, tmp_path, monkeypatch):
|
||||
# A genuine clean publish ([]) returns "" in auto mode and does NOT
|
||||
# redundantly run the subprocess checker.
|
||||
import asyncio
|
||||
from praisonaiagents.tools import edit_tools as et
|
||||
|
||||
editor = EditTools(post_edit_diagnostics="auto")
|
||||
uri_holder = {}
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *a, **k):
|
||||
self.config = type("C", (), {"timeout": 5})()
|
||||
self._diagnostics = uri_holder
|
||||
|
||||
async def start(self):
|
||||
return True
|
||||
|
||||
async def open_document(self, path):
|
||||
uri_holder[f"file://{os.path.abspath(path)}"] = []
|
||||
return True
|
||||
|
||||
async def get_diagnostics(self, path):
|
||||
return []
|
||||
|
||||
async def close_document(self, path):
|
||||
return None
|
||||
|
||||
async def stop(self):
|
||||
return None
|
||||
|
||||
import shutil
|
||||
monkeypatch.setattr(shutil, "which",
|
||||
lambda cmd: "/usr/bin/" + cmd, raising=False)
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.lsp.config.DEFAULT_SERVERS",
|
||||
{"python": {"command": "pyright-langserver", "args": []}},
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.lsp.client.LSPClient", _FakeClient, raising=False
|
||||
)
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
pytest.skip("running loop present; sync LSP path not exercised")
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
block = editor._try_lsp_diagnostics(str(tmp_path / "ok.py"), "ok.py")
|
||||
assert block == "" # published-and-clean -> authoritative all-clear
|
||||
|
||||
|
||||
class TestAutomaticStaleness:
|
||||
def test_edit_aborts_when_changed_after_read(self, tools, tmp_path):
|
||||
p = tmp_path / "a.py"
|
||||
|
||||
@@ -336,5 +336,79 @@ class TestWorkflowHandoffCycleState:
|
||||
assert second["output"] == "ok"
|
||||
|
||||
|
||||
class TestSubstituteActionVariables:
|
||||
"""Test the _substitute_action_variables helper covering all branches."""
|
||||
|
||||
def test_substitutes_all_variables_keys(self):
|
||||
from praisonaiagents.workflows.workflows import _substitute_action_variables
|
||||
|
||||
result = _substitute_action_variables(
|
||||
"Hello {{name}} from {{place}}",
|
||||
{"name": "Ada", "place": "London"},
|
||||
previous_output=None,
|
||||
task_input="",
|
||||
)
|
||||
assert result == "Hello Ada from London"
|
||||
|
||||
def test_substitutes_input_placeholder(self):
|
||||
from praisonaiagents.workflows.workflows import _substitute_action_variables
|
||||
|
||||
result = _substitute_action_variables(
|
||||
"Process {{input}}",
|
||||
{},
|
||||
previous_output=None,
|
||||
task_input="the data",
|
||||
)
|
||||
assert result == "Process the data"
|
||||
|
||||
def test_explicit_previous_output_placeholder(self):
|
||||
from praisonaiagents.workflows.workflows import _substitute_action_variables
|
||||
|
||||
result = _substitute_action_variables(
|
||||
"Summarise: {{previous_output}}",
|
||||
{},
|
||||
previous_output="step one result",
|
||||
task_input="",
|
||||
)
|
||||
assert result == "Summarise: step one result"
|
||||
assert "Context from previous step:" not in result
|
||||
|
||||
def test_implicit_previous_output_append(self):
|
||||
"""When previous_output exists but {{previous_output}} is absent, append context."""
|
||||
from praisonaiagents.workflows.workflows import _substitute_action_variables
|
||||
|
||||
result = _substitute_action_variables(
|
||||
"Do the task",
|
||||
{},
|
||||
previous_output="prior result",
|
||||
task_input="",
|
||||
)
|
||||
assert result == "Do the task\n\nContext from previous step:\nprior result"
|
||||
|
||||
def test_implicit_append_runs_before_input_substitution(self):
|
||||
"""The appended context block precedes the final {{input}} replacement."""
|
||||
from praisonaiagents.workflows.workflows import _substitute_action_variables
|
||||
|
||||
result = _substitute_action_variables(
|
||||
"Handle {{input}}",
|
||||
{},
|
||||
previous_output="prior",
|
||||
task_input="X",
|
||||
)
|
||||
assert result == "Handle X\n\nContext from previous step:\nprior"
|
||||
|
||||
def test_no_previous_output_leaves_action_unchanged_aside_from_input(self):
|
||||
from praisonaiagents.workflows.workflows import _substitute_action_variables
|
||||
|
||||
result = _substitute_action_variables(
|
||||
"Just {{input}}",
|
||||
{},
|
||||
previous_output=None,
|
||||
task_input="text",
|
||||
)
|
||||
assert result == "Just text"
|
||||
assert "Context from previous step:" not in result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Generated
+1
-1
@@ -3125,7 +3125,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.74"
|
||||
version = "1.6.80"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -70,9 +70,20 @@ async def delete_dependency(
|
||||
raise HTTPException(status_code=404, detail="Dependency not found")
|
||||
if dep.issue_id != issue_id and dep.depends_on_issue_id != issue_id:
|
||||
raise HTTPException(status_code=404, detail="Dependency not found")
|
||||
|
||||
other_issue_id = (
|
||||
dep.depends_on_issue_id if dep.issue_id == issue_id else dep.issue_id
|
||||
)
|
||||
other_issue = await issue_svc.get(other_issue_id, workspace_id=workspace_id)
|
||||
if other_issue is None:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
|
||||
await require_delete_permission(
|
||||
workspace_id, user, session, resource_owner_id=issue.creator_id
|
||||
)
|
||||
await require_delete_permission(
|
||||
workspace_id, user, session, resource_owner_id=other_issue.creator_id
|
||||
)
|
||||
deleted = await svc.delete(dep_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Dependency not found")
|
||||
@@ -14,6 +14,7 @@ from ..deps import (
|
||||
get_db,
|
||||
require_delete_permission,
|
||||
require_issue_in_workspace,
|
||||
require_workspace_admin,
|
||||
require_workspace_member,
|
||||
)
|
||||
from ..schemas import LabelCreate, LabelResponse, LabelUpdate
|
||||
@@ -58,6 +59,7 @@ async def update_label(
|
||||
if label is None:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
|
||||
await require_workspace_admin(workspace_id, user, session)
|
||||
label = await svc.update(label_id, body.name, body.color)
|
||||
if label is None:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
|
||||
@@ -71,6 +71,12 @@ async def update_project(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = ProjectService(session)
|
||||
project = await svc.get(project_id, workspace_id=workspace_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
await require_delete_permission(
|
||||
workspace_id, user, session, resource_owner_id=project.lead_id
|
||||
)
|
||||
project = await svc.update(
|
||||
project_id,
|
||||
workspace_id=workspace_id,
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonai-platform"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
description = "Platform layer for PraisonAI — workspace, auth, issues, projects"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Platform GHSA regression tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_project_patch_has_owner_guard():
|
||||
"""GHSA-c78w: project PATCH must call require_delete_permission."""
|
||||
source = Path(__file__).resolve().parents[2] / "praisonai_platform/api/routes/projects.py"
|
||||
text = source.read_text()
|
||||
patch_block = text.split("async def update_project", 1)[1].split("async def delete_project", 1)[0]
|
||||
assert "require_delete_permission" in patch_block
|
||||
|
||||
|
||||
def test_label_patch_requires_admin_guard():
|
||||
"""GHSA-xxgv: label PATCH must require workspace admin."""
|
||||
source = Path(__file__).resolve().parents[2] / "praisonai_platform/api/routes/labels.py"
|
||||
text = source.read_text()
|
||||
patch_block = text.split("async def update_label", 1)[1].split("async def delete_label", 1)[0]
|
||||
assert "require_workspace_admin" in patch_block
|
||||
|
||||
|
||||
def test_dependency_delete_checks_both_issues():
|
||||
"""GHSA-mxmx: dependency delete must authorise both linked issues."""
|
||||
source = Path(__file__).resolve().parents[2] / "praisonai_platform/api/routes/dependencies.py"
|
||||
text = source.read_text()
|
||||
delete_block = text.split("async def delete_dependency", 1)[1]
|
||||
assert delete_block.count("require_delete_permission") >= 2
|
||||
assert "other_issue" in delete_block
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_patch_requires_owner_or_admin():
|
||||
"""GHSA-c78w: project PATCH must match DELETE permission model."""
|
||||
pytest.importorskip("fastapi")
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from praisonai_platform.api.routes import projects as routes
|
||||
|
||||
user = MagicMock()
|
||||
user.id = "member-1"
|
||||
session = AsyncMock()
|
||||
project = MagicMock()
|
||||
project.lead_id = "owner-1"
|
||||
|
||||
with patch.object(routes.ProjectService, "get", AsyncMock(return_value=project)):
|
||||
with patch.object(routes, "require_delete_permission", AsyncMock(side_effect=Exception("403"))) as guard:
|
||||
with pytest.raises(Exception, match="403"):
|
||||
await routes.update_project(
|
||||
"ws-1",
|
||||
"proj-1",
|
||||
MagicMock(),
|
||||
user=user,
|
||||
session=session,
|
||||
)
|
||||
guard.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_label_patch_requires_admin():
|
||||
"""GHSA-xxgv: shared label PATCH requires admin/owner."""
|
||||
pytest.importorskip("fastapi")
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from praisonai_platform.api.routes import labels as routes
|
||||
|
||||
label = MagicMock()
|
||||
label.workspace_id = "ws-1"
|
||||
|
||||
with patch.object(routes.LabelService, "get", AsyncMock(return_value=label)):
|
||||
with patch.object(routes, "require_workspace_admin", AsyncMock(side_effect=Exception("403"))) as guard:
|
||||
with pytest.raises(Exception, match="403"):
|
||||
await routes.update_label(
|
||||
"ws-1",
|
||||
"label-1",
|
||||
MagicMock(),
|
||||
user=MagicMock(),
|
||||
session=AsyncMock(),
|
||||
)
|
||||
guard.assert_awaited_once()
|
||||
Generated
+27
-1
@@ -509,6 +509,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "dnspython" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
@@ -1208,11 +1230,12 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-platform"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "alembic" },
|
||||
{ name = "email-validator" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "passlib", extra = ["bcrypt"] },
|
||||
@@ -1229,6 +1252,7 @@ postgres = [
|
||||
{ name = "psycopg2-binary" },
|
||||
]
|
||||
test = [
|
||||
{ name = "email-validator" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
@@ -1239,6 +1263,8 @@ requires-dist = [
|
||||
{ name = "aiosqlite", specifier = ">=0.20.0" },
|
||||
{ name = "alembic", specifier = ">=1.14.0" },
|
||||
{ name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.30.0" },
|
||||
{ name = "email-validator", specifier = ">=2.0.0" },
|
||||
{ name = "email-validator", marker = "extra == 'test'", specifier = ">=2.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" },
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-06-24",
|
||||
"lastUpdated": "2026-06-26",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"status": "PARITY_ACHIEVED",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 388,
|
||||
"pythonCoreFeatures": 392,
|
||||
"rustFeatures": 667,
|
||||
"gapCount": 106,
|
||||
"parityPercentage": 171.9
|
||||
"gapCount": 110,
|
||||
"parityPercentage": 170.2
|
||||
},
|
||||
"pythonCoreSDK": {
|
||||
"exports": [
|
||||
@@ -27,6 +27,7 @@
|
||||
"AgentOSProtocol",
|
||||
"AgentPluginProtocol",
|
||||
"AgentRunOutcome",
|
||||
"AgentRuntimeProtocol",
|
||||
"AgentTeam",
|
||||
"Agents",
|
||||
"ApprovalCallback",
|
||||
@@ -350,6 +351,7 @@
|
||||
"is_path_like",
|
||||
"is_policy_string",
|
||||
"list_memory_adapters",
|
||||
"list_runtimes",
|
||||
"list_toolsets",
|
||||
"load_plugin",
|
||||
"load_skill",
|
||||
@@ -364,6 +366,7 @@
|
||||
"register_display_callback",
|
||||
"register_memory_adapter",
|
||||
"register_memory_factory",
|
||||
"register_runtime",
|
||||
"register_tool",
|
||||
"register_toolset",
|
||||
"repeat",
|
||||
@@ -381,6 +384,7 @@
|
||||
"resolve_planning",
|
||||
"resolve_reflection",
|
||||
"resolve_routing",
|
||||
"resolve_runtime",
|
||||
"resolve_skills",
|
||||
"resolve_toolset",
|
||||
"resolve_toolsets",
|
||||
@@ -3089,6 +3093,15 @@
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "AgentRuntimeProtocol",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "medium",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "AgentTeam",
|
||||
"python": true,
|
||||
@@ -4997,6 +5010,15 @@
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "list_runtimes",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "list_toolsets",
|
||||
"python": true,
|
||||
@@ -5078,6 +5100,15 @@
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "register_runtime",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "register_toolset",
|
||||
"python": true,
|
||||
@@ -5213,6 +5244,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "resolve_runtime",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "resolve_skills",
|
||||
"python": true,
|
||||
|
||||
Loaded 100 of 232 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user