mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
218
Commits
No files matched your search
@@ -227,9 +227,10 @@ runs:
|
||||
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
|
||||
3. Tools (PraisonAI-Tools): Specific integrations, external tools (e.g. SurrealDB, Slack), and community extensions MUST go to the `MervinPraison/PraisonAI-Tools` repository. If the feature is a tool, clone `https://github.com/MervinPraison/PraisonAI-Tools`, implement it there, and create the PR using `gh pr create -R MervinPraison/PraisonAI-Tools`.
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
|
||||
- Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
|
||||
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead. PRs with >100 lines added to agent.py require manual review (merge gate will not auto-merge).
|
||||
- Is there a name collision with existing exports in __init__.py?
|
||||
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
|
||||
|
||||
@@ -260,16 +261,24 @@ runs:
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
|
||||
GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
INPUT_MODEL: ${{ inputs.model }}
|
||||
INPUT_MAX_TURNS: ${{ inputs.max_turns }}
|
||||
INPUT_ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
|
||||
INPUT_DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
|
||||
INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: inherit
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
|
||||
run: |
|
||||
echo "🚀 Running Claude Code with GitHub context..."
|
||||
|
||||
# Build command arguments
|
||||
CMD_ARGS=("-p" "--verbose" "--output-format" "stream-json")
|
||||
|
||||
if [[ -n "$INPUT_MODEL" ]]; then
|
||||
CMD_ARGS+=("--model" "$INPUT_MODEL")
|
||||
export ANTHROPIC_MODEL="$INPUT_MODEL"
|
||||
fi
|
||||
|
||||
# Add max turns if specified
|
||||
if [[ -n "$INPUT_MAX_TURNS" ]]; then
|
||||
CMD_ARGS+=("--max-turns" "$INPUT_MAX_TURNS")
|
||||
@@ -280,11 +289,36 @@ runs:
|
||||
if [[ -n "$INPUT_ALLOWED_TOOLS" ]]; then
|
||||
TOOLS="$TOOLS,$INPUT_ALLOWED_TOOLS"
|
||||
fi
|
||||
|
||||
# Remove any explicitly disallowed tools from the effective allow-list so
|
||||
# the read-only contract is enforced deterministically (no reliance on CLI
|
||||
# precedence between --allowedTools and --disallowedTools).
|
||||
if [[ -n "$INPUT_DISALLOWED_TOOLS" ]]; then
|
||||
DISALLOWED_CSV=$(echo "$INPUT_DISALLOWED_TOOLS" | tr ',\n' ' ' | xargs)
|
||||
FILTERED=""
|
||||
IFS=',' read -ra TOOL_ARR <<< "$TOOLS"
|
||||
for tool in "${TOOL_ARR[@]}"; do
|
||||
tool=$(echo "$tool" | xargs)
|
||||
[[ -z "$tool" ]] && continue
|
||||
skip="false"
|
||||
for bad in $DISALLOWED_CSV; do
|
||||
if [[ "$tool" == "$bad" ]]; then
|
||||
skip="true"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$skip" == "false" ]]; then
|
||||
FILTERED="${FILTERED:+$FILTERED,}$tool"
|
||||
fi
|
||||
done
|
||||
TOOLS="$FILTERED"
|
||||
fi
|
||||
CMD_ARGS+=("--allowedTools" "$TOOLS")
|
||||
|
||||
# Add disallowed tools
|
||||
if [[ -n "$INPUT_DISALLOWED_TOOLS" ]]; then
|
||||
CMD_ARGS+=("--disallowedTools" "$INPUT_DISALLOWED_TOOLS")
|
||||
DISALLOWED_FLAG=$(echo "$INPUT_DISALLOWED_TOOLS" | tr '\n' ',' | sed 's/,*$//')
|
||||
CMD_ARGS+=("--disallowedTools" "$DISALLOWED_FLAG")
|
||||
fi
|
||||
|
||||
echo "📝 Executing Claude Code with prompt from file..."
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
const chain = require('./bot-pr-review-chain.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
assert('chainKickPosted requires both markers', chain.chainKickPosted([
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
{ user: { login: 'MervinPraison' }, body: '/review' },
|
||||
]));
|
||||
assert('chainKickPosted false with only coderabbit', !chain.chainKickPosted([
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
]));
|
||||
assert('coderabbitKickPosted detects coderabbit', chain.coderabbitKickPosted([
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
]));
|
||||
assert('qodoKickPosted detects qodo', chain.qodoKickPosted([
|
||||
{ user: { login: 'github-actions[bot]' }, body: '/review' },
|
||||
]));
|
||||
assert('ignores coderabbit bot comment', !chain.coderabbitKickPosted([
|
||||
{ user: { login: 'coderabbitai[bot]' }, body: '@coderabbitai review' },
|
||||
]));
|
||||
assert('bot PR author match', chain.isBotOpenedPr({ user: { login: 'praisonai-triage-agent[bot]' } }));
|
||||
assert('generic bot type match', chain.isBotOpenedPr({ user: { login: 'my-bot', type: 'Bot' } }));
|
||||
assert('human author excluded', !chain.isBotOpenedPr({ user: { login: 'MervinPraison', type: 'User' } }));
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Idempotent CodeRabbit/Qodo kick for bot-opened PRs.
|
||||
* @see .github/workflows/auto-pr-comment.yml, claude.yml, bot-pr-recovery.yml
|
||||
*/
|
||||
|
||||
const KICK_AUTHORS = new Set(['MervinPraison', 'github-actions[bot]']);
|
||||
const BOT_PR_AUTHORS = new Set(['praisonai-triage-agent[bot]', 'github-actions[bot]']);
|
||||
|
||||
async function listAllComments(github, owner, repo, issueNumber) {
|
||||
if (typeof github.paginate === 'function') {
|
||||
return github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
const { data } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
function kickAuthored(comment, marker) {
|
||||
return (
|
||||
KICK_AUTHORS.has(comment.user?.login) &&
|
||||
(comment.body || '').includes(marker)
|
||||
);
|
||||
}
|
||||
|
||||
function coderabbitKickPosted(comments) {
|
||||
return comments.some((c) => kickAuthored(c, '@coderabbitai review'));
|
||||
}
|
||||
|
||||
function qodoKickPosted(comments) {
|
||||
return comments.some((c) => kickAuthored(c, '/review'));
|
||||
}
|
||||
|
||||
function chainKickPosted(comments) {
|
||||
return coderabbitKickPosted(comments) && qodoKickPosted(comments);
|
||||
}
|
||||
|
||||
function isBotOpenedPr(pr) {
|
||||
if (BOT_PR_AUTHORS.has(pr.user?.login)) return true;
|
||||
return pr.user?.type === 'Bot';
|
||||
}
|
||||
|
||||
async function kickReviewChain(github, owner, repo, prNumber, core, preFetchedComments = null) {
|
||||
const comments = preFetchedComments || await listAllComments(github, owner, repo, prNumber);
|
||||
const needCoderabbit = !coderabbitKickPosted(comments);
|
||||
const needQodo = !qodoKickPosted(comments);
|
||||
|
||||
if (!needCoderabbit && !needQodo) {
|
||||
core?.info?.(`Review chain already kicked on PR #${prNumber}`);
|
||||
return { kicked: false, reason: 'already_kicked' };
|
||||
}
|
||||
|
||||
if (needCoderabbit) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: '@coderabbitai review',
|
||||
});
|
||||
}
|
||||
if (needQodo) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: '/review',
|
||||
});
|
||||
}
|
||||
core?.info?.(`Kicked review chain for PR #${prNumber} (coderabbit=${needCoderabbit}, qodo=${needQodo})`);
|
||||
return { kicked: true, coderabbit: needCoderabbit, qodo: needQodo };
|
||||
}
|
||||
|
||||
async function findOpenPrForIssue(github, owner, repo, issueNumber) {
|
||||
const prefix = `claude/issue-${issueNumber}-`;
|
||||
const { data: prs } = await github.rest.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
sort: 'created',
|
||||
direction: 'desc',
|
||||
per_page: 30,
|
||||
});
|
||||
return (
|
||||
prs.find(
|
||||
(p) => (p.head?.ref || '').startsWith(prefix) && isBotOpenedPr(p)
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
async function kickReviewChainForIssue(github, owner, repo, issueNumber, core) {
|
||||
const pr = await findOpenPrForIssue(github, owner, repo, issueNumber);
|
||||
if (!pr) {
|
||||
core?.info?.(`No open PR for issue #${issueNumber}, skipping review kick`);
|
||||
return { kicked: false, reason: 'no_pr' };
|
||||
}
|
||||
const result = await kickReviewChain(github, owner, repo, pr.number, core);
|
||||
return { ...result, prNumber: pr.number };
|
||||
}
|
||||
|
||||
async function recoverStalledBotPrs(github, owner, repo, options, core) {
|
||||
const { prNumber = null, minAgeMs = 10 * 60 * 1000, maxRecover = 10 } = options || {};
|
||||
let prs;
|
||||
if (prNumber) {
|
||||
const { data } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
prs = [data];
|
||||
} else if (typeof github.paginate === 'function') {
|
||||
prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
} else {
|
||||
const { data } = await github.rest.pulls.list({ owner, repo, state: 'open', per_page: 100 });
|
||||
prs = data;
|
||||
}
|
||||
|
||||
const cutoff = Date.now() - minAgeMs;
|
||||
let recovered = 0;
|
||||
for (const pr of prs) {
|
||||
if (recovered >= maxRecover) break;
|
||||
if (!isBotOpenedPr(pr)) continue;
|
||||
if (!prNumber && new Date(pr.created_at).getTime() > cutoff) continue;
|
||||
const comments = await listAllComments(github, owner, repo, pr.number);
|
||||
if (chainKickPosted(comments)) continue;
|
||||
await kickReviewChain(github, owner, repo, pr.number, core, comments);
|
||||
recovered += 1;
|
||||
}
|
||||
core?.info?.(`Recovery complete (${recovered} PR(s) kicked)`);
|
||||
return recovered;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KICK_AUTHORS,
|
||||
BOT_PR_AUTHORS,
|
||||
listAllComments,
|
||||
chainKickPosted,
|
||||
coderabbitKickPosted,
|
||||
qodoKickPosted,
|
||||
isBotOpenedPr,
|
||||
kickReviewChain,
|
||||
findOpenPrForIssue,
|
||||
kickReviewChainForIssue,
|
||||
recoverStalledBotPrs,
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Local self-test for merge-gate.js heuristics (no GitHub API).
|
||||
* Run: node .github/scripts/merge-gate-selftest.js
|
||||
*/
|
||||
const mg = require('./merge-gate.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
// Stale FINAL: push after FINAL, no @claude since head
|
||||
const finals = [
|
||||
{ user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T08:00:00Z' },
|
||||
];
|
||||
assert('stale when head after final', mg.isStaleFinalAfterPush(finals, '2026-06-12T09:00:00Z'));
|
||||
|
||||
const withRecovery = [
|
||||
...finals,
|
||||
{ user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T09:30:00Z' },
|
||||
];
|
||||
assert('not stale when @claude after head', !mg.isStaleFinalAfterPush(withRecovery, '2026-06-12T09:00:00Z'));
|
||||
|
||||
// Bot CHANGES_REQUESTED then APPROVE
|
||||
const reviews = [
|
||||
{ user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T08:00:00Z' },
|
||||
{ user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'APPROVED', submitted_at: '2026-06-12T09:00:00Z' },
|
||||
];
|
||||
assert('bot approve clears CR', !mg.hasAnyChangesRequested(reviews));
|
||||
assert('human CR blocks', mg.hasAnyChangesRequested([
|
||||
{ user: { login: 'MervinPraison', type: 'User' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T09:00:00Z' },
|
||||
]));
|
||||
|
||||
// Verdict after HEAD
|
||||
const verdictComments = [
|
||||
{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T08:00:00Z' },
|
||||
];
|
||||
assert('verdict before head rejected', mg.findMergeGateVerdict(verdictComments, null, '2026-06-12T09:00:00Z') === null);
|
||||
assert('verdict after head accepted', mg.findMergeGateVerdict(
|
||||
[{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T10:00:00Z' }],
|
||||
null,
|
||||
'2026-06-12T09:00:00Z'
|
||||
) === 'APPROVE');
|
||||
|
||||
const noise = [{ user: { login: 'MervinPraison' }, body: '**Merge gate scan** — wait for `@claude`', created_at: new Date().toISOString() }];
|
||||
assert('diagnostic comment not a trigger', !mg.hasRecentClaudeTrigger(noise, 35));
|
||||
|
||||
// Sensitive + secrets
|
||||
assert('workflow path sensitive', mg.sensitivePathReasons([{ filename: '.github/workflows/foo.yml' }]).length === 1);
|
||||
assert('ci-only label exempts workflows', mg.sensitivePathReasons(
|
||||
[{ filename: '.github/workflows/foo.yml' }],
|
||||
[mg.WORKFLOW_ONLY_LABEL]
|
||||
).length === 0);
|
||||
assert('ci-only label not exempt mixed changes', mg.sensitivePathReasons(
|
||||
[{ filename: '.github/workflows/foo.yml' }, { filename: 'src/foo.py' }],
|
||||
[mg.WORKFLOW_ONLY_LABEL]
|
||||
).length === 1);
|
||||
assert('secret in patch', mg.secretScanReasons([{ filename: 'x.py', patch: '+key = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"' }]).length === 1);
|
||||
|
||||
// Claude run scoping — other PR branches must not block
|
||||
assert('other branch claude does not block', !mg.hasBlockingClaudeRunForPr(
|
||||
[{ event: 'issue_comment', head_branch: 'other-branch' }],
|
||||
'my-branch'
|
||||
));
|
||||
assert('same branch claude blocks', mg.hasBlockingClaudeRunForPr(
|
||||
[{ event: 'issue_comment', head_branch: 'my-branch' }],
|
||||
'my-branch'
|
||||
));
|
||||
assert('issues event never blocks', !mg.hasBlockingClaudeRunForPr(
|
||||
[{ event: 'issues', head_branch: 'my-branch' }],
|
||||
'my-branch'
|
||||
));
|
||||
|
||||
// Conflict rebase clears after bot completion + FINAL on HEAD
|
||||
const conflictTrigger = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude this PR has merge conflicts with main. Please rebase',
|
||||
created_at: '2026-06-26T10:30:38Z',
|
||||
};
|
||||
const rebaseDone = {
|
||||
user: { login: 'praisonai-triage-agent[bot]' },
|
||||
body: 'Rebase complete — PR #2308 onto latest main',
|
||||
created_at: '2026-06-26T10:30:55Z',
|
||||
};
|
||||
const finalAfterRebase = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude You are the FINAL architecture reviewer.',
|
||||
created_at: '2026-06-26T10:39:43Z',
|
||||
};
|
||||
const headAfterRebase = '2026-06-26T10:39:25Z';
|
||||
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase];
|
||||
assert('conflict blocks before rebase done', mg.hasRecentConflictComment([conflictTrigger], headAfterRebase));
|
||||
assert('conflict clears after rebase + FINAL on HEAD', !mg.hasRecentConflictComment(rebaseComments, headAfterRebase));
|
||||
assert('conflict still blocks without FINAL on HEAD', mg.hasRecentConflictComment(
|
||||
[conflictTrigger, rebaseDone],
|
||||
headAfterRebase
|
||||
));
|
||||
|
||||
// Tests heuristic
|
||||
assert('sdk without tests', mg.missingTestsReason([{ filename: 'src/praisonai-agents/a/b.py', additions: 3 }]) !== null);
|
||||
assert('sdk with tests ok', mg.missingTestsReason([
|
||||
{ filename: 'src/praisonai-agents/a/b.py', additions: 3 },
|
||||
{ filename: 'src/praisonai-agents/tests/test_x.py', additions: 10 },
|
||||
]) === null);
|
||||
|
||||
// PR size
|
||||
assert('large PR blocked', mg.prSizeReasons([{ additions: 900 }]).length > 0);
|
||||
|
||||
assert('internal PR link accepted', mg.isInternalPullRequestLink(
|
||||
{ base: { repo: { full_name: 'MervinPraison/PraisonAI' } } },
|
||||
'MervinPraison',
|
||||
'PraisonAI'
|
||||
));
|
||||
assert('fork sync PR link rejected', !mg.isInternalPullRequestLink(
|
||||
{ number: 21, base: { repo: { full_name: 'Milkmange/PraisonAI' } } },
|
||||
'MervinPraison',
|
||||
'PraisonAI'
|
||||
));
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,747 @@
|
||||
/**
|
||||
* Shared merge-gate helpers for claude-merge-gate.yml
|
||||
* @see .github/workflows/claude-merge-gate.yml
|
||||
*/
|
||||
|
||||
const CLAUDE_TRIGGER_LOGINS = ['MervinPraison', 'github-actions[bot]'];
|
||||
const AUTO_ACTORS = CLAUDE_TRIGGER_LOGINS;
|
||||
const CONFLICT_COOLDOWN_MS = 12 * 60 * 60 * 1000;
|
||||
const CLAUDE_ACTIVE_MS = 35 * 60 * 1000;
|
||||
const POST_PUSH_BUFFER_MS = 5 * 60 * 1000;
|
||||
const ALLOWED_MERGE_STATES = new Set(['CLEAN', 'UNSTABLE']);
|
||||
const AGENT_PY_MAX_AUTO_LINES = 100;
|
||||
const PR_MAX_AUTO_ADDITIONS = 800;
|
||||
const PR_MAX_AUTO_FILES = 30;
|
||||
const MANUAL_ONLY_LABELS = new Set(['security', 'breaking-change', 'needs-manual-review', 'release']);
|
||||
const WORKFLOW_ONLY_LABEL = 'merge-gate-ci-only';
|
||||
const CI_ONLY_PATH_PREFIXES = ['.github/workflows/', '.github/actions/', '.github/scripts/merge-gate'];
|
||||
const SDK_PATH_PREFIXES = ['src/praisonai-agents/', 'src/praisonai/'];
|
||||
const SENSITIVE_PATH_PATTERNS = [
|
||||
/^\.github\/workflows\//,
|
||||
/praisonaiagents\/(auth|approval|policy|sandbox)\//,
|
||||
/^src\/praisonai-agents\/pyproject\.toml$/,
|
||||
/^src\/praisonai\/pyproject\.toml$/,
|
||||
/\.env(\.|$)/,
|
||||
/credentials\.json$/i,
|
||||
];
|
||||
const REQUIRED_SDK_CHECK_PATTERNS = [/test/i, /smoke/i, /core/i, /python package/i, /comprehensive/i, /optimized/i];
|
||||
const SECRET_PATTERNS = [
|
||||
/sk-[a-zA-Z0-9]{20,}/,
|
||||
/AKIA[0-9A-Z]{16}/,
|
||||
/ghp_[a-zA-Z0-9]{36,}/,
|
||||
/github_pat_/,
|
||||
/Bearer eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/,
|
||||
/-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/,
|
||||
];
|
||||
const BLOCK_LABELS = new Set([
|
||||
'claude-conflict-pending',
|
||||
'claude-merge-gate-active',
|
||||
'no-auto-merge',
|
||||
'auto-merged-by-gate',
|
||||
]);
|
||||
const BOT_REVIEWER_PATTERNS = [
|
||||
'coderabbit',
|
||||
'qodo',
|
||||
'gemini',
|
||||
'copilot',
|
||||
'greptile',
|
||||
];
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function isFinalClaudeTriggerComment(c) {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!AUTO_ACTORS.includes(c.user.login)) return false;
|
||||
if (!body.includes('@claude')) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
}
|
||||
|
||||
function isClaudeTriggerNoise(c) {
|
||||
const body = c.body || '';
|
||||
if (body.includes('Merge gate scan')) return true;
|
||||
if (body.includes('MERGE_GATE_VERDICT')) return true;
|
||||
if (body.includes('Merged by **Claude PR merge gate**')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasRecentClaudeTrigger(comments, minutes = 35) {
|
||||
const cutoff = Date.now() - minutes * 60 * 1000;
|
||||
return comments.some((c) => {
|
||||
if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false;
|
||||
if (isClaudeTriggerNoise(c)) return false;
|
||||
if (!(c.body || '').includes('@claude')) return false;
|
||||
return new Date(c.created_at).getTime() > cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
function isConflictRebaseTriggerComment(c) {
|
||||
if (!AUTO_ACTORS.includes(c.user.login)) return false;
|
||||
const body = (c.body || '').toLowerCase();
|
||||
return body.includes('@claude') && body.includes('merge conflict');
|
||||
}
|
||||
|
||||
function isConflictRebaseCompletionComment(c) {
|
||||
const login = (c.user?.login || '').toLowerCase();
|
||||
if (!login.includes('praisonai-triage') && !login.includes('github-actions')) return false;
|
||||
const body = (c.body || '').toLowerCase();
|
||||
return (
|
||||
body.includes('rebase complete') ||
|
||||
body.includes('rebase onto') ||
|
||||
(body.includes('conflict') && body.includes('resolved'))
|
||||
);
|
||||
}
|
||||
|
||||
function conflictRebaseQuiescent(comments, headPushedAt) {
|
||||
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
|
||||
if (conflictTriggers.length === 0) return true;
|
||||
|
||||
const latestConflict = conflictTriggers.reduce((a, b) =>
|
||||
new Date(a.created_at) > new Date(b.created_at) ? a : b
|
||||
);
|
||||
const conflictTime = new Date(latestConflict.created_at).getTime();
|
||||
|
||||
const rebaseDone = comments.some(
|
||||
(c) =>
|
||||
new Date(c.created_at).getTime() > conflictTime && isConflictRebaseCompletionComment(c)
|
||||
);
|
||||
if (!rebaseDone) return false;
|
||||
|
||||
return finalClaudeCompletedOnSha(comments, headPushedAt);
|
||||
}
|
||||
|
||||
function hasRecentConflictComment(comments, headPushedAt = null) {
|
||||
const cutoff = Date.now() - CONFLICT_COOLDOWN_MS;
|
||||
const hasRecentTrigger = comments.some((c) => {
|
||||
if (!isConflictRebaseTriggerComment(c)) return false;
|
||||
return new Date(c.created_at).getTime() > cutoff;
|
||||
});
|
||||
if (!hasRecentTrigger) return false;
|
||||
|
||||
if (headPushedAt && conflictRebaseQuiescent(comments, headPushedAt)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isBotReviewer(login, userType) {
|
||||
const lower = (login || '').toLowerCase();
|
||||
if (userType === 'Bot') return true;
|
||||
if (lower.endsWith('[bot]')) return true;
|
||||
return ['coderabbit', 'qodo', 'gemini', 'copilot', 'greptile'].some((p) => lower.includes(p));
|
||||
}
|
||||
|
||||
function latestReviewsByUser(reviews) {
|
||||
const latestByUser = new Map();
|
||||
for (const r of reviews) {
|
||||
const login = r.user?.login;
|
||||
if (!login) continue;
|
||||
const prev = latestByUser.get(login);
|
||||
if (!prev || new Date(r.submitted_at) > new Date(prev.submitted_at)) {
|
||||
latestByUser.set(login, r);
|
||||
}
|
||||
}
|
||||
return latestByUser;
|
||||
}
|
||||
|
||||
function hasHumanChangesRequested(reviews) {
|
||||
for (const [login, review] of latestReviewsByUser(reviews)) {
|
||||
if (review.state !== 'CHANGES_REQUESTED') continue;
|
||||
if (!isBotReviewer(login, review.user?.type)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasAnyChangesRequested(reviews) {
|
||||
for (const [, review] of latestReviewsByUser(reviews)) {
|
||||
if (review.state === 'CHANGES_REQUESTED') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasFinalClaudeReviewTrigger(comments) {
|
||||
return comments.some(isFinalClaudeTriggerComment);
|
||||
}
|
||||
|
||||
function isStaleFinalAfterPush(comments, headPushedAt) {
|
||||
if (!headPushedAt) return false;
|
||||
const headTime = new Date(headPushedAt).getTime();
|
||||
const finals = comments.filter(isFinalClaudeTriggerComment);
|
||||
if (finals.length === 0) return true;
|
||||
const latestFinal = finals.reduce((a, b) =>
|
||||
new Date(a.created_at) > new Date(b.created_at) ? a : b
|
||||
);
|
||||
const finalTime = new Date(latestFinal.created_at).getTime();
|
||||
if (headTime <= finalTime + 60000) return false;
|
||||
const claudeSinceHead = comments.some((c) => {
|
||||
if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false;
|
||||
if (isClaudeTriggerNoise(c)) return false;
|
||||
if (!(c.body || '').includes('@claude')) return false;
|
||||
return new Date(c.created_at).getTime() >= headTime - 60000;
|
||||
});
|
||||
return !claudeSinceHead;
|
||||
}
|
||||
|
||||
function needsStaleFinalRecovery(comments, headPushedAt) {
|
||||
return (
|
||||
hasFinalClaudeReviewTrigger(comments) &&
|
||||
isStaleFinalAfterPush(comments, headPushedAt)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldSkipFinalRecovery(comments, headPushedAt) {
|
||||
const isStale = isStaleFinalAfterPush(comments, headPushedAt);
|
||||
if (isStale) return false;
|
||||
return hasRecentClaudeTrigger(comments, 35);
|
||||
}
|
||||
|
||||
function finalClaudeCompletedOnSha(comments, headPushedAt) {
|
||||
if (!hasFinalClaudeReviewTrigger(comments)) return false;
|
||||
if (isStaleFinalAfterPush(comments, headPushedAt)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const FINAL_CLAUDE_REVIEW_BODY =
|
||||
'@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items';
|
||||
|
||||
async function getMergeState(github, owner, repo, prNumber) {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
mergeStateStatus
|
||||
maintainerCanModify
|
||||
isDraft
|
||||
headRefOid
|
||||
headRef { repository { nameWithOwner } }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const result = await github.graphql(query, { owner, repo, number: prNumber });
|
||||
const prGql = result.repository.pullRequest;
|
||||
const status = (prGql?.mergeStateStatus || '').toUpperCase();
|
||||
if (status && status !== 'UNKNOWN') {
|
||||
return {
|
||||
status,
|
||||
isDraft: prGql.isDraft,
|
||||
headRepo: prGql.headRef?.repository?.nameWithOwner,
|
||||
headSha: prGql.headRefOid,
|
||||
maintainerCanModify: prGql.maintainerCanModify === true,
|
||||
};
|
||||
}
|
||||
if (attempt < 2) await sleep(10000);
|
||||
}
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
return {
|
||||
status: (pr.mergeable_state || '').toUpperCase(),
|
||||
isDraft: pr.draft,
|
||||
headRepo: pr.head.repo?.full_name,
|
||||
headSha: pr.head.sha,
|
||||
maintainerCanModify: pr.maintainer_can_modify === true,
|
||||
};
|
||||
}
|
||||
|
||||
async function allChecksGreenOnSha(github, owner, repo, sha, core) {
|
||||
const { data } = await github.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
const runs = (data.check_runs || []).filter((r) => r.head_sha === sha);
|
||||
if (runs.length === 0) {
|
||||
core?.info?.(`No check runs on ${sha.slice(0, 7)} — allowing (e.g. docs-only PR)`);
|
||||
return true;
|
||||
}
|
||||
for (const run of runs) {
|
||||
if (run.status !== 'completed') {
|
||||
core?.info?.(`Check pending: ${run.name} (${run.status})`);
|
||||
return false;
|
||||
}
|
||||
const ok = ['success', 'neutral', 'skipped'].includes(run.conclusion);
|
||||
if (!ok) {
|
||||
core?.info?.(`Check failed: ${run.name} (${run.conclusion})`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function claudeRunBlocksPr(run, headRef) {
|
||||
if (!run || run.event === 'issues') return false;
|
||||
if (!headRef) return true;
|
||||
return run.head_branch === headRef;
|
||||
}
|
||||
|
||||
function hasBlockingClaudeRunForPr(runs, headRef) {
|
||||
return (runs || []).some((r) => claudeRunBlocksPr(r, headRef));
|
||||
}
|
||||
|
||||
async function hasInProgressClaudeAssistant(github, owner, repo, prNumber = null) {
|
||||
try {
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: 'claude.yml',
|
||||
status: 'in_progress',
|
||||
per_page: 20,
|
||||
});
|
||||
const runs = (data.workflow_runs || []).filter((r) => r.event !== 'issues');
|
||||
if (runs.length === 0) return false;
|
||||
if (prNumber == null) return runs.length > 0;
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
const headRef = pr.head.ref;
|
||||
return hasBlockingClaudeRunForPr(runs, headRef);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isCiOnlyChange(files) {
|
||||
if (!files.length) return false;
|
||||
return files.every((f) => CI_ONLY_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
|
||||
}
|
||||
|
||||
function isInternalPullRequestLink(link, owner, repo) {
|
||||
if (link?.base?.repo?.full_name === `${owner}/${repo}`) return true;
|
||||
const baseUrl = link?.base?.repo?.url || '';
|
||||
return baseUrl.endsWith(`/repos/${owner}/${repo}`);
|
||||
}
|
||||
|
||||
async function resolvePrNumberFromLinkedPullRequests(github, owner, repo, linked) {
|
||||
const repoFull = `${owner}/${repo}`;
|
||||
const internal = (linked || []).filter((l) => isInternalPullRequestLink(l, owner, repo));
|
||||
for (const link of internal) {
|
||||
if (!link.number) continue;
|
||||
try {
|
||||
const { data } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: link.number,
|
||||
});
|
||||
if (data.state === 'open' && data.base?.repo?.full_name === repoFull) {
|
||||
return data.number;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.status !== 404) throw err;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolvePrNumberFromHeadBranch(github, owner, repo, branch) {
|
||||
if (!branch || branch === 'main' || branch === 'master') return null;
|
||||
const { data } = await github.rest.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
head: `${owner}:${branch}`,
|
||||
per_page: 1,
|
||||
});
|
||||
return data[0]?.number || null;
|
||||
}
|
||||
|
||||
async function resolvePrNumberFromWorkflowRun(github, owner, repo, workflowRun) {
|
||||
const fromLinked = await resolvePrNumberFromLinkedPullRequests(
|
||||
github, owner, repo, workflowRun.pull_requests
|
||||
);
|
||||
if (fromLinked) return fromLinked;
|
||||
|
||||
const fromBranch = await resolvePrNumberFromHeadBranch(
|
||||
github, owner, repo, workflowRun.head_branch
|
||||
);
|
||||
if (fromBranch) return fromBranch;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listPullFiles(github, owner, repo, prNumber) {
|
||||
if (typeof github.paginate === 'function') {
|
||||
return github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
const { data } = await github.rest.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
function countNewAgentParams(patch) {
|
||||
if (!patch) return 0;
|
||||
return patch
|
||||
.split('\n')
|
||||
.filter((l) => l.startsWith('+') && !l.startsWith('+++'))
|
||||
.filter((l) => {
|
||||
const t = l.slice(1).trim();
|
||||
if (!t || t.startsWith('#') || t.startsWith('"""') || t.startsWith("'''")) return false;
|
||||
if (/^(def|class|@|return\b|if\b|elif\b|else\b|for\b|while\b)/.test(t)) return false;
|
||||
if (/^\w+\s*[:=]/.test(t) && !t.startsWith('self.')) return true;
|
||||
return false;
|
||||
}).length;
|
||||
}
|
||||
|
||||
function getAgentPyChangeFromFiles(files) {
|
||||
const agentFile = files.find((f) => f.filename.endsWith('praisonaiagents/agent/agent.py'));
|
||||
if (!agentFile) {
|
||||
return { touched: false, additions: 0, newParams: 0 };
|
||||
}
|
||||
return {
|
||||
touched: true,
|
||||
additions: agentFile.additions || 0,
|
||||
newParams: countNewAgentParams(agentFile.patch || ''),
|
||||
};
|
||||
}
|
||||
|
||||
async function getAgentPyChange(github, owner, repo, prNumber) {
|
||||
const files = await listPullFiles(github, owner, repo, prNumber);
|
||||
return getAgentPyChangeFromFiles(files);
|
||||
}
|
||||
|
||||
function touchesSdk(files) {
|
||||
return files.some((f) => SDK_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
|
||||
}
|
||||
|
||||
function hasManualOnlyLabel(labels) {
|
||||
return labels.some((l) => MANUAL_ONLY_LABELS.has(l));
|
||||
}
|
||||
|
||||
function sensitivePathReasons(files, labels = []) {
|
||||
if (labels.includes(WORKFLOW_ONLY_LABEL) && isCiOnlyChange(files)) {
|
||||
return [];
|
||||
}
|
||||
const reasons = [];
|
||||
for (const f of files) {
|
||||
if (SENSITIVE_PATH_PATTERNS.some((p) => p.test(f.filename))) {
|
||||
reasons.push(`sensitive path: ${f.filename}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function prSizeReasons(files) {
|
||||
const reasons = [];
|
||||
const totalAdditions = files.reduce((sum, f) => sum + (f.additions || 0), 0);
|
||||
if (totalAdditions > PR_MAX_AUTO_ADDITIONS) {
|
||||
reasons.push(`PR +${totalAdditions} lines (>${PR_MAX_AUTO_ADDITIONS}) requires manual review`);
|
||||
}
|
||||
if (files.length > PR_MAX_AUTO_FILES) {
|
||||
reasons.push(`${files.length} files changed (>${PR_MAX_AUTO_FILES}) requires manual review`);
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function missingTestsReason(files) {
|
||||
const sdkAdds = files.filter(
|
||||
(f) =>
|
||||
f.filename.startsWith('src/praisonai-agents/') &&
|
||||
f.filename.endsWith('.py') &&
|
||||
!f.filename.endsWith('__init__.py') &&
|
||||
(f.additions || 0) > 0
|
||||
);
|
||||
if (sdkAdds.length === 0) return null;
|
||||
const hasTestChange = files.some(
|
||||
(f) => /\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename) || /_test\.py$/.test(f.filename)
|
||||
);
|
||||
if (!hasTestChange) return 'SDK code added without test file changes — requires manual review';
|
||||
return null;
|
||||
}
|
||||
|
||||
function secretScanReasons(files) {
|
||||
for (const f of files) {
|
||||
if (/\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename)) continue;
|
||||
const patch = f.patch || '';
|
||||
if (!patch) continue;
|
||||
for (const pattern of SECRET_PATTERNS) {
|
||||
if (pattern.test(patch)) {
|
||||
return [`possible secret in diff (${f.filename}) — requires manual review`];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function sdkTestChecksReason(github, owner, repo, sha, files, core) {
|
||||
if (!touchesSdk(files)) return null;
|
||||
const { data } = await github.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
const runs = (data.check_runs || []).filter((r) => r.head_sha === sha);
|
||||
if (runs.length === 0) {
|
||||
return 'SDK code changed but no CI checks on HEAD';
|
||||
}
|
||||
const testRuns = runs.filter((r) => REQUIRED_SDK_CHECK_PATTERNS.some((p) => p.test(r.name || '')));
|
||||
if (testRuns.length === 0) {
|
||||
return 'SDK code changed but no test check runs on HEAD';
|
||||
}
|
||||
core?.info?.(`SDK test checks on HEAD: ${testRuns.map((r) => r.name).join(', ')}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
function manualReviewReasonForAgentPy(agentChange) {
|
||||
if (!agentChange.touched) return null;
|
||||
if (agentChange.additions > AGENT_PY_MAX_AUTO_LINES) {
|
||||
return `agent.py +${agentChange.additions} lines (>${AGENT_PY_MAX_AUTO_LINES}) requires manual review`;
|
||||
}
|
||||
if (agentChange.newParams > 0) {
|
||||
return `agent.py adds ${agentChange.newParams} new Agent param(s) — requires manual review`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listAllComments(github, owner, repo, issueNumber) {
|
||||
if (typeof github.paginate === 'function') {
|
||||
return github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
const { data } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getHeadCommitDate(github, owner, repo, prNumber) {
|
||||
try {
|
||||
let commits;
|
||||
if (typeof github.paginate === 'function') {
|
||||
commits = await github.paginate(github.rest.pulls.listCommits, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
} else {
|
||||
const { data } = await github.rest.pulls.listCommits({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
commits = data;
|
||||
}
|
||||
const last = commits[commits.length - 1];
|
||||
return last?.commit?.committer?.date || last?.commit?.author?.date || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrContext(github, owner, repo, prNumber) {
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: prNumber });
|
||||
const comments = await listAllComments(github, owner, repo, prNumber);
|
||||
const { data: reviews } = await github.rest.pulls.listReviews({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const mergeState = await getMergeState(github, owner, repo, prNumber);
|
||||
const headSha = mergeState.headSha || pr.head.sha;
|
||||
const headCommitDate = await getHeadCommitDate(github, owner, repo, prNumber);
|
||||
const headPushedAt = headCommitDate || pr.updated_at;
|
||||
|
||||
return {
|
||||
pr,
|
||||
issue,
|
||||
comments,
|
||||
reviews,
|
||||
mergeState,
|
||||
headSha,
|
||||
headPushedAt,
|
||||
labels: issue.labels.map((l) => l.name),
|
||||
baseRepo: `${owner}/${repo}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, options = {}) {
|
||||
const {
|
||||
forMergeStep = false,
|
||||
skipGlobalClaudeRunCheck = false,
|
||||
skipRecentClaudeCooldown = false,
|
||||
} = options;
|
||||
const ctx = await loadPrContext(github, owner, repo, prNumber);
|
||||
const reasons = [];
|
||||
|
||||
if (ctx.pr.draft) reasons.push('draft');
|
||||
if (ctx.pr.state !== 'open') reasons.push('not open');
|
||||
if (ctx.labels.includes('auto-merged-by-gate')) reasons.push('already merged by gate');
|
||||
if (ctx.labels.includes('no-auto-merge')) reasons.push('no-auto-merge label');
|
||||
if (ctx.labels.includes('claude-conflict-pending')) reasons.push('claude-conflict-pending');
|
||||
if (!forMergeStep && ctx.labels.includes('claude-merge-gate-active')) {
|
||||
reasons.push('claude-merge-gate-active');
|
||||
}
|
||||
|
||||
const { status, headRepo, maintainerCanModify } = ctx.mergeState;
|
||||
if (!ALLOWED_MERGE_STATES.has(status)) reasons.push(`mergeState=${status}`);
|
||||
|
||||
if (headRepo && headRepo !== ctx.baseRepo) {
|
||||
reasons.push('fork PR');
|
||||
}
|
||||
|
||||
if (hasRecentConflictComment(ctx.comments, ctx.headPushedAt)) {
|
||||
reasons.push('recent merge-conflict @claude');
|
||||
}
|
||||
if (!skipRecentClaudeCooldown && hasRecentClaudeTrigger(ctx.comments, 35)) {
|
||||
reasons.push('recent @claude within 35min');
|
||||
}
|
||||
|
||||
if (!skipGlobalClaudeRunCheck && (await hasInProgressClaudeAssistant(github, owner, repo, prNumber))) {
|
||||
reasons.push('claude.yml in progress');
|
||||
}
|
||||
|
||||
const headTime = new Date(ctx.headPushedAt).getTime();
|
||||
if (Date.now() - headTime < POST_PUSH_BUFFER_MS) reasons.push('post-push buffer (<5min)');
|
||||
|
||||
if (!finalClaudeCompletedOnSha(ctx.comments, ctx.headPushedAt)) {
|
||||
if (!hasFinalClaudeReviewTrigger(ctx.comments)) {
|
||||
reasons.push('no FINAL Claude review trigger');
|
||||
} else if (isStaleFinalAfterPush(ctx.comments, ctx.headPushedAt)) {
|
||||
reasons.push('stale FINAL after new commits (needs @claude re-review)');
|
||||
} else {
|
||||
reasons.push('FINAL Claude not complete on HEAD');
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAnyChangesRequested(ctx.reviews)) reasons.push('CHANGES_REQUESTED review');
|
||||
|
||||
if (hasManualOnlyLabel(ctx.labels)) {
|
||||
const manualLabel = ctx.labels.find((l) => MANUAL_ONLY_LABELS.has(l));
|
||||
reasons.push(`manual-only label: ${manualLabel}`);
|
||||
}
|
||||
|
||||
const pullFiles = await listPullFiles(github, owner, repo, prNumber);
|
||||
|
||||
const agentChange = getAgentPyChangeFromFiles(pullFiles);
|
||||
const agentManual = manualReviewReasonForAgentPy(agentChange);
|
||||
if (agentManual) reasons.push(agentManual);
|
||||
|
||||
reasons.push(...sensitivePathReasons(pullFiles, ctx.labels));
|
||||
reasons.push(...prSizeReasons(pullFiles));
|
||||
reasons.push(...secretScanReasons(pullFiles));
|
||||
|
||||
const testsReason = missingTestsReason(pullFiles);
|
||||
if (testsReason) reasons.push(testsReason);
|
||||
|
||||
const sdkCheckReason = await sdkTestChecksReason(github, owner, repo, ctx.headSha, pullFiles, core);
|
||||
if (sdkCheckReason) reasons.push(sdkCheckReason);
|
||||
|
||||
const checksOk = await allChecksGreenOnSha(github, owner, repo, ctx.headSha, core);
|
||||
if (!checksOk) reasons.push('CI not green on HEAD');
|
||||
|
||||
if (ctx.pr.mergeable === false) reasons.push('not mergeable');
|
||||
|
||||
const ready = reasons.length === 0;
|
||||
return {
|
||||
ready,
|
||||
reasons,
|
||||
headSha: ctx.headSha,
|
||||
prNumber,
|
||||
};
|
||||
}
|
||||
|
||||
async function selectMergeGateCandidates(github, owner, repo, prNumbers, maxCandidates, core) {
|
||||
const readyList = [];
|
||||
const skipped = [];
|
||||
for (const num of prNumbers) {
|
||||
const result = await evaluatePipelineQuiescent(github, owner, repo, num, core);
|
||||
if (result.ready) {
|
||||
readyList.push({ pr_number: num, head_sha: result.headSha });
|
||||
} else {
|
||||
skipped.push({ pr: num, reasons: result.reasons });
|
||||
}
|
||||
}
|
||||
readyList.sort((a, b) => a.pr_number - b.pr_number);
|
||||
return {
|
||||
candidates: readyList.slice(0, maxCandidates),
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
function findMergeGateVerdict(comments, minCreatedAt = null, headPushedAt = null) {
|
||||
const minTime = minCreatedAt ? new Date(minCreatedAt).getTime() : 0;
|
||||
const headTime = headPushedAt ? new Date(headPushedAt).getTime() - 60000 : 0;
|
||||
const gateComments = comments
|
||||
.filter((c) => {
|
||||
if (!(c.body || '').includes('MERGE_GATE_VERDICT:')) return false;
|
||||
const created = new Date(c.created_at).getTime();
|
||||
if (minTime && created < minTime) return false;
|
||||
if (headTime && created < headTime) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
if (gateComments.length === 0) return null;
|
||||
const body = gateComments[0].body || '';
|
||||
if (body.includes('MERGE_GATE_VERDICT: APPROVE')) return 'APPROVE';
|
||||
if (body.includes('MERGE_GATE_VERDICT: BLOCK')) return 'BLOCK';
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CLAUDE_TRIGGER_LOGINS,
|
||||
AUTO_ACTORS,
|
||||
isFinalClaudeTriggerComment,
|
||||
isClaudeTriggerNoise,
|
||||
hasRecentClaudeTrigger,
|
||||
hasRecentConflictComment,
|
||||
isConflictRebaseTriggerComment,
|
||||
isConflictRebaseCompletionComment,
|
||||
conflictRebaseQuiescent,
|
||||
hasHumanChangesRequested,
|
||||
hasAnyChangesRequested,
|
||||
hasFinalClaudeReviewTrigger,
|
||||
finalClaudeCompletedOnSha,
|
||||
getMergeState,
|
||||
allChecksGreenOnSha,
|
||||
hasInProgressClaudeAssistant,
|
||||
claudeRunBlocksPr,
|
||||
hasBlockingClaudeRunForPr,
|
||||
isCiOnlyChange,
|
||||
isInternalPullRequestLink,
|
||||
resolvePrNumberFromLinkedPullRequests,
|
||||
resolvePrNumberFromHeadBranch,
|
||||
resolvePrNumberFromWorkflowRun,
|
||||
WORKFLOW_ONLY_LABEL,
|
||||
getAgentPyChange,
|
||||
getAgentPyChangeFromFiles,
|
||||
manualReviewReasonForAgentPy,
|
||||
countNewAgentParams,
|
||||
listPullFiles,
|
||||
touchesSdk,
|
||||
hasManualOnlyLabel,
|
||||
sensitivePathReasons,
|
||||
prSizeReasons,
|
||||
missingTestsReason,
|
||||
secretScanReasons,
|
||||
sdkTestChecksReason,
|
||||
listAllComments,
|
||||
getHeadCommitDate,
|
||||
isStaleFinalAfterPush,
|
||||
needsStaleFinalRecovery,
|
||||
shouldSkipFinalRecovery,
|
||||
FINAL_CLAUDE_REVIEW_BODY,
|
||||
loadPrContext,
|
||||
evaluatePipelineQuiescent,
|
||||
selectMergeGateCandidates,
|
||||
findMergeGateVerdict,
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run: node .github/scripts/pipeline-status-selftest.js
|
||||
*/
|
||||
const ps = require('./pipeline-status.js');
|
||||
const mg = require('./merge-gate.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
const kickComments = [
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
{ user: { login: 'MervinPraison' }, body: '/review' },
|
||||
];
|
||||
|
||||
const finalComment = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude You are the FINAL architecture reviewer.',
|
||||
created_at: '2026-06-26T10:00:00Z',
|
||||
};
|
||||
|
||||
assert(
|
||||
'reviews pending when not kicked',
|
||||
ps.deriveStage([], { ready: false, reasons: ['no FINAL Claude review trigger'] })
|
||||
=== 'pipeline/reviews-pending'
|
||||
);
|
||||
assert(
|
||||
'final pending after kick',
|
||||
ps.deriveStage(kickComments, { ready: false, reasons: ['no FINAL Claude review trigger'] })
|
||||
=== 'pipeline/final-claude-pending'
|
||||
);
|
||||
assert(
|
||||
'awaiting merge gate after final',
|
||||
ps.deriveStage(
|
||||
[...kickComments, finalComment],
|
||||
{ ready: false, reasons: ['recent @claude within 35min'] }
|
||||
) === 'pipeline/awaiting-merge-gate'
|
||||
);
|
||||
assert(
|
||||
'merge ready',
|
||||
ps.deriveStage([...kickComments, finalComment], { ready: true, reasons: [] })
|
||||
=== 'pipeline/merge-ready'
|
||||
);
|
||||
|
||||
const blockers = ps.deriveBlockerLabels({
|
||||
ready: false,
|
||||
reasons: ['CI not green on HEAD', 'SDK code added without test file changes — requires manual review'],
|
||||
});
|
||||
assert('maps ci blocker', blockers.includes('pipeline/blocked:ci'));
|
||||
assert('maps manual blocker', blockers.includes('pipeline/blocked:manual-review'));
|
||||
|
||||
assert(
|
||||
'internal link matches upstream base',
|
||||
mg.isInternalPullRequestLink(
|
||||
{ base: { repo: { full_name: 'MervinPraison/PraisonAI' } } },
|
||||
'MervinPraison',
|
||||
'PraisonAI'
|
||||
)
|
||||
);
|
||||
assert(
|
||||
'fork sync link rejected',
|
||||
!mg.isInternalPullRequestLink(
|
||||
{ number: 21, base: { repo: { full_name: 'Milkmange/PraisonAI' } } },
|
||||
'MervinPraison',
|
||||
'PraisonAI'
|
||||
)
|
||||
);
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Sync pipeline stage/blocker labels on open PRs.
|
||||
* @see .github/workflows/pipeline-status-sync.yml
|
||||
*/
|
||||
|
||||
const mergeGate = require('./merge-gate.js');
|
||||
const chain = require('./bot-pr-review-chain.js');
|
||||
|
||||
const PIPELINE_PREFIX = 'pipeline/';
|
||||
const STAGE_LABELS = [
|
||||
'pipeline/reviews-pending',
|
||||
'pipeline/final-claude-pending',
|
||||
'pipeline/awaiting-merge-gate',
|
||||
'pipeline/merge-ready',
|
||||
'pipeline/merged',
|
||||
];
|
||||
const BLOCKER_LABELS = [
|
||||
'pipeline/blocked:ci',
|
||||
'pipeline/blocked:conflict',
|
||||
'pipeline/blocked:manual-review',
|
||||
'pipeline/blocked:cooldown',
|
||||
'pipeline/blocked:stale-final',
|
||||
'pipeline/blocked:no-final',
|
||||
];
|
||||
const ALL_PIPELINE_LABELS = [...STAGE_LABELS, ...BLOCKER_LABELS];
|
||||
|
||||
const LABEL_SPECS = [
|
||||
{ name: 'pipeline/reviews-pending', color: 'fbca04', description: 'Waiting for CodeRabbit/Qodo/Copilot reviews' },
|
||||
{ name: 'pipeline/final-claude-pending', color: 'd4c5f9', description: 'Reviews done; waiting for FINAL @claude' },
|
||||
{ name: 'pipeline/awaiting-merge-gate', color: 'c5def5', description: 'FINAL done; waiting for merge gate / CI' },
|
||||
{ name: 'pipeline/merge-ready', color: '0e8a16', description: 'Eligible for merge gate auto-merge' },
|
||||
{ name: 'pipeline/merged', color: '6f42c1', description: 'Merged via pipeline' },
|
||||
{ name: 'pipeline/blocked:ci', color: 'd93f0b', description: 'Blocked: CI not green on HEAD' },
|
||||
{ name: 'pipeline/blocked:conflict', color: 'b60205', description: 'Blocked: merge conflict or rebase pending' },
|
||||
{ name: 'pipeline/blocked:manual-review', color: 'e99695', description: 'Blocked: requires manual review' },
|
||||
{ name: 'pipeline/blocked:cooldown', color: 'fef2c0', description: 'Blocked: post-push or @claude cooldown' },
|
||||
{ name: 'pipeline/blocked:stale-final', color: 'f9d0c4', description: 'Blocked: FINAL stale after new commits' },
|
||||
{ name: 'pipeline/blocked:no-final', color: 'ededed', description: 'Blocked: no FINAL @claude trigger yet' },
|
||||
];
|
||||
|
||||
function deriveStage(comments, evalResult) {
|
||||
if (evalResult.ready) return 'pipeline/merge-ready';
|
||||
if (!chain.chainKickPosted(comments)) return 'pipeline/reviews-pending';
|
||||
if (!mergeGate.hasFinalClaudeReviewTrigger(comments)) return 'pipeline/final-claude-pending';
|
||||
return 'pipeline/awaiting-merge-gate';
|
||||
}
|
||||
|
||||
function reasonToBlockerLabel(reason) {
|
||||
const r = (reason || '').toLowerCase();
|
||||
if (r.includes('ci not green') || r.includes('sdk code changed but no ci')) return 'pipeline/blocked:ci';
|
||||
if (
|
||||
r.includes('conflict') ||
|
||||
r.includes('mergestate=dirty') ||
|
||||
r.includes('not mergeable')
|
||||
) return 'pipeline/blocked:conflict';
|
||||
if (
|
||||
r.includes('manual') ||
|
||||
r.includes('requires manual') ||
|
||||
r.includes('sensitive path') ||
|
||||
r.includes('no-auto-merge') ||
|
||||
r.includes('manual-only label') ||
|
||||
r.includes('without test') ||
|
||||
r.includes('files changed') ||
|
||||
r.includes('possible secret') ||
|
||||
r.includes('agent.py')
|
||||
) return 'pipeline/blocked:manual-review';
|
||||
if (r.includes('recent @claude') || r.includes('post-push buffer')) return 'pipeline/blocked:cooldown';
|
||||
if (r.includes('stale final')) return 'pipeline/blocked:stale-final';
|
||||
if (r.includes('no final claude')) return 'pipeline/blocked:no-final';
|
||||
if (r.includes('final claude not complete')) return 'pipeline/blocked:stale-final';
|
||||
if (r.includes('claude.yml in progress') || r.includes('claude-merge-gate-active')) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveBlockerLabels(evalResult) {
|
||||
if (evalResult.ready) return [];
|
||||
const labels = new Set();
|
||||
for (const reason of evalResult.reasons || []) {
|
||||
const mapped = reasonToBlockerLabel(reason);
|
||||
if (mapped && mapped.startsWith('pipeline/blocked:')) labels.add(mapped);
|
||||
}
|
||||
return [...labels];
|
||||
}
|
||||
|
||||
function computePipelineLabels(comments, evalResult) {
|
||||
const stage = deriveStage(comments, evalResult);
|
||||
const blockers = stage === 'pipeline/merge-ready' ? [] : deriveBlockerLabels(evalResult);
|
||||
return { stage, blockers, all: [stage, ...blockers] };
|
||||
}
|
||||
|
||||
async function ensurePipelineLabels(github, owner, repo, core) {
|
||||
let existing;
|
||||
if (typeof github.paginate === 'function') {
|
||||
existing = await github.paginate(github.rest.issues.listLabelsForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
} else {
|
||||
({ data: existing } = await github.rest.issues.listLabelsForRepo({
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
}));
|
||||
}
|
||||
const names = new Set(existing.map((l) => l.name));
|
||||
for (const spec of LABEL_SPECS) {
|
||||
if (names.has(spec.name)) continue;
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: spec.name,
|
||||
color: spec.color,
|
||||
description: spec.description,
|
||||
});
|
||||
core?.info?.(`Created label ${spec.name}`);
|
||||
} catch (err) {
|
||||
if (err.status !== 422) throw err;
|
||||
core?.info?.(`Label ${spec.name} already exists`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncPipelineLabels(github, owner, repo, prNumber, core) {
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
if (ctx.pr.state !== 'open') return { synced: false, reason: 'not_open' };
|
||||
|
||||
const evalResult = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core
|
||||
);
|
||||
const { stage, blockers, all } = computePipelineLabels(ctx.comments, evalResult);
|
||||
|
||||
const current = ctx.labels.filter((l) => l.startsWith(PIPELINE_PREFIX));
|
||||
const desired = new Set(all);
|
||||
const toRemove = current.filter((l) => !desired.has(l) && l !== 'pipeline/merged');
|
||||
const toAdd = all.filter((l) => !current.includes(l));
|
||||
|
||||
for (const name of toRemove) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name });
|
||||
} catch (err) {
|
||||
if (err.status !== 404) throw err;
|
||||
}
|
||||
}
|
||||
if (toAdd.length) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: toAdd,
|
||||
});
|
||||
}
|
||||
|
||||
core?.info?.(`PR #${prNumber}: stage=${stage} blockers=[${blockers.join(', ')}]`);
|
||||
return { synced: true, stage, blockers, ready: evalResult.ready, reasons: evalResult.reasons };
|
||||
}
|
||||
|
||||
async function syncOpenPullRequests(github, owner, repo, options, core) {
|
||||
const { maxPrs = 20 } = options || {};
|
||||
await ensurePipelineLabels(github, owner, repo, core);
|
||||
let prs;
|
||||
if (maxPrs <= 100) {
|
||||
({ data: prs } = await github.rest.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
}));
|
||||
} else {
|
||||
prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
let synced = 0;
|
||||
for (const pr of prs) {
|
||||
if (synced >= maxPrs) break;
|
||||
if (pr.draft) continue;
|
||||
if (pr.head?.repo?.full_name && pr.head.repo.full_name !== `${owner}/${repo}`) continue;
|
||||
await syncPipelineLabels(github, owner, repo, pr.number, core);
|
||||
synced += 1;
|
||||
}
|
||||
core?.info?.(`Pipeline label sync complete (${synced} PR(s))`);
|
||||
return synced;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STAGE_LABELS,
|
||||
BLOCKER_LABELS,
|
||||
ALL_PIPELINE_LABELS,
|
||||
deriveStage,
|
||||
deriveBlockerLabels,
|
||||
computePipelineLabels,
|
||||
ensurePipelineLabels,
|
||||
syncPipelineLabels,
|
||||
syncOpenPullRequests,
|
||||
};
|
||||
@@ -15,7 +15,10 @@ on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
pull_request:
|
||||
types: [opened]
|
||||
types: [opened, synchronize]
|
||||
schedule:
|
||||
- cron: '30 3,9,15,21 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
MAX_POLL_ATTEMPTS: 20
|
||||
@@ -143,9 +146,12 @@ jobs:
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
const alreadyPosted = comments.data.some(c => {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
});
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
core.setOutput('comments_count', comments.data.length.toString());
|
||||
|
||||
@@ -187,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});
|
||||
@@ -209,6 +216,10 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
@@ -295,9 +306,12 @@ jobs:
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
const alreadyPosted = comments.data.some(c => {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
});
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
@@ -313,10 +327,23 @@ jobs:
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\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)
|
||||
# ================================================================
|
||||
@@ -332,6 +359,10 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
@@ -399,9 +430,12 @@ jobs:
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
const alreadyPosted = comments.data.some(c => {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
});
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
@@ -417,10 +451,23 @@ jobs:
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\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.
|
||||
@@ -453,9 +500,12 @@ jobs:
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
const alreadyPosted = comments.data.some(c => {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
});
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Post Claude analysis request (Lead Engineer)
|
||||
@@ -468,9 +518,127 @@ jobs:
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the Lead Engineer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\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'
|
||||
});
|
||||
|
||||
# ================================================================
|
||||
# Recovery: post FINAL @claude if merge-conflict comment blocked the chain
|
||||
# (e.g. conflict fired before claude-after-copilot could post review prompt)
|
||||
# ================================================================
|
||||
claude-review-recovery:
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.action == 'synchronize'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Re-post FINAL Claude review when stale or missing
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const comments = await mergeGate.listAllComments(github, owner, repo, pr);
|
||||
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr))
|
||||
|| context.payload.pull_request.updated_at;
|
||||
|
||||
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
|
||||
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
|
||||
|
||||
if (mergeGate.shouldSkipFinalRecovery(comments, headPushedAt)) {
|
||||
core.info('Recent @claude within 35min and FINAL not stale — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalBody = mergeGate.FINAL_CLAUDE_REVIEW_BODY;
|
||||
|
||||
if (hasFinal && isStale) {
|
||||
await github.rest.issues.createComment({ issue_number: pr, owner, repo, body: finalBody });
|
||||
core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasFinal) {
|
||||
core.info('FINAL Claude review current on HEAD — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasReviewerActivity = comments.some(c => {
|
||||
const login = c.user.login.toLowerCase();
|
||||
return login.includes('coderabbit') || login.includes('copilot') ||
|
||||
login.includes('greptile') || login.includes('qodo') || login.includes('gemini');
|
||||
});
|
||||
if (!hasReviewerActivity) {
|
||||
core.info('No bot reviewer activity yet, skipping recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({ issue_number: pr, owner, repo, body: finalBody });
|
||||
core.info(`Posted missing FINAL Claude review on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# Scheduled: re-post @claude when FINAL is stale (Claude pushed after last FINAL trigger)
|
||||
stale-final-recovery-scheduled:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Re-post stale FINAL @claude on open internal PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: 'open', per_page: 100,
|
||||
});
|
||||
let posted = 0;
|
||||
for (const pr of prs) {
|
||||
if (pr.draft) continue;
|
||||
if (pr.head?.repo?.full_name && pr.head.repo.full_name !== `${owner}/${repo}`) continue;
|
||||
const comments = await mergeGate.listAllComments(github, owner, repo, pr.number);
|
||||
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr.number)) || pr.updated_at;
|
||||
if (!mergeGate.needsStaleFinalRecovery(comments, headPushedAt)) continue;
|
||||
if (mergeGate.hasRecentClaudeTrigger(comments, 10)) {
|
||||
core.info(`PR #${pr.number}: stale FINAL but @claude within 10min — skip`);
|
||||
continue;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr.number, owner, repo,
|
||||
body: mergeGate.FINAL_CLAUDE_REVIEW_BODY,
|
||||
});
|
||||
posted++;
|
||||
core.info(`Posted scheduled stale-FINAL recovery on PR #${pr.number}`);
|
||||
if (posted >= 5) break;
|
||||
}
|
||||
core.info(`Scheduled stale-FINAL recovery complete (${posted} posted)`);
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: CodeRabbit/Qodo skip bot-authored PRs by default.
|
||||
# Trigger them explicitly. Do NOT trigger Copilot here — it will
|
||||
@@ -488,38 +656,29 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Trigger CodeRabbit and Qodo reviews
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
await chain.kickReviewChain(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
// 1. Trigger CodeRabbit review
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '@coderabbitai review'
|
||||
});
|
||||
console.log(`Triggered CodeRabbit for bot PR #${pr}`);
|
||||
|
||||
// 2. Trigger Qodo review
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '/review'
|
||||
});
|
||||
console.log(`Triggered Qodo for bot PR #${pr}`);
|
||||
|
||||
// 3. Trigger Gemini review (commented out)
|
||||
// await github.rest.issues.createComment({
|
||||
// issue_number: pr, owner, repo,
|
||||
// body: '@gemini review this PR'
|
||||
// });
|
||||
// console.log(`Triggered Gemini for bot PR #${pr}`);
|
||||
|
||||
// NOTE: @copilot is NOT triggered here.
|
||||
// It will be triggered by copilot-after-coderabbit or copilot-after-qodo
|
||||
// once those bots finish, ensuring Copilot always reviews LAST.
|
||||
console.log('Copilot will be triggered after CodeRabbit/Qodo complete.');
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Bot PR Recovery
|
||||
|
||||
# Belt-and-braces: kick CodeRabbit/Qodo when pull_request:opened never fired for bot PRs.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Specific PR number to kick (optional)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Find and kick stalled bot PRs
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER_INPUT: ${{ github.event.inputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const manual = (process.env.PR_NUMBER_INPUT || '').trim();
|
||||
const prNumber = manual ? parseInt(manual, 10) : null;
|
||||
if (manual && Number.isNaN(prNumber)) {
|
||||
throw new Error(`Invalid pr_number: ${manual}`);
|
||||
}
|
||||
await chain.recoverStalledBotPrs(github, owner, repo, { prNumber }, core);
|
||||
@@ -0,0 +1,474 @@
|
||||
name: Claude PR Merge Gate
|
||||
|
||||
# Read-only Claude assesses merge readiness; GH_TOKEN merges (no @claude, no gh pr review).
|
||||
# Does not overlap with merge-conflict-claude.yml or auto-pr-comment @claude chains.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2,8,14,20 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Optional PR number (scan all open internal PRs if empty)'
|
||||
required: false
|
||||
type: string
|
||||
merge_method:
|
||||
description: 'Merge method'
|
||||
required: false
|
||||
type: choice
|
||||
default: merge
|
||||
options:
|
||||
- merge
|
||||
- squash
|
||||
- rebase
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Claude Assistant
|
||||
- Core Tests
|
||||
types:
|
||||
- completed
|
||||
repository_dispatch:
|
||||
types:
|
||||
- claude-merge-gate
|
||||
|
||||
concurrency:
|
||||
group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || github.event.workflow_run.id || 'scan' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
MAX_CANDIDATES: 5
|
||||
DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
auto-dispatch:
|
||||
if: github.event_name == 'workflow_run'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Dispatch merge gate when PR pipeline is ready
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PAT: ${{ secrets.GH_TOKEN }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const wr = context.payload.workflow_run;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
if (!process.env.PAT) {
|
||||
core.setFailed('GH_TOKEN secret is required for merge gate auto-dispatch (needs repo + workflow scope).');
|
||||
return;
|
||||
}
|
||||
|
||||
if (wr.conclusion !== 'success') {
|
||||
core.info(`Skip dispatch: ${wr.name} conclusion=${wr.conclusion}`);
|
||||
return;
|
||||
}
|
||||
const allowed = ['Claude Assistant', 'Core Tests'];
|
||||
if (!allowed.includes(wr.name)) {
|
||||
core.info(`Skip dispatch: workflow ${wr.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun(github, owner, repo, wr);
|
||||
if (!prNumber) {
|
||||
core.info('Skip dispatch: could not resolve PR from workflow run');
|
||||
return;
|
||||
}
|
||||
|
||||
const fromClaude = wr.name === 'Claude Assistant';
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, {
|
||||
skipGlobalClaudeRunCheck: fromClaude,
|
||||
skipRecentClaudeCooldown: fromClaude,
|
||||
}
|
||||
);
|
||||
if (!check.ready) {
|
||||
core.info(`PR #${prNumber} not ready: ${check.reasons.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
event_type: 'claude-merge-gate',
|
||||
client_payload: { pr_number: prNumber },
|
||||
});
|
||||
core.info(`Dispatched merge gate for PR #${prNumber} after ${wr.name}`);
|
||||
|
||||
scan-candidates:
|
||||
if: github.event_name != 'workflow_run'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
actions: read
|
||||
outputs:
|
||||
matrix: ${{ steps.scan.outputs.matrix }}
|
||||
has_candidates: ${{ steps.scan.outputs.has_candidates }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Scan open PRs for merge gate eligibility
|
||||
id: scan
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const maxCandidates = parseInt(process.env.MAX_CANDIDATES || '5', 10);
|
||||
const inputPr = String(process.env.DISPATCH_PR_NUMBER || '').trim();
|
||||
|
||||
const candidates = [];
|
||||
const skipped = [];
|
||||
|
||||
if (inputPr) {
|
||||
const n = parseInt(inputPr, 10);
|
||||
if (Number.isNaN(n)) {
|
||||
throw new Error(`Invalid pr_number input: ${inputPr}`);
|
||||
}
|
||||
const result = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, n, core);
|
||||
if (result.ready) {
|
||||
candidates.push({ pr_number: n, head_sha: result.headSha });
|
||||
} else {
|
||||
skipped.push({ pr: n, reasons: result.reasons });
|
||||
}
|
||||
} else {
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: 'open', per_page: 100, sort: 'created', direction: 'asc',
|
||||
});
|
||||
const prNumbers = prs.map((p) => p.number);
|
||||
const selected = await mergeGate.selectMergeGateCandidates(
|
||||
github, owner, repo, prNumbers, maxCandidates, core
|
||||
);
|
||||
candidates.push(...selected.candidates);
|
||||
skipped.push(...selected.skipped);
|
||||
}
|
||||
|
||||
core.info('Skipped: ' + JSON.stringify(skipped));
|
||||
core.setOutput('matrix', JSON.stringify({ include: candidates }));
|
||||
core.setOutput('has_candidates', candidates.length > 0 ? 'true' : 'false');
|
||||
|
||||
core.summary.addRaw('## Merge gate scan\n');
|
||||
core.summary.addRaw(`Candidates: ${candidates.length}\n`);
|
||||
core.summary.addRaw('```json\n' + JSON.stringify({ candidates, skipped }, null, 2) + '\n```');
|
||||
await core.summary.write();
|
||||
|
||||
if (inputPr && skipped.length === 1 && skipped[0].pr === parseInt(inputPr, 10)) {
|
||||
const reasons = skipped[0].reasons;
|
||||
const body = [
|
||||
'**Merge gate scan** — not eligible for auto-merge.',
|
||||
'',
|
||||
...reasons.map((r) => `- \`${r}\``),
|
||||
'',
|
||||
'**Actions:** wait for CI and the Claude review chain, or add label `needs-manual-review` and merge manually.',
|
||||
'**Opt out:** label `no-auto-merge`.',
|
||||
].join('\n');
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: skipped[0].pr, body,
|
||||
});
|
||||
} else if (inputPr && candidates.length === 1) {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: candidates[0].pr_number,
|
||||
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
|
||||
});
|
||||
}
|
||||
|
||||
await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 20 }, core);
|
||||
|
||||
claude-assess:
|
||||
needs: scan-candidates
|
||||
if: needs.scan-candidates.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Mark merge gate active
|
||||
id: mark_active
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
core.setOutput('started_at', new Date().toISOString());
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: ${{ matrix.pr_number }},
|
||||
labels: ['claude-merge-gate-active'],
|
||||
});
|
||||
|
||||
- name: Stash merge gate helpers
|
||||
run: cp .github/scripts/merge-gate.js /tmp/merge-gate.js
|
||||
|
||||
- name: Fetch PR branch
|
||||
env:
|
||||
PR_NUMBER: ${{ matrix.pr_number }}
|
||||
run: |
|
||||
git fetch origin "pull/${PR_NUMBER}/head:pr-${PR_NUMBER}"
|
||||
git checkout "pr-${PR_NUMBER}"
|
||||
|
||||
- name: Claude merge gate assessment (read-only)
|
||||
id: assess
|
||||
continue-on-error: true
|
||||
uses: ./.github/actions/claude-code-action
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: inherit
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
model: claude-opus-4-8
|
||||
trigger_phrase: '@claude-merge-gate-internal'
|
||||
direct_prompt: |
|
||||
You are the MERGE GATE reviewer for PraisonAI PR #${{ matrix.pr_number }}.
|
||||
READ-ONLY: do not modify code, commit, push, rebase, or create branches.
|
||||
|
||||
CRITICAL OUTPUT RULE:
|
||||
Post exactly one PR comment via GitHub MCP containing either:
|
||||
MERGE_GATE_VERDICT: APPROVE
|
||||
or
|
||||
MERGE_GATE_VERDICT: BLOCK
|
||||
followed by a short rationale on the next lines.
|
||||
|
||||
NEVER include @claude in any comment. NEVER mention merge conflict resolution.
|
||||
|
||||
Review checklist:
|
||||
1. Read AGENTS.md and the PR diff on the current branch.
|
||||
2. Read all PR comments and reviews (FINAL architecture reviewer / Lead Engineer must have run).
|
||||
3. Confirm SDK value gate: change strengthens praisonaiagents (no scope creep).
|
||||
4. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
5. If agent.py adds >100 lines or new Agent __init__ params, respond MERGE_GATE_VERDICT: BLOCK (manual review required).
|
||||
6. BLOCK if labels security/breaking-change/needs-manual-review/release, sensitive paths (.github/workflows, auth, pyproject.toml), secrets in diff, PR >800 lines or >30 files, or SDK changes without tests. Label `merge-gate-ci-only` exempts CI-only changes under `.github/workflows/`, `.github/actions/`, or merge-gate scripts.
|
||||
7. Confirm no CHANGES_REQUESTED reviews and CI green on HEAD ${{ matrix.head_sha }}.
|
||||
8. If ready to merge: MERGE_GATE_VERDICT: APPROVE. Otherwise: MERGE_GATE_VERDICT: BLOCK with reasons.
|
||||
|
||||
Do not approve or merge via GitHub UI/API — only post the verdict comment.
|
||||
disallowed_tools: |
|
||||
Bash
|
||||
Edit
|
||||
Write
|
||||
Replace
|
||||
allowed_tools: |
|
||||
View
|
||||
GlobTool
|
||||
GrepTool
|
||||
Read
|
||||
timeout_minutes: 15
|
||||
|
||||
- name: Post fallback verdict if Claude did not comment
|
||||
id: fallback_verdict
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate.js');
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const existing = mergeGate.findMergeGateVerdict(ctx.comments, minCreatedAt, ctx.headPushedAt);
|
||||
if (existing) {
|
||||
core.info(`Verdict already posted: ${existing}`);
|
||||
core.setOutput('verdict', existing);
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
const verdict = check.ready ? 'APPROVE' : 'BLOCK';
|
||||
const lines = [
|
||||
`MERGE_GATE_VERDICT: ${verdict}`,
|
||||
'',
|
||||
'Automated fallback — Claude assess did not post a verdict comment (e.g. GitHub MCP unavailable).',
|
||||
];
|
||||
if (check.ready) {
|
||||
lines.push('All deterministic merge gates passed.');
|
||||
} else {
|
||||
lines.push('Blockers:', ...check.reasons.map((r) => `- ${r}`));
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber,
|
||||
body: lines.join('\n'),
|
||||
});
|
||||
core.info(`Posted fallback verdict: ${verdict}`);
|
||||
core.setOutput('verdict', verdict);
|
||||
|
||||
- name: Verify verdict posted
|
||||
id: verdict
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate.js');
|
||||
const comments = await mergeGate.listAllComments(
|
||||
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
|
||||
);
|
||||
const ctx = await mergeGate.loadPrContext(
|
||||
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
|
||||
);
|
||||
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const verdict = mergeGate.findMergeGateVerdict(comments, minCreatedAt, ctx.headPushedAt);
|
||||
core.setOutput('verdict', verdict || 'NONE');
|
||||
if (!verdict) core.warning('No recent MERGE_GATE_VERDICT comment found after assessment');
|
||||
|
||||
- name: Remove merge gate active label
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
name: 'claude-merge-gate-active',
|
||||
});
|
||||
core.info(`Removed claude-merge-gate-active from PR #${prNumber}`);
|
||||
} catch (e) {
|
||||
core.info(`Label claude-merge-gate-active already removed or missing on PR #${prNumber}`);
|
||||
}
|
||||
|
||||
- name: Fail job if assessment failed
|
||||
if: steps.assess.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
merge-only:
|
||||
needs: [scan-candidates, claude-assess]
|
||||
if: |
|
||||
always() &&
|
||||
needs.scan-candidates.outputs.has_candidates == 'true' &&
|
||||
needs.claude-assess.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Re-check gates and merge
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
MERGE_METHOD: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
SCAN_HEAD_SHA: ${{ matrix.head_sha }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
const scanHeadSha = process.env.SCAN_HEAD_SHA;
|
||||
|
||||
const removeLabels = async () => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: prNumber, name: 'claude-merge-gate-active',
|
||||
});
|
||||
} catch (e) {
|
||||
core.info('Label claude-merge-gate-active already removed or missing');
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const comments = ctx.comments;
|
||||
const verdictMin = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const verdict = mergeGate.findMergeGateVerdict(comments, verdictMin, ctx.headPushedAt);
|
||||
if (verdict !== 'APPROVE') {
|
||||
core.info(`Skip merge: verdict=${verdict || 'NONE'}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
if (!check.ready) {
|
||||
core.info(`Skip merge: ${check.reasons.join(', ')}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
if (check.headSha !== scanHeadSha) {
|
||||
core.info(`Skip merge: HEAD moved ${scanHeadSha.slice(0, 7)} → ${check.headSha.slice(0, 7)}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
const method = process.env.MERGE_METHOD || 'merge';
|
||||
await github.rest.pulls.merge({
|
||||
owner, repo, pull_number: prNumber, merge_method: method,
|
||||
});
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: prNumber, labels: ['auto-merged-by-gate'],
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber,
|
||||
body: [
|
||||
'Merged by **Claude PR merge gate** (`claude-merge-gate.yml`).',
|
||||
`Verdict: MERGE_GATE_VERDICT: APPROVE`,
|
||||
`SHA: \`${check.headSha.slice(0, 7)}\``,
|
||||
`Method: ${method}`,
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
core.info(`Merged PR #${prNumber}`);
|
||||
} finally {
|
||||
await removeLabels();
|
||||
}
|
||||
@@ -123,6 +123,8 @@ jobs:
|
||||
github.event.action != 'opened' &&
|
||||
(github.event.action != 'labeled' || github.event.label.name == 'claude') &&
|
||||
(github.event_name != 'issue_comment' || contains(github.event.comment.body, '@claude')) &&
|
||||
(github.event_name != 'pull_request_review' || contains(github.event.review.body, '@claude')) &&
|
||||
(github.event_name != 'pull_request_review_comment' || contains(github.event.comment.body, '@claude')) &&
|
||||
(
|
||||
!contains(github.actor, '[bot]') ||
|
||||
github.actor == 'github-actions[bot]' ||
|
||||
@@ -161,6 +163,7 @@ jobs:
|
||||
if (!isPR) {
|
||||
core.setOutput('is_pr', 'false');
|
||||
core.setOutput('is_fork', 'false');
|
||||
core.setOutput('can_push_fork', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -198,13 +201,19 @@ jobs:
|
||||
core.setOutput('merge_state', mergeState);
|
||||
|
||||
const baseRepo = `${context.repo.owner}/${context.repo.repo}`;
|
||||
const maintainerCanModify = pr.data.maintainer_can_modify === true;
|
||||
core.setOutput('maintainer_can_modify', String(maintainerCanModify));
|
||||
|
||||
if (pr.data.head.repo && pr.data.head.repo.full_name !== baseRepo) {
|
||||
core.setOutput('is_fork', 'true');
|
||||
core.setOutput('can_push_fork', String(maintainerCanModify));
|
||||
core.setOutput('fork_repo', pr.data.head.repo.full_name);
|
||||
core.setOutput('clone_url', pr.data.head.repo.clone_url);
|
||||
core.setOutput('branch', pr.data.head.ref);
|
||||
return;
|
||||
}
|
||||
core.setOutput('is_fork', 'false');
|
||||
core.setOutput('can_push_fork', 'false');
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -219,19 +228,24 @@ jobs:
|
||||
PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }}
|
||||
PR_NUMBER: ${{ steps.check_fork.outputs.pr_number }}
|
||||
IS_FORK: ${{ steps.check_fork.outputs.is_fork }}
|
||||
CAN_PUSH_FORK: ${{ steps.check_fork.outputs.can_push_fork }}
|
||||
FORK_REPO: ${{ steps.check_fork.outputs.fork_repo }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
git fetch origin "pull/${PR_NUMBER}/head:${PR_BRANCH}"
|
||||
git checkout "${PR_BRANCH}"
|
||||
git fetch origin main
|
||||
|
||||
# If it's a fork, make origin point to local to trick claude-code-action's `git fetch origin <branch>`
|
||||
if [ "${IS_FORK}" = "true" ]; then
|
||||
if [ "${IS_FORK}" = "true" ] && [ "${CAN_PUSH_FORK}" = "true" ]; then
|
||||
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${FORK_REPO}.git"
|
||||
elif [ "${IS_FORK}" = "true" ]; then
|
||||
# Block push: trick claude-code-action's `git fetch origin <branch>`
|
||||
git remote set-url origin "file://$(pwd)"
|
||||
fi
|
||||
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GH_TOKEN: ${{ steps.check_fork.outputs.can_push_fork == 'true' && secrets.GH_TOKEN || steps.app-token.outputs.token }}
|
||||
with:
|
||||
allowed_bots: 'praisonai-triage-agent[bot]'
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
@@ -241,8 +255,8 @@ jobs:
|
||||
label_trigger: "claude"
|
||||
|
||||
direct_prompt: |
|
||||
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: THIS IS A PULL REQUEST FROM A FORK. YOU DO NOT HAVE PUSH PERMISSIONS TO THIS REPOSITORY. ONLY READ FILES, VALIDATE CODE, AND PROVIDE YOUR COMMENTS/FEEDBACK DIRECTLY IN THIS PR VIA COMMENTS. DO NOT ATTEMPT TO PUSH OR CREATE PULL REQUESTS.\n\n' || (steps.check_fork.outputs.is_pr == 'true' && 'NOTE: The branch is under MervinPraison/PraisonAI (not a fork). You are able to make modifications and push directly to this branch.\n\n' || 'NOTE: This is an issue (not a PR). Create a new fix branch per STEP 3 below.\n\n') }}
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && format('CONTEXT: PR #{0} on branch {1}. head.repo={2}, is_fork={3}, merge_state={4}. NEVER treat cursor/*, claude/* branch names or bot authors as forks — only is_fork=true means fork.\n\n', steps.check_fork.outputs.pr_number, steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.head_repo, steps.check_fork.outputs.is_fork, steps.check_fork.outputs.merge_state) || '' }}
|
||||
${{ steps.check_fork.outputs.is_fork == 'true' && steps.check_fork.outputs.can_push_fork != 'true' && 'CRITICAL: THIS IS A PULL REQUEST FROM A FORK WITHOUT MAINTAINER EDITS. YOU DO NOT HAVE PUSH PERMISSIONS. ONLY READ FILES, VALIDATE CODE, AND PROVIDE COMMENTS/FEEDBACK ON THIS PR. DO NOT ATTEMPT TO PUSH OR CREATE PULL REQUESTS.\n\n' || (steps.check_fork.outputs.can_push_fork == 'true' && format('NOTE: Fork PR with maintainer edits allowed. Push directly to fork {0} branch {1}.\n\n', steps.check_fork.outputs.fork_repo, steps.check_fork.outputs.pr_branch) || (steps.check_fork.outputs.is_pr == 'true' && steps.check_fork.outputs.is_fork != 'true' && 'NOTE: The branch is under MervinPraison/PraisonAI (not a fork). You are able to make modifications and push directly to this branch.\n\n' || (steps.check_fork.outputs.is_pr != 'true' && 'NOTE: This is an issue (not a PR). Create a new fix branch per STEP 3 below.\n\n' || ''))) }}
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && format('CONTEXT: PR #{0} on branch {1}. head.repo={2}, is_fork={3}, can_push_fork={4}, merge_state={5}. NEVER treat cursor/*, claude/* branch names or bot authors as forks — only is_fork=true means fork.\n\n', steps.check_fork.outputs.pr_number, steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.head_repo, steps.check_fork.outputs.is_fork, steps.check_fork.outputs.can_push_fork, steps.check_fork.outputs.merge_state) || '' }}
|
||||
You are working on the PraisonAI SDK. Follow AGENTS.md strictly.
|
||||
|
||||
STEP 0 — SETUP GIT IDENTITY & AUTH (GLOBAL — required for all repos):
|
||||
@@ -265,14 +279,15 @@ jobs:
|
||||
5. Plugins (PraisonAI-Plugins): Framework lifecycle extensions replacing systemic behavior (e.g. tracing, logging, metrics, hooks, guardrails).
|
||||
For items routed to an EXTERNAL repository (Tools, Docs, Plugins), follow STEP 3-ALT below.
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
|
||||
- Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
|
||||
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead. PRs with >100 lines added to agent.py require manual review (merge gate will not auto-merge).
|
||||
- Is there a name collision with existing exports in __init__.py?
|
||||
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
|
||||
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && steps.check_fork.outputs.is_fork != 'true' && steps.check_fork.outputs.merge_state == 'DIRTY' && format('STEP 3 — RESOLVE MERGE CONFLICTS ON PR (branch: {0}, PR #{1}):\n- DO NOT create a new branch or PR\n- git fetch origin main && git rebase origin/main\n- Resolve conflicts: keep this PR intent, merge in main newer logic\n- git add -A && git rebase --continue (repeat until done)\n- git push --force-with-lease origin {0}\n- Run targeted pytest on changed paths\n- Comment files resolved and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number) || '' }}
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && steps.check_fork.outputs.is_fork != 'true' && steps.check_fork.outputs.merge_state != 'DIRTY' && format('STEP 3 — IMPLEMENT ON EXISTING PR (branch: {0}, PR #{1}):\n- DO NOT create a new branch or PR\n- git checkout {0}\n- Implement minimal, focused fixes from reviewer feedback\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description>"\n- Push: git push origin {0}\n- Comment a summary of exact files modified and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number) || '' }}
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && (steps.check_fork.outputs.is_fork != 'true' || steps.check_fork.outputs.can_push_fork == 'true') && steps.check_fork.outputs.merge_state == 'DIRTY' && format('STEP 3 — RESOLVE MERGE CONFLICTS ON PR (branch: {0}, PR #{1}{2}):\n- DO NOT create a new branch or PR\n- git fetch origin main && git rebase origin/main\n- Resolve conflicts: keep this PR intent, merge in main newer logic\n- git add -A && git rebase --continue (repeat until done)\n- git push --force-with-lease origin {0}\n- Run targeted pytest on changed paths\n- Comment files resolved and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number, steps.check_fork.outputs.can_push_fork == 'true' && format(', fork: {0}', steps.check_fork.outputs.fork_repo) || '') || '' }}
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && (steps.check_fork.outputs.is_fork != 'true' || steps.check_fork.outputs.can_push_fork == 'true') && steps.check_fork.outputs.merge_state != 'DIRTY' && format('STEP 3 — IMPLEMENT ON EXISTING PR (branch: {0}, PR #{1}{2}):\n- DO NOT create a new branch or PR\n- git checkout {0}\n- Implement minimal, focused fixes from reviewer feedback\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description>"\n- Push: git push origin {0}\n- Comment a summary of exact files modified and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number, steps.check_fork.outputs.can_push_fork == 'true' && format(', fork: {0}', steps.check_fork.outputs.fork_repo) || '') || '' }}
|
||||
${{ steps.check_fork.outputs.is_pr != 'true' && 'STEP 3 — IMPLEMENT (for changes in THIS repo — PraisonAI):\n- Create a fix branch: git checkout -b claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\n- Implement a minimal, focused fix\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description> (fixes #$ISSUE_NUMBER)"\n- Push: git push origin claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\nCRITICAL: After pushing, you MUST immediately run: gh pr create --title "fix: <title>" --body "Fixes #$ISSUE_NUMBER" --head claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\nDo NOT stop after pushing. Do NOT say "manually create a PR". Always create the PR automatically.\n' || '' }}
|
||||
|
||||
STEP 3-ALT — IMPLEMENT IN EXTERNAL REPO (for PraisonAI-Tools, PraisonAIDocs, PraisonAI-Plugins):
|
||||
@@ -321,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));
|
||||
@@ -54,6 +54,7 @@ jobs:
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
mergeStateStatus
|
||||
maintainerCanModify
|
||||
isDraft
|
||||
headRef { repository { nameWithOwner } }
|
||||
}
|
||||
@@ -69,6 +70,7 @@ jobs:
|
||||
status,
|
||||
isDraft: prGql.isDraft,
|
||||
headRepo: prGql.headRef?.repository?.nameWithOwner,
|
||||
maintainerCanModify: prGql.maintainerCanModify === true,
|
||||
};
|
||||
}
|
||||
if (attempt < 2) await sleep(10000);
|
||||
@@ -78,9 +80,20 @@ jobs:
|
||||
status: (pr.data.mergeable_state || '').toUpperCase(),
|
||||
isDraft: pr.data.draft,
|
||||
headRepo: pr.data.head.repo?.full_name,
|
||||
maintainerCanModify: pr.data.maintainer_can_modify === true,
|
||||
};
|
||||
}
|
||||
|
||||
function hasFinalClaudeReviewTrigger(comments) {
|
||||
return comments.some((c) => {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!AUTO_ACTORS.includes(c.user.login)) return false;
|
||||
if (!body.includes('@claude')) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
});
|
||||
}
|
||||
|
||||
function hasRecentAutoComment(comments) {
|
||||
const cutoff = Date.now() - COOLDOWN_MS;
|
||||
return comments.some((c) => {
|
||||
@@ -92,8 +105,8 @@ jobs:
|
||||
}
|
||||
|
||||
async function processPR(prNumber) {
|
||||
const { status, isDraft, headRepo } = await getMergeState(prNumber);
|
||||
core.info(`PR #${prNumber}: mergeState=${status}, head=${headRepo}, draft=${isDraft}`);
|
||||
const { status, isDraft, headRepo, maintainerCanModify } = await getMergeState(prNumber);
|
||||
core.info(`PR #${prNumber}: mergeState=${status}, head=${headRepo}, draft=${isDraft}, maintainerCanModify=${maintainerCanModify}`);
|
||||
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner,
|
||||
@@ -114,7 +127,12 @@ jobs:
|
||||
|
||||
if (status !== 'DIRTY') return { skipped: true, reason: 'not dirty' };
|
||||
if (isDraft) return { skipped: true, reason: 'draft' };
|
||||
if (headRepo && headRepo !== baseRepo) return { skipped: true, reason: 'fork' };
|
||||
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,
|
||||
@@ -122,24 +140,26 @@ jobs:
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
if (!isMainPush && hasRecentAutoComment(comments)) {
|
||||
if (hasRecentAutoComment(comments)) {
|
||||
return { skipped: true, reason: 'recent auto comment' };
|
||||
}
|
||||
|
||||
if (!hasFinalClaudeReviewTrigger(comments)) {
|
||||
return { skipped: true, reason: 'awaiting final claude review trigger' };
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: [LABEL],
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: COMMENT_BODY,
|
||||
});
|
||||
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>
|
||||
@@ -479,11 +483,16 @@ Methods:
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">activate</a>(skill: LoadedSkill) -> bool</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">activate_by_name</a>(name: str) -> bool</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">add_skill</a>(skill_path: str) -> Optional[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">approve</a>(identifier: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">archive_skill</a>(name: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">clear</a>() -> None</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">create_skill</a>(name: str, content: str, category: str = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete_skill</a>(name: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">create</a>(name: str, content: str, category: Optional[str] = None, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">create_skill</a>(name: str, content: str, category: str = None, agent_created: bool = True, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete</a>(name: str, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete_skill</a>(name: str, hard: bool = False, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">discover</a>(skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> int</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit_skill</a>(name: str, content: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit</a>(name: str, content: str, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit_skill</a>(name: str, content: str, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_allowed_tools</a>(name: str) -> List[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills</a>() -> List[SkillMetadata]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills_by_state</a>(state: SkillState) -> List[LoadedSkill]</code>
|
||||
@@ -492,15 +501,24 @@ Methods:
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skills_diagnostics</a>() -> Dict[str, ValidationResult]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_user_invocable_skills</a>() -> List[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">invoke</a>(name: str, raw_args: str = '', session_id: Optional[str] = None, shell_exec: bool = False) -> Optional[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">list_archived_skills</a>() -> List[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">list_pending</a>() -> List[dict]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">load_resources</a>(name: str) -> bool</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch_skill</a>(name: str, old_string: str, new_string: str, file_path: str = None, replace_all: bool = False) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_skill_file</a>(name: str, file_path: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch</a>(name: str, old_string: str, new_string: str, file_path: Optional[str] = None, replace_all: bool = False, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch_skill</a>(name: str, old_string: str, new_string: str, file_path: str = None, replace_all: bool = False, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">reject</a>(identifier: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_file</a>(name: str, file_path: str, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_skill_file</a>(name: str, file_path: str, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">restore_skill</a>(name: str, skill_dir: Optional[str] = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">rollback_skill</a>(name: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skill_names</a>() -> List[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skills</a>() -> List[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">to_prompt</a>() -> str</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">validate_skill_capabilities</a>(skill_name: str, force_refresh: bool = False) -> ValidationResult</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_skill_file</a>(name: str, file_path: str, file_content: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_file</a>(name: str, file_path: str, file_content: str, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_skill_file</a>(name: str, file_path: str, file_content: str, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillMetadata">SkillMetadata.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">from_properties</a>(props: SkillProperties) -> 'SkillMetadata'</code>
|
||||
* <code title="class SkillProperties">SkillProperties.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">idle_days</a>() -> Optional[float]</code>
|
||||
* <code title="class SkillProperties">SkillProperties.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">to_dict</a>() -> dict</code>
|
||||
* <code title="class SkillsConfig">SkillsConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
|
||||
@@ -801,6 +819,7 @@ Methods:
|
||||
* <code title="cli">praisonai agents info <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
|
||||
* <code title="cli">praisonai agents list <a href="./src/praisonai/praisonai/cli/commands/agents.py">--help</a></code>
|
||||
* <code title="cli">praisonai app app <a href="./src/praisonai/praisonai/cli/commands/app.py">--help</a></code>
|
||||
* <code title="cli">praisonai attach attach-main <a href="./src/praisonai/praisonai/cli/commands/attach.py">--help</a></code>
|
||||
* <code title="cli">praisonai audit agent-centric <a href="./src/praisonai/praisonai/cli/commands/audit.py">--help</a></code>
|
||||
* <code title="cli">praisonai auth list <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
|
||||
* <code title="cli">praisonai auth login <a href="./src/praisonai/praisonai/cli/commands/auth.py">--help</a></code>
|
||||
@@ -843,6 +862,11 @@ Methods:
|
||||
* <code title="cli">praisonai call call-main <a href="./src/praisonai/praisonai/cli/commands/call.py">--help</a></code>
|
||||
* <code title="cli">praisonai chat <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
|
||||
* <code title="cli">praisonai chat chat-main <a href="./src/praisonai/praisonai/cli/commands/chat.py">--help</a></code>
|
||||
* <code title="cli">praisonai checkpoint delete <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
|
||||
* <code title="cli">praisonai checkpoint diff <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
|
||||
* <code title="cli">praisonai checkpoint list <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
|
||||
* <code title="cli">praisonai checkpoint restore <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
|
||||
* <code title="cli">praisonai checkpoint save <a href="./src/praisonai/praisonai/cli/commands/checkpoint.py">--help</a></code>
|
||||
* <code title="cli">praisonai claw claw <a href="./src/praisonai/praisonai/cli/commands/claw.py">--help</a></code>
|
||||
* <code title="cli">praisonai code <a href="./src/praisonai/praisonai/cli/main.py">--help</a></code>
|
||||
* <code title="cli">praisonai code code-main <a href="./src/praisonai/praisonai/cli/commands/code.py">--help</a></code>
|
||||
@@ -872,6 +896,10 @@ Methods:
|
||||
* <code title="cli">praisonai context show <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai context stats <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai context tail <a href="./src/praisonai/praisonai/cli/commands/context.py">--help</a></code>
|
||||
* <code title="cli">praisonai daemon daemon-main <a href="./src/praisonai/praisonai/cli/commands/daemon.py">--help</a></code>
|
||||
* <code title="cli">praisonai daemon start <a href="./src/praisonai/praisonai/cli/commands/daemon.py">--help</a></code>
|
||||
* <code title="cli">praisonai daemon status <a href="./src/praisonai/praisonai/cli/commands/daemon.py">--help</a></code>
|
||||
* <code title="cli">praisonai daemon stop <a href="./src/praisonai/praisonai/cli/commands/daemon.py">--help</a></code>
|
||||
* <code title="cli">praisonai dashboard unified <a href="./src/praisonai/praisonai/cli/commands/dashboard.py">--help</a></code>
|
||||
* <code title="cli">praisonai debug acp <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
|
||||
* <code title="cli">praisonai debug debug-callback <a href="./src/praisonai/praisonai/cli/commands/debug.py">--help</a></code>
|
||||
@@ -1103,6 +1131,8 @@ Methods:
|
||||
* <code title="cli">praisonai sandbox sandbox-callback <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
|
||||
* <code title="cli">praisonai sandbox status <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule add <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule blueprint <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule blueprint-list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule delete <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule describe <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
@@ -1112,6 +1142,10 @@ Methods:
|
||||
* <code title="cli">praisonai schedule start <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule stats <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule stop <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule suggestion-accept <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule suggestion-dismiss <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule suggestion-propose <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai schedule suggestions <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve a2a <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve a2u <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve acp <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
@@ -1147,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>
|
||||
@@ -1342,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.72" \
|
||||
"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.72" \
|
||||
"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.72" \
|
||||
"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>"
|
||||
@@ -257,6 +267,21 @@ find $(python -c "import site; print(site.getsitepackages()[0])") -name "<suspic
|
||||
|
||||
---
|
||||
|
||||
## Fork PRs — contributor checklist and Claude push
|
||||
|
||||
For PRs from external forks (`head.repo != MervinPraison/PraisonAI`), ask the contributor:
|
||||
|
||||
1. **Allow edits from maintainers** on the PR (right sidebar).
|
||||
2. **Keep fork and branch** until the PR is merged or closed.
|
||||
|
||||
Optional: private fork → add maintainer as collaborator; fork with Actions → only enable “Allow edits and access to secrets by maintainers” if they accept the risk.
|
||||
|
||||
When `maintainerCanModify` is true, `claude.yml` pushes via `GH_TOKEN` to the fork remote and `merge-conflict-claude.yml` may auto-trigger `@claude` for `DIRTY` PRs (after FINAL architecture review).
|
||||
|
||||
Manual unblock: `gh pr checkout <N> && git fetch origin main && git rebase origin/main && git push --force-with-lease`
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference — Common Commands
|
||||
|
||||
| Action | Command |
|
||||
|
||||
@@ -50,7 +50,7 @@ The workflow uses the agent configuration at `.github/praisonai-reviewer.yaml` a
|
||||
- Copilot NEVER triggers before CodeRabbit/Qodo complete.
|
||||
- Claude NEVER triggers before Copilot completes.
|
||||
- Duplicate `@copilot` comments are prevented by checking existing comments.
|
||||
- PraisonAI PRs: NEVER merge automatically — user merges manually.
|
||||
- Default merge: **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`) after review chain + CI green. Opt out with label `no-auto-merge`. Manual merge only as fallback.
|
||||
|
||||
## Security & Credentials
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> --jq '{ghsa:
|
||||
3. For each advisory, validate the vulnerability in source code:
|
||||
- Read the file(s) mentioned in the advisory description
|
||||
- Confirm the vulnerability exists (or is already fixed)
|
||||
- Classify: `real` | `already-fixed` | `not-reproducible`
|
||||
- Classify using triage labels (see **Managing ongoing audits** below)
|
||||
- Reference the official repository for latest source: https://github.com/MervinPraison/PraisonAI
|
||||
|
||||
## PHASE 2 — VALIDATE & FIX
|
||||
@@ -99,9 +99,29 @@ pip index versions praisonaiagents | head -1
|
||||
pip index versions praisonai | head -1
|
||||
```
|
||||
|
||||
## PHASE 4 — UPDATE ADVISORIES (only after packages are on PyPI)
|
||||
13. Publish **praisonai-platform** (only when the advisory targets platform code):
|
||||
```bash
|
||||
cd /Users/praison/praisonai-package/src/praisonai-platform
|
||||
praisonai publish pypi
|
||||
# or: uv lock && uv build && uv publish --token $PYPI_TOKEN
|
||||
```
|
||||
Platform releases are **independent** of `praisonai` / `praisonaiagents` version numbers. A platform-only fix (e.g. `0.1.8`) does **not** require bumping the main SDK.
|
||||
|
||||
13. For each advisory, reopen to draft state and set patched versions + credits:
|
||||
14. Publish **praisonai (npm / TypeScript)** (only when the advisory targets `src/praisonai-ts`):
|
||||
```bash
|
||||
cd /Users/praison/praisonai-package/src/praisonai-ts
|
||||
npm version patch && npm publish
|
||||
```
|
||||
|
||||
15. Verify platform / npm when applicable:
|
||||
```bash
|
||||
pip index versions praisonai-platform | head -1
|
||||
npm view praisonai version
|
||||
```
|
||||
|
||||
## PHASE 4 — UPDATE ADVISORIES (only after packages are on PyPI/npm)
|
||||
|
||||
16. For each advisory, reopen to draft state and set patched versions + credits:
|
||||
```bash
|
||||
gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> \
|
||||
--method PATCH \
|
||||
@@ -119,34 +139,86 @@ gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> \
|
||||
}
|
||||
EOF
|
||||
```
|
||||
- `<PACKAGE_NAME>`: `praisonaiagents` for core SDK, `praisonai` for wrapper
|
||||
- `<PACKAGE_NAME>`: `praisonaiagents` (core SDK), `praisonai` (Python wrapper), `praisonai-platform` (platform layer), or `praisonai` (npm ecosystem for TypeScript)
|
||||
- `<LAST_VULNERABLE_VERSION>`: version before the fix
|
||||
- `<FIRST_PATCHED_VERSION>`: the version just published with the fix
|
||||
- Always include credits for the reporter
|
||||
|
||||
14. Publish each advisory:
|
||||
17. Publish each advisory:
|
||||
```bash
|
||||
gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> \
|
||||
--method PATCH --field state=published
|
||||
```
|
||||
|
||||
15. Request CVE for each published advisory:
|
||||
18. Request CVE for each published advisory:
|
||||
```bash
|
||||
gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID>/cve --method POST
|
||||
```
|
||||
GitHub reviews and assigns CVE IDs within 1-3 business days.
|
||||
|
||||
16. Verify final state:
|
||||
19. Verify final state:
|
||||
```bash
|
||||
gh api repos/MervinPraison/PraisonAI/security-advisories --jq '.[] | "\(.ghsa_id) \(.state) \(.cve_id // "pending") \(.severity) \([.credits[].login] | join(",") ) \(.summary | .[0:50])"'
|
||||
```
|
||||
|
||||
## MANAGING ONGOING AUDITS
|
||||
|
||||
Continuous security reports are **expected** for an agent framework. Treat them as a product signal, not a crisis. Use a consistent process so triage stays manageable.
|
||||
|
||||
### Triage labels
|
||||
|
||||
After validating each advisory in source code, assign **one** label:
|
||||
|
||||
| Label | Meaning | Action |
|
||||
|---|---|---|
|
||||
| `real` | Reproducible, valid security issue | Fix → publish → advisory |
|
||||
| `already-fixed` | Fixed in `main` but advisory still open | Set patched version to current release; publish advisory |
|
||||
| `duplicate` | Same root cause as another GHSA | Close or merge; link to primary advisory |
|
||||
| `not-reproducible` | Cannot reproduce with steps provided | Request more detail; hold in triage |
|
||||
| `theoretical` | Requires unlikely preconditions or no practical impact | Document reasoning; close as wontfix or low priority |
|
||||
| `documented-opt-out` | Behaviour is intentional with documented escape hatch | Close as wontfix; point to docs/env var |
|
||||
| `out-of-scope` | Wrong package, fork, or deployment the project does not ship | Close with explanation |
|
||||
|
||||
### Triage load
|
||||
|
||||
- Not every report is valid — filter with the labels above before writing code.
|
||||
- Batch related GHSAs (same file, same pattern) into one fix where possible.
|
||||
- Prefer regression tests per GHSA so fixes are not re-opened by the next audit round.
|
||||
|
||||
### Version churn and release boundaries
|
||||
|
||||
| Package | Version line | Notes |
|
||||
|---|---|---|
|
||||
| `praisonaiagents` + `praisonai` | Aligned patch (e.g. `1.6.62` / `4.6.62`) | Main SDK + wrapper; bump together via `bump_and_release.py` |
|
||||
| `praisonai-platform` | Independent semver (e.g. `0.1.8`) | Platform-only fixes; users need **not** upgrade the main SDK |
|
||||
| `praisonai` (npm) | Independent semver (e.g. `1.7.2`) | TypeScript SDK; publish from `src/praisonai-ts` |
|
||||
|
||||
Always set the advisory `patched_versions` to the **package that actually contains the fix**. Do not imply users must upgrade all packages when only one changed.
|
||||
|
||||
### Researcher incentives
|
||||
|
||||
- Some reports are bounty-driven or low-quality — still acknowledge and triage promptly.
|
||||
- Credit valid reporters in the advisory even for small fixes.
|
||||
- Close invalid reports with a clear label and short rationale (no exploit discussion in public issues).
|
||||
- Never fix “for the sake of it” — see Phase 2 validate checks.
|
||||
|
||||
### When to close as wontfix
|
||||
|
||||
Close (or publish advisory with no code change only if already mitigated) when:
|
||||
|
||||
- Documented opt-out exists and risk is accepted (e.g. localhost-only dev modes with explicit env vars).
|
||||
- Issue is theoretical with no realistic attack path in supported deployments.
|
||||
- Fix would remove existing features or materially bloat the SDK — escalate instead.
|
||||
- Duplicate of an existing published advisory.
|
||||
|
||||
## REFERENCE — Package-to-file mapping
|
||||
|
||||
| Package | Canonical Path | Key files |
|
||||
|---|---|---|
|
||||
| praisonaiagents | src/praisonai-agents/praisonaiagents/ | tools/python_tools.py, mcp/, memory/, agent/ |
|
||||
| praisonai | src/praisonai/praisonai/ | ui/sql_alchemy.py, capabilities/, mcp_server/, cli/ |
|
||||
| Package | Ecosystem | Canonical Path | Key files |
|
||||
|---|---|---|---|
|
||||
| praisonaiagents | pip | src/praisonai-agents/praisonaiagents/ | tools/, mcp/, memory/, agent/, sandbox/ |
|
||||
| praisonai | pip | src/praisonai/praisonai/ | cli/, capabilities/, mcp_server/, sandbox/, recipe/ |
|
||||
| praisonai-platform | pip | src/praisonai-platform/praisonai_platform/ | api/routes/, services/, auth |
|
||||
| praisonai | npm | src/praisonai-ts/src/ | agent/, mcp/, tools/, code-mode/ |
|
||||
|
||||
## RESOURCES & DOCUMENTATION
|
||||
|
||||
@@ -160,8 +232,10 @@ gh api repos/MervinPraison/PraisonAI/security-advisories --jq '.[] | "\(.ghsa_id
|
||||
- NEVER expose exploit details in public issues or PRs
|
||||
- ALWAYS credit the reporter in the advisory
|
||||
- ALWAYS set patched_versions before publishing advisory
|
||||
- ALWAYS publish to PyPI BEFORE publishing advisories (so Dependabot can point users to a fix) - to publish `praisonai publish pypi` from praisonai-agents folder, after that
|
||||
`cd /Users/praison/praisonai-package/src/praisonai && python scripts/bump_and_release.py 4.5.125 --agents 1.5.125 --wait`. 4.5.125 is praisonai version number and 1.5.125 is praisonaiagents version number. Last 2 numbers will be same 4/1.xx.xxx
|
||||
- ALWAYS publish to PyPI/npm BEFORE publishing advisories (so Dependabot can point users to a fix)
|
||||
- Python SDK: `praisonai publish pypi` from `src/praisonai-agents`, then `bump_and_release.py` for the wrapper
|
||||
- Platform-only: `praisonai publish pypi` from `src/praisonai-platform` — no SDK bump required
|
||||
- npm: `npm publish` from `src/praisonai-ts` when the TypeScript SDK is affected
|
||||
- ALWAYS request CVE AFTER publishing advisory
|
||||
- Use generic commit messages: "refactor: harden X" not "fix: SQL injection in Y"
|
||||
- Fix on main, push directly — no feature branches for security patches
|
||||
@@ -172,7 +246,6 @@ gh api repos/MervinPraison/PraisonAI/security-advisories --jq '.[] | "\(.ghsa_id
|
||||
|
||||
## REPORTING SECURITY ISSUES
|
||||
|
||||
If you discover a security vulnerability, please report it responsibly:
|
||||
1. Open a draft security advisory at https://github.com/MervinPraison/PraisonAI/security/advisories/new
|
||||
2. Include detailed reproduction steps and impact assessment
|
||||
3. Allow time for the maintainers to address the issue before public disclosure
|
||||
If you discover a security vulnerability, please report it responsibly. See [SECURITY.md](../../../../SECURITY.md) at the monorepo root for scope, supported packages, and the private advisory link.
|
||||
|
||||
Maintainers and agents: use [.cursor/skills/security-audit/SKILL.md](../../.cursor/skills/security-audit/SKILL.md) for Cursor skill entry point.
|
||||
@@ -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'),
|
||||
|
||||
@@ -24,6 +24,7 @@ from .async_safety import AsyncSafeState
|
||||
from .unified_execution_mixin import UnifiedExecutionMixin
|
||||
from .sandbox_mixin import SandboxMixin
|
||||
from .message_steering import SteeringMixin
|
||||
from .skill_review import SkillReviewMixin
|
||||
|
||||
# Module-level logger for thread safety errors and debugging
|
||||
logger = get_logger(__name__)
|
||||
@@ -207,6 +208,7 @@ if TYPE_CHECKING:
|
||||
from ..rag.models import RAGResult, ContextPack
|
||||
from ..eval.results import EvaluationLoopResult
|
||||
from ..tools.retry import RetryPolicy
|
||||
from ..skills.protocols import SkillReviewProtocol
|
||||
|
||||
# Import structured error from central errors module
|
||||
from ..errors import BudgetExceededError
|
||||
@@ -214,7 +216,7 @@ from ..errors import BudgetExceededError
|
||||
# Import retry configuration
|
||||
from .retry_utils import RetryBackoffConfig
|
||||
|
||||
class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
|
||||
class Agent(SteeringMixin, SandboxMixin, SkillReviewMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
|
||||
# Class-level counter for generating unique display names for nameless agents
|
||||
_agent_counter = 0
|
||||
_agent_counter_lock = threading.Lock()
|
||||
@@ -540,6 +542,7 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
caching: Optional[Union[bool, str, Dict[str, Any], 'CachingConfig']] = None,
|
||||
hooks: Optional[Union[List[Any], Dict[str, Any], 'HooksConfig']] = None,
|
||||
skills: Optional[Union[List[str], str, Dict[str, Any], 'SkillsConfig']] = None,
|
||||
self_improve: Optional[Union[bool, 'SkillReviewProtocol']] = False, # Autonomous skill self-improvement loop (off by default)
|
||||
approval: Optional[Union[bool, str, Dict[str, Any], 'ApprovalConfig', 'ApprovalProtocol']] = None,
|
||||
tool_config: Optional[Union[bool, 'ToolConfig']] = None, # Tool execution configuration (timeout, retry, parallel)
|
||||
learn: Optional[Union[bool, str, Dict[str, Any], 'LearnConfig']] = None, # Continuous learning (peer to memory)
|
||||
@@ -587,6 +590,14 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
|
||||
reflection: Self-reflection. Accepts:
|
||||
- bool: True enables with defaults
|
||||
- ReflectionConfig: Custom configuration
|
||||
self_improve: Autonomous skill self-improvement loop (off by default).
|
||||
After each task, runs a guarded review pass restricted to the
|
||||
``skill_manage`` tool that asks the agent to capture a reusable
|
||||
technique as a new/patched skill. Accepts:
|
||||
- bool: True enables with the default review policy, False disables
|
||||
- SkillReviewProtocol: Custom review policy
|
||||
This is distinct from ``reflection`` (which is answer-quality
|
||||
retry) — it captures durable capability across runs.
|
||||
guardrails: Output validation. Accepts:
|
||||
- bool: True enables with defaults
|
||||
- Callable: Validation function
|
||||
@@ -2015,6 +2026,18 @@ Your Goal: {self.goal}
|
||||
self._skill_manager = None # Lazy loaded
|
||||
self._skills_initialized = False
|
||||
|
||||
# Autonomous skill self-improvement loop (issue #2231). Off by default;
|
||||
# when enabled, a guarded review pass restricted to skill_manage runs
|
||||
# after each task. A SkillReviewProtocol instance customises the policy.
|
||||
if self_improve is True or self_improve is False or self_improve is None:
|
||||
self._self_improve = bool(self_improve)
|
||||
self._self_improve_policy = None
|
||||
else:
|
||||
# Custom policy object implementing SkillReviewProtocol.
|
||||
self._self_improve = True
|
||||
self._self_improve_policy = self_improve
|
||||
self._in_skill_review = False
|
||||
|
||||
# Database persistence (lazy - no imports until used)
|
||||
self._db = db
|
||||
self._session_id = session_id
|
||||
@@ -2182,6 +2205,12 @@ Your Goal: {self.goal}
|
||||
'skills': getattr(self, '_skills_config', None),
|
||||
'approval': getattr(self, '_approval_config', None),
|
||||
'learn': getattr(self, '_learn_config', None),
|
||||
# Autonomous skill self-improvement: forward the custom policy when
|
||||
# set, else the opt-in boolean, so clones keep the same behavior.
|
||||
'self_improve': (
|
||||
getattr(self, '_self_improve_policy', None)
|
||||
or getattr(self, '_self_improve', False)
|
||||
),
|
||||
'tool_search': getattr(self, '_tool_search_config', None),
|
||||
# Tool configuration - use consolidated config when available
|
||||
'tool_config': getattr(self, '_tool_config', None),
|
||||
@@ -4034,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
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -4195,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.
|
||||
@@ -5615,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()
|
||||
@@ -5663,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)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Autonomous skill self-improvement loop.
|
||||
|
||||
Closes the loop on the existing skill subsystem: after a task finishes, an
|
||||
opt-in *guarded* review pass runs a short, isolated turn restricted to the
|
||||
``skill_manage`` tool and asks the agent whether the session revealed a
|
||||
reusable technique worth persisting as a skill.
|
||||
|
||||
Design guarantees (see issue #2231):
|
||||
- Off by default — only runs when ``self_improve`` is enabled.
|
||||
- Never runs with the full toolset — the review turn is restricted to the
|
||||
single skill-mutation tool.
|
||||
- Re-entrancy guarded — the review turn itself cannot trigger another review.
|
||||
- Best-effort — any failure is swallowed so it never breaks the main task.
|
||||
|
||||
This logic lives in its own mixin so ``agent.py`` stays lean.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SkillReviewMixin:
|
||||
"""Mixin providing the guarded skill-review pass for ``Agent``."""
|
||||
|
||||
def _skill_review_policy(self):
|
||||
"""Return the active review policy, building the default lazily."""
|
||||
policy = getattr(self, "_self_improve_policy", None)
|
||||
if policy is not None:
|
||||
return policy
|
||||
from ..skills import DefaultSkillReviewPolicy
|
||||
policy = DefaultSkillReviewPolicy()
|
||||
self._self_improve_policy = policy
|
||||
return policy
|
||||
|
||||
def _build_skill_review_tool(self):
|
||||
"""Return the restricted toolset for the review turn (skill_manage only)."""
|
||||
try:
|
||||
from ..tools.skill_tools import create_skill_tools
|
||||
skill_tools = create_skill_tools()
|
||||
return [skill_tools.skill_manage]
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.debug("Could not build skill review tool: %s", e, exc_info=True)
|
||||
return []
|
||||
|
||||
def _skill_review_log_context(self):
|
||||
"""Return (agent_name, session_id) for swallowed-failure warnings."""
|
||||
return (
|
||||
getattr(self, "name", None),
|
||||
getattr(self, "_session_id", None),
|
||||
)
|
||||
|
||||
def _prepare_skill_review(self, prompt, response, tools_used=None):
|
||||
"""Gate + build the review turn. Returns (review_prompt, review_tools) or None.
|
||||
|
||||
Shared by the sync and async paths so they stay in lock-step. Applies
|
||||
the opt-in switch, the re-entrancy guard, the policy decision, and the
|
||||
restricted-toolset build.
|
||||
"""
|
||||
# Opt-in only.
|
||||
if not getattr(self, "_self_improve", False):
|
||||
return None
|
||||
# Re-entrancy guard: never review a review.
|
||||
if getattr(self, "_in_skill_review", False):
|
||||
return None
|
||||
|
||||
trajectory = {
|
||||
"prompt": prompt if isinstance(prompt, str) else str(prompt),
|
||||
"response": response or "",
|
||||
"tools_used": list(tools_used or []),
|
||||
}
|
||||
|
||||
try:
|
||||
policy = self._skill_review_policy()
|
||||
if not policy.should_review(trajectory):
|
||||
return None
|
||||
review_prompt = policy.review_prompt(trajectory)
|
||||
except Exception as e:
|
||||
name, session_id = self._skill_review_log_context()
|
||||
logger.warning(
|
||||
"Skill review policy failed for agent=%s session_id=%s: %s",
|
||||
name, session_id, e, exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
review_tools = self._build_skill_review_tool()
|
||||
if not review_tools:
|
||||
logger.debug("Skill review skipped: skill_manage tool unavailable")
|
||||
return None
|
||||
|
||||
return review_prompt, review_tools
|
||||
|
||||
def _snapshot_chat_history_len(self):
|
||||
"""Return the current chat_history length, or None if unavailable."""
|
||||
history = getattr(self, "chat_history", None)
|
||||
return len(history) if isinstance(history, list) else None
|
||||
|
||||
def _restore_chat_history(self, snapshot_len):
|
||||
"""Trim chat_history back to ``snapshot_len`` so the review turn is isolated.
|
||||
|
||||
The review turn is internal: any user/assistant messages it appended to
|
||||
the shared, persistent chat_history must not leak into subsequent turns
|
||||
of a reused agent (chatbot, REPL, agentic loop).
|
||||
"""
|
||||
if snapshot_len is None:
|
||||
return
|
||||
history = getattr(self, "chat_history", None)
|
||||
if isinstance(history, list) and len(history) > snapshot_len:
|
||||
del history[snapshot_len:]
|
||||
|
||||
def _run_skill_review(self, prompt, response, tools_used=None):
|
||||
"""Run the guarded skill-review pass.
|
||||
|
||||
Args:
|
||||
prompt: The original user prompt for the finished task.
|
||||
response: The final response produced for the task.
|
||||
tools_used: Names of tools used during the task (if known).
|
||||
"""
|
||||
prepared = self._prepare_skill_review(prompt, response, tools_used)
|
||||
if prepared is None:
|
||||
return
|
||||
review_prompt, review_tools = prepared
|
||||
|
||||
self._in_skill_review = True
|
||||
history_len = self._snapshot_chat_history_len()
|
||||
try:
|
||||
if getattr(self, "verbose", False):
|
||||
logger.info("Agent %s running guarded skill-review pass", self.name)
|
||||
# Restricted turn: only skill_manage is exposed via the tools= arg.
|
||||
self.chat(review_prompt, tools=review_tools)
|
||||
except Exception as e:
|
||||
name, session_id = self._skill_review_log_context()
|
||||
logger.warning(
|
||||
"Skill review pass failed for agent=%s session_id=%s: %s",
|
||||
name, session_id, e, exc_info=True,
|
||||
)
|
||||
finally:
|
||||
self._restore_chat_history(history_len)
|
||||
self._in_skill_review = False
|
||||
|
||||
async def _arun_skill_review(self, prompt, response, tools_used=None):
|
||||
"""Async variant of :meth:`_run_skill_review`."""
|
||||
prepared = self._prepare_skill_review(prompt, response, tools_used)
|
||||
if prepared is None:
|
||||
return
|
||||
review_prompt, review_tools = prepared
|
||||
|
||||
self._in_skill_review = True
|
||||
history_len = self._snapshot_chat_history_len()
|
||||
try:
|
||||
if getattr(self, "verbose", False):
|
||||
logger.info("Agent %s running guarded skill-review pass", self.name)
|
||||
await self.achat(review_prompt, tools=review_tools)
|
||||
except Exception as e:
|
||||
name, session_id = self._skill_review_log_context()
|
||||
logger.warning(
|
||||
"Skill review pass failed for agent=%s session_id=%s: %s",
|
||||
name, session_id, e, exc_info=True,
|
||||
)
|
||||
finally:
|
||||
self._restore_chat_history(history_len)
|
||||
self._in_skill_review = False
|
||||
@@ -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
|
||||
@@ -676,6 +694,12 @@ class ToolExecutionMixin:
|
||||
|
||||
def _trigger_after_agent_hook(self, prompt, response, start_time, tools_used=None):
|
||||
"""Trigger AFTER_AGENT hook and return response."""
|
||||
# During a guarded skill-review turn, skip the entire after-agent
|
||||
# side-effect pipeline (hooks, auto-memory, auto-learning, nudge,
|
||||
# skill-review). The review turn is internal and must not re-fire
|
||||
# these effects or recurse into another review.
|
||||
if getattr(self, "_in_skill_review", False):
|
||||
return response
|
||||
from ..hooks import HookEvent, AfterAgentInput
|
||||
after_agent_input = AfterAgentInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
@@ -711,10 +735,31 @@ class ToolExecutionMixin:
|
||||
# Log learning nudge failures for debugging
|
||||
logger.warning("Learning nudge generation failed: %s", e, exc_info=True)
|
||||
|
||||
# Autonomous skill self-improvement loop (opt-in via self_improve=True).
|
||||
# Runs a guarded review pass restricted to skill_manage. No-op when
|
||||
# disabled or already inside a review (re-entrancy guarded).
|
||||
try:
|
||||
run_review = getattr(self, "_run_skill_review", None)
|
||||
if run_review is not None:
|
||||
run_review(prompt, response, tools_used)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Skill self-improvement review failed for agent=%s session_id=%s; "
|
||||
"set self_improve=False to disable this best-effort pass. error=%s",
|
||||
getattr(self, "name", None),
|
||||
getattr(self, "_session_id", None),
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
async def _atrigger_after_agent_hook(self, prompt, response, start_time, tools_used=None):
|
||||
"""Async version: Trigger AFTER_AGENT hook and return response."""
|
||||
# During a guarded skill-review turn, skip the entire after-agent
|
||||
# side-effect pipeline (see sync variant for rationale).
|
||||
if getattr(self, "_in_skill_review", False):
|
||||
return response
|
||||
from ..hooks import HookEvent, AfterAgentInput
|
||||
after_agent_input = AfterAgentInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
@@ -747,6 +792,21 @@ class ToolExecutionMixin:
|
||||
# Log learning nudge failures for debugging
|
||||
logger.warning("Learning nudge generation failed: %s", e, exc_info=True)
|
||||
|
||||
# Autonomous skill self-improvement loop (opt-in via self_improve=True).
|
||||
try:
|
||||
arun_review = getattr(self, "_arun_skill_review", None)
|
||||
if arun_review is not None:
|
||||
await arun_review(prompt, response, tools_used)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Skill self-improvement review failed for agent=%s session_id=%s; "
|
||||
"set self_improve=False to disable this best-effort pass. error=%s",
|
||||
getattr(self, "name", None),
|
||||
getattr(self, "_session_id", None),
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def _calculate_llm_cost(self, prompt_tokens: int, completion_tokens: int, response: any = None) -> float:
|
||||
@@ -771,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,
|
||||
@@ -33,6 +34,24 @@ from .protocols import (
|
||||
OutboundMessengerProtocol,
|
||||
DeliveryResult,
|
||||
TargetInfo,
|
||||
# Inbound route binding (Issue #2225)
|
||||
RouteBinding,
|
||||
RouteFacts,
|
||||
RouteMatch,
|
||||
resolve_route,
|
||||
# Per-route, trust-tiered toolset scoping (Issue #2298)
|
||||
ToolPolicy,
|
||||
UNTRUSTED_DENY_SUBSTRINGS,
|
||||
TRUST_TIERS,
|
||||
# Outbound send-policy guard (Issue #2226)
|
||||
SendDecision,
|
||||
SendPolicyProtocol,
|
||||
SendPolicy,
|
||||
# Gateway idle-dormancy / scale-to-zero (Issue #2332)
|
||||
IdleDecision,
|
||||
GatewayIdlePolicyProtocol,
|
||||
GatewayIdlePolicy, # backward-compat alias
|
||||
ScaleToZeroPolicy,
|
||||
# Protocol version negotiation
|
||||
PROTOCOL_VERSION,
|
||||
MIN_PROTOCOL_VERSION,
|
||||
@@ -42,6 +61,13 @@ from .protocols import (
|
||||
GapInfo,
|
||||
ResumeSnapshot,
|
||||
)
|
||||
from .hooks import (
|
||||
HookAction,
|
||||
HookConfig,
|
||||
InboundTriggerProtocol,
|
||||
render_template,
|
||||
compute_idempotency_key,
|
||||
)
|
||||
from .config import (
|
||||
GatewayConfig,
|
||||
SessionConfig,
|
||||
@@ -103,6 +129,7 @@ __all__ = [
|
||||
"GatewayMessage",
|
||||
"EventType",
|
||||
"OperatorScope",
|
||||
"GatewayCloseCode",
|
||||
# Push protocols (always available)
|
||||
"PushChannelProtocol",
|
||||
"PresenceProtocol",
|
||||
@@ -117,6 +144,24 @@ __all__ = [
|
||||
"OutboundMessengerProtocol",
|
||||
"DeliveryResult",
|
||||
"TargetInfo",
|
||||
# Inbound route binding (Issue #2225)
|
||||
"RouteBinding",
|
||||
"RouteFacts",
|
||||
"RouteMatch",
|
||||
"resolve_route",
|
||||
# Per-route, trust-tiered toolset scoping (Issue #2298)
|
||||
"ToolPolicy",
|
||||
"UNTRUSTED_DENY_SUBSTRINGS",
|
||||
"TRUST_TIERS",
|
||||
# Outbound send-policy guard (Issue #2226)
|
||||
"SendDecision",
|
||||
"SendPolicyProtocol",
|
||||
"SendPolicy",
|
||||
# Gateway idle-dormancy / scale-to-zero (Issue #2332)
|
||||
"IdleDecision",
|
||||
"GatewayIdlePolicyProtocol",
|
||||
"GatewayIdlePolicy",
|
||||
"ScaleToZeroPolicy",
|
||||
# Protocol version negotiation
|
||||
"PROTOCOL_VERSION",
|
||||
"MIN_PROTOCOL_VERSION",
|
||||
@@ -125,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,
|
||||
@@ -47,6 +48,49 @@ class ConnectErrorCode(str, Enum):
|
||||
PROTOCOL_UNSUPPORTED = "protocol_unsupported"
|
||||
PAIRING_REQUIRED = "pairing_required"
|
||||
AGENT_NOT_FOUND = "agent_not_found"
|
||||
RATE_LIMITED = "rate_limited"
|
||||
ORIGIN_NOT_ALLOWED = "origin_not_allowed"
|
||||
CONFIGURATION_ERROR = "configuration_error"
|
||||
|
||||
|
||||
class GatewayCloseCode(str, Enum):
|
||||
"""Structured, machine-readable reasons for a server-initiated close.
|
||||
|
||||
Distinct from :class:`ConnectErrorCode` (which describes why a *connection*
|
||||
was rejected at/handshake time), these describe why an already-established
|
||||
connection is being torn down by the server mid-session.
|
||||
|
||||
Codes:
|
||||
SLOW_CONSUMER: The client's outbound buffer exceeded the gateway's
|
||||
advertised ``max_buffered_bytes`` policy (a genuinely slow/stalled
|
||||
consumer). The server evicts it so its backlog cannot grow without
|
||||
bound or stall delivery to healthy clients.
|
||||
"""
|
||||
|
||||
SLOW_CONSUMER = "slow_consumer"
|
||||
|
||||
|
||||
class ConnectRecoveryStep(str, Enum):
|
||||
"""Machine-readable recovery hint for a connection rejection.
|
||||
|
||||
Clients branch on ``(code, next_step)`` to implement deterministic,
|
||||
uniform reconnect behaviour without parsing free-text reasons:
|
||||
|
||||
REAUTHENTICATE: Obtain fresh credentials, then reconnect.
|
||||
REPAIR: Re-run the device pairing flow, then reconnect.
|
||||
UPGRADE_CLIENT: The client protocol is too old; update the client.
|
||||
DOWNGRADE_CLIENT: The client protocol is newer than the server
|
||||
supports; use an older client or wait for a server upgrade.
|
||||
WAIT_THEN_RETRY: Back off (see ``retry_after_seconds``) then reconnect.
|
||||
DO_NOT_RETRY: The rejection is terminal; reconnecting will not help.
|
||||
"""
|
||||
|
||||
REAUTHENTICATE = "reauthenticate"
|
||||
REPAIR = "repair"
|
||||
UPGRADE_CLIENT = "upgrade_client"
|
||||
DOWNGRADE_CLIENT = "downgrade_client"
|
||||
WAIT_THEN_RETRY = "wait_then_retry"
|
||||
DO_NOT_RETRY = "do_not_retry"
|
||||
|
||||
|
||||
class EventType(str, Enum):
|
||||
@@ -179,17 +223,56 @@ class HelloResult:
|
||||
|
||||
@dataclass
|
||||
class HelloError:
|
||||
"""Error response for failed handshake.
|
||||
|
||||
"""Structured connect-rejection envelope.
|
||||
|
||||
Emitted from *every* connection rejection path — both pre-handshake
|
||||
transport checks (auth/origin/rate-limit) and handshake negotiation —
|
||||
so clients can implement deterministic reconnect logic by branching on
|
||||
``(code, next_step)`` instead of string-matching close reasons.
|
||||
|
||||
Attributes:
|
||||
code: Structured error code
|
||||
message: Human-readable error message
|
||||
next_action: Suggested next action (e.g., "upgrade_client", "pair_device")
|
||||
code: Structured, machine-readable error code.
|
||||
message: Human-readable error message (display only).
|
||||
next_step: Machine-readable recovery hint telling the client what to
|
||||
do next (re-authenticate, re-pair, upgrade, wait then retry, ...).
|
||||
retry_after_seconds: Optional backoff hint (rate limiting / transient
|
||||
unavailability). Only meaningful with ``WAIT_THEN_RETRY``.
|
||||
next_action: Deprecated free-text hint, retained for backward
|
||||
compatibility. Prefer ``next_step``.
|
||||
"""
|
||||
code: ConnectErrorCode
|
||||
message: str
|
||||
next_step: Optional[ConnectRecoveryStep] = None
|
||||
retry_after_seconds: Optional[int] = None
|
||||
next_action: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to a ``hello_error`` wire frame.
|
||||
|
||||
The ``next`` key is preserved for backward compatibility with existing
|
||||
clients; ``next_step`` and ``retry_after_seconds`` are the structured
|
||||
recovery fields new clients should branch on.
|
||||
"""
|
||||
frame: Dict[str, Any] = {
|
||||
"type": "hello_error",
|
||||
"code": self.code.value,
|
||||
"message": self.message,
|
||||
}
|
||||
if self.next_step is not None:
|
||||
frame["next_step"] = self.next_step.value
|
||||
if self.retry_after_seconds is not None:
|
||||
frame["retry_after_seconds"] = self.retry_after_seconds
|
||||
# Backward-compatible legacy field: fall back to next_step's value.
|
||||
# Omitted entirely when there is no recovery hint so clients using
|
||||
# strict schema validation or ``if frame["next"]`` are not tripped by
|
||||
# an explicit ``null``.
|
||||
legacy_next = self.next_action
|
||||
if legacy_next is None and self.next_step is not None:
|
||||
legacy_next = self.next_step.value
|
||||
if legacy_next is not None:
|
||||
frame["next"] = legacy_next
|
||||
return frame
|
||||
|
||||
|
||||
@dataclass
|
||||
class GatewayEvent:
|
||||
@@ -945,6 +1028,352 @@ class OutboundDeliveryProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-route, trust-tiered toolset scoping (Issue #2298)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Conservative deny-list applied to ``trust: "untrusted"`` routes. Inbound
|
||||
# content from strangers / generic webhooks is the framework's largest
|
||||
# prompt-injection surface, so dangerous tool *families* are never advertised
|
||||
# to the model on these routes (shell, file mutation, delegation,
|
||||
# self-scheduling). Names are matched case-insensitively against substrings of
|
||||
# the tool name so deployments do not have to enumerate every concrete tool.
|
||||
UNTRUSTED_DENY_SUBSTRINGS: List[str] = [
|
||||
"shell",
|
||||
"exec",
|
||||
"command",
|
||||
"subprocess",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"delete_file",
|
||||
"rm_file",
|
||||
"delegate",
|
||||
"handoff",
|
||||
"cronjob",
|
||||
"schedule",
|
||||
]
|
||||
|
||||
# Trust tiers, ordered from least to most privileged.
|
||||
TRUST_TIERS: List[str] = ["untrusted", "standard", "trusted"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolPolicy:
|
||||
"""Declarative, per-route scope applied to an agent's tool surface.
|
||||
|
||||
Mirrors the scheduler's ``RunPolicy.filter_tools`` contract (wrapper layer)
|
||||
but lives in core so :class:`RouteBinding` can *declare* the scope without
|
||||
importing any heavy wrapper code. The wrapper inbound path applies it via a
|
||||
small apply/restore helper, exactly as the scheduler already does for
|
||||
unattended runs.
|
||||
|
||||
Attributes:
|
||||
allow_tools: If set, only tools whose name is in this set are kept;
|
||||
everything else is removed. ``None`` means "allow all except
|
||||
``deny_tools`` / the trust deny-list".
|
||||
deny_tools: Exact tool names removed before the run.
|
||||
deny_substrings: Case-insensitive substrings; a tool whose name
|
||||
contains any of them is removed (used by the ``untrusted`` tier).
|
||||
"""
|
||||
|
||||
allow_tools: Optional[Set[str]] = None
|
||||
deny_tools: Set[str] = field(default_factory=set)
|
||||
deny_substrings: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_noop(self) -> bool:
|
||||
"""``True`` when the policy would never remove any tool."""
|
||||
return (
|
||||
self.allow_tools is None
|
||||
and not self.deny_tools
|
||||
and not self.deny_substrings
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _tool_name(tool: Any) -> str:
|
||||
"""Best-effort name for a tool (matches Agent's own resolution)."""
|
||||
name = getattr(tool, "name", None)
|
||||
if isinstance(name, str) and name:
|
||||
return name
|
||||
dunder = getattr(tool, "__name__", None)
|
||||
if isinstance(dunder, str) and dunder:
|
||||
return dunder
|
||||
if isinstance(tool, dict):
|
||||
fn = tool.get("function")
|
||||
if isinstance(fn, dict) and isinstance(fn.get("name"), str):
|
||||
return fn["name"]
|
||||
top = tool.get("name")
|
||||
if isinstance(top, str) and top:
|
||||
return top
|
||||
return str(tool)
|
||||
|
||||
def is_tool_allowed(self, tool: Any) -> bool:
|
||||
"""Return ``True`` if ``tool`` may be exposed on this route."""
|
||||
name = self._tool_name(tool)
|
||||
if name in self.deny_tools:
|
||||
return False
|
||||
lowered = name.lower()
|
||||
for needle in self.deny_substrings:
|
||||
if needle and needle.lower() in lowered:
|
||||
return False
|
||||
if self.allow_tools is not None and name not in self.allow_tools:
|
||||
return False
|
||||
return True
|
||||
|
||||
def filter_tools(self, tools: Optional[List[Any]]) -> List[Any]:
|
||||
"""Return a copy of ``tools`` with denied/disallowed tools removed."""
|
||||
if not tools:
|
||||
return []
|
||||
return [tool for tool in tools if self.is_tool_allowed(tool)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inbound route binding (Issue #2225)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class RouteBinding:
|
||||
"""A single declarative inbound-routing rule.
|
||||
|
||||
A binding maps a set of optional inbound conditions to a handling agent.
|
||||
Bindings are evaluated most-specific-first so operators get deterministic,
|
||||
debuggable routing across a fleet of agents behind one gateway.
|
||||
|
||||
All condition fields are optional; ``None`` means "do not constrain on this
|
||||
field". A binding matches a set of :class:`RouteFacts` only when *every*
|
||||
non-``None`` condition equals the corresponding fact.
|
||||
|
||||
Attributes:
|
||||
agent: The agent id to route to when this binding matches.
|
||||
chat_type: Chat type ("dm" | "group" | "channel").
|
||||
peer: Sender/user id (most specific).
|
||||
role: Role / guild-role membership of the sender.
|
||||
channel_id: Specific chat/channel id.
|
||||
account: Receiving bot account (for multi-account channels).
|
||||
priority: Higher wins; ties are broken by specificity then order.
|
||||
trust: Optional trust tier ("untrusted" | "standard" | "trusted").
|
||||
``untrusted`` advertises a conservative, read-only-leaning toolset
|
||||
to the model so dangerous tools are never offered on third-party /
|
||||
stranger / generic-webhook routes (Issue #2298). ``None`` /
|
||||
``standard`` / ``trusted`` apply no tier deny-list.
|
||||
allow_tools: If set, only these tool names are exposed on this route.
|
||||
deny_tools: Tool names removed before the run on this route.
|
||||
"""
|
||||
|
||||
agent: str
|
||||
chat_type: Optional[str] = None
|
||||
peer: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
channel_id: Optional[str] = None
|
||||
account: Optional[str] = None
|
||||
priority: int = 0
|
||||
trust: Optional[str] = None
|
||||
allow_tools: Optional[List[str]] = None
|
||||
deny_tools: Optional[List[str]] = None
|
||||
|
||||
# Specificity weights — exact peer beats role/channel beats account
|
||||
# beats chat-type. Higher means more specific.
|
||||
_SPECIFICITY = {
|
||||
"peer": 16,
|
||||
"role": 8,
|
||||
"channel_id": 8,
|
||||
"account": 4,
|
||||
"chat_type": 2,
|
||||
}
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Normalise ``trust`` so config typos cannot silently fail open.
|
||||
|
||||
Whitespace/case variants of a known tier (e.g. ``" Untrusted "``) are
|
||||
canonicalised. Any *unknown* non-empty value is treated as the most
|
||||
restrictive tier (``untrusted``) rather than as "no policy", so a
|
||||
misconfigured route can never accidentally expose the full toolset.
|
||||
"""
|
||||
if self.trust is None:
|
||||
return
|
||||
normalized = str(self.trust).strip().lower()
|
||||
if not normalized:
|
||||
self.trust = None
|
||||
elif normalized in TRUST_TIERS:
|
||||
self.trust = normalized
|
||||
else:
|
||||
self.trust = "untrusted"
|
||||
|
||||
@property
|
||||
def specificity(self) -> int:
|
||||
"""Sum of weights for the conditions this binding constrains on."""
|
||||
score = 0
|
||||
for field_name, weight in self._SPECIFICITY.items():
|
||||
if getattr(self, field_name) is not None:
|
||||
score += weight
|
||||
return score
|
||||
|
||||
def matches(self, facts: "RouteFacts") -> bool:
|
||||
"""Return True if every constrained condition equals the facts."""
|
||||
if self.peer is not None and str(self.peer) != str(facts.peer):
|
||||
return False
|
||||
if self.channel_id is not None and str(self.channel_id) != str(facts.channel_id):
|
||||
return False
|
||||
if self.account is not None and str(self.account) != str(facts.account):
|
||||
return False
|
||||
if self.chat_type is not None and self.chat_type != facts.chat_type:
|
||||
return False
|
||||
if self.role is not None:
|
||||
expected_role = str(self.role)
|
||||
if expected_role not in [str(role) for role in (facts.roles or [])]:
|
||||
return False
|
||||
return True
|
||||
|
||||
def tool_policy(self) -> Optional["ToolPolicy"]:
|
||||
"""Build the :class:`ToolPolicy` this binding declares, if any.
|
||||
|
||||
Returns ``None`` when the binding does not constrain the toolset, so
|
||||
callers can cheaply skip the apply/restore dance for trusted routes.
|
||||
The ``untrusted`` trust tier seeds a conservative substring deny-list;
|
||||
explicit ``allow_tools`` / ``deny_tools`` layer on top of it.
|
||||
"""
|
||||
deny_substrings: List[str] = []
|
||||
if self.trust == "untrusted":
|
||||
deny_substrings = list(UNTRUSTED_DENY_SUBSTRINGS)
|
||||
|
||||
allow = set(self.allow_tools) if self.allow_tools else None
|
||||
deny = set(self.deny_tools) if self.deny_tools else set()
|
||||
|
||||
if allow is None and not deny and not deny_substrings:
|
||||
return None
|
||||
return ToolPolicy(
|
||||
allow_tools=allow,
|
||||
deny_tools=deny,
|
||||
deny_substrings=deny_substrings,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "RouteBinding":
|
||||
"""Create a binding from a YAML/dict mapping.
|
||||
|
||||
Accepts ``agent`` (required). Unknown keys are ignored so the shape
|
||||
can evolve without breaking older configs.
|
||||
"""
|
||||
return cls(
|
||||
agent=data.get("agent", "default"),
|
||||
chat_type=data.get("chat_type"),
|
||||
peer=_as_opt_str(data.get("peer")),
|
||||
role=data.get("role"),
|
||||
channel_id=_as_opt_str(data.get("channel_id")),
|
||||
account=_as_opt_str(data.get("account")),
|
||||
priority=int(data.get("priority", 0) or 0),
|
||||
trust=_as_opt_str(data.get("trust")),
|
||||
allow_tools=_as_opt_str_list(data.get("allow_tools")),
|
||||
deny_tools=_as_opt_str_list(data.get("deny_tools")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteFacts:
|
||||
"""Inbound facts extracted from a message, used to resolve a binding.
|
||||
|
||||
Attributes:
|
||||
chat_type: Normalised chat type ("dm" | "group" | "channel" | "default").
|
||||
peer: Sender/user id.
|
||||
roles: Roles/guild-role memberships of the sender.
|
||||
channel_id: The chat/channel id the message arrived in.
|
||||
account: The receiving bot account (multi-account channels).
|
||||
"""
|
||||
|
||||
chat_type: str = "default"
|
||||
peer: Optional[str] = None
|
||||
roles: List[str] = field(default_factory=list)
|
||||
channel_id: Optional[str] = None
|
||||
account: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteMatch:
|
||||
"""Result of resolving a route.
|
||||
|
||||
Attributes:
|
||||
agent: The resolved agent id.
|
||||
binding: The binding that matched, or ``None`` when the fallback was used.
|
||||
reason: Short human-readable explanation for logging/debugging.
|
||||
"""
|
||||
|
||||
agent: str
|
||||
binding: Optional[RouteBinding] = None
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def _as_opt_str(value: Any) -> Optional[str]:
|
||||
"""Coerce a value to a string, preserving ``None``."""
|
||||
if value is None:
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
|
||||
def _as_opt_str_list(value: Any) -> Optional[List[str]]:
|
||||
"""Coerce a YAML scalar or sequence into ``Optional[List[str]]``.
|
||||
|
||||
Accepts a single string (wrapped into a one-element list) or any iterable
|
||||
of values; returns ``None`` for ``None``/empty so an absent key stays
|
||||
unconstrained.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return [value] if value else None
|
||||
try:
|
||||
items = [str(v) for v in value]
|
||||
except TypeError:
|
||||
return [str(value)]
|
||||
return items or None
|
||||
|
||||
|
||||
def resolve_route(
|
||||
bindings: List[RouteBinding],
|
||||
facts: RouteFacts,
|
||||
default_agent: str = "default",
|
||||
) -> RouteMatch:
|
||||
"""Resolve the handling agent from priority-ordered bindings.
|
||||
|
||||
Bindings are evaluated most-specific-first: the matching binding with the
|
||||
highest ``priority`` wins; ties are broken by specificity (exact peer →
|
||||
role/channel → account → chat-type), then by declaration order.
|
||||
|
||||
Args:
|
||||
bindings: Candidate route bindings (any order).
|
||||
facts: Inbound facts extracted from the message.
|
||||
default_agent: Agent id to fall back to when nothing matches.
|
||||
|
||||
Returns:
|
||||
A :class:`RouteMatch` with the selected agent and matched binding.
|
||||
"""
|
||||
best: Optional[RouteBinding] = None
|
||||
best_key: tuple = ()
|
||||
for idx, binding in enumerate(bindings):
|
||||
if not binding.matches(facts):
|
||||
continue
|
||||
# Higher priority wins, then higher specificity, then earlier order.
|
||||
key = (binding.priority, binding.specificity, -idx)
|
||||
if best is None or key > best_key:
|
||||
best = binding
|
||||
best_key = key
|
||||
|
||||
if best is not None:
|
||||
return RouteMatch(
|
||||
agent=best.agent,
|
||||
binding=best,
|
||||
reason=(
|
||||
f"matched binding (priority={best.priority}, "
|
||||
f"specificity={best.specificity})"
|
||||
),
|
||||
)
|
||||
|
||||
return RouteMatch(
|
||||
agent=default_agent,
|
||||
binding=None,
|
||||
reason="no binding matched; using default",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth Mode protocols and helpers (bind-aware authentication posture)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1342,6 +1771,266 @@ class OutboundMessengerProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outbound send-policy guard (Issue #2226)
|
||||
#
|
||||
# ``send_message`` lets the model choose where to deliver. Because the target
|
||||
# is model-controlled, poisoned inbound content (prompt injection) can steer an
|
||||
# agent into delivering to a channel the operator never intended. The router
|
||||
# only fails on *unresolvable* targets — a reachability check, not an
|
||||
# authorisation one. This policy seam sits in core, *before* dispatch, so every
|
||||
# messenger implementation is constrained, not just one adapter. Absent a
|
||||
# policy, today's behaviour is preserved (allow-all).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SendDecision:
|
||||
"""Closed decision shape for an outbound send-policy evaluation.
|
||||
|
||||
Attributes:
|
||||
allow: Whether the send to the requested target is permitted.
|
||||
reason: Optional model-readable explanation (used in the
|
||||
:class:`DeliveryResult` detail when denied).
|
||||
"""
|
||||
|
||||
allow: bool
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SendPolicyProtocol(Protocol):
|
||||
"""Protocol for authorising agent-initiated proactive sends.
|
||||
|
||||
Implementations decide whether an agent may deliver to a model-chosen
|
||||
``target``. The hook is evaluated inside the ``send_message`` path before
|
||||
the messenger dispatches, so a denied send returns a clean, model-readable
|
||||
:class:`DeliveryResult` (``ok=False``) rather than delivering.
|
||||
|
||||
A concrete, config-driven implementation (:class:`SendPolicy`) is provided
|
||||
for the common allow/deny case; richer back-ends may live in plugins.
|
||||
"""
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
target: str,
|
||||
*,
|
||||
agent_id: str = "",
|
||||
session_id: str = "",
|
||||
origin: Optional[str] = None,
|
||||
) -> SendDecision:
|
||||
"""Return a :class:`SendDecision` for the requested ``target``."""
|
||||
...
|
||||
|
||||
|
||||
class SendPolicy:
|
||||
"""A lightweight allow/deny send-policy with an optional default-deny posture.
|
||||
|
||||
This is the config-driven default referenced by ``send_policy`` blocks in
|
||||
``gateway.yaml`` and the ``Bot(..., send_policy=...)`` Python surface. It is
|
||||
intentionally minimal (no heavy dependencies) and lives in core so the
|
||||
built-in ``send_message`` path is always interceptable.
|
||||
|
||||
Matching is exact against the symbolic target token (e.g. ``"origin"``,
|
||||
``"slack:#ops"``, or a friendly alias). With ``default="deny"`` only listed
|
||||
targets are permitted; with ``default="allow"`` all targets are permitted
|
||||
except those in ``deny``.
|
||||
|
||||
Example::
|
||||
|
||||
# default-deny: only the conversation origin and an ops alias allowed
|
||||
SendPolicy(default="deny", allow=["origin", "ops-alerts"])
|
||||
# default-allow: everything permitted except an exec channel
|
||||
SendPolicy(default="allow", deny=["slack:#exec"])
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
default: str = "allow",
|
||||
allow: Optional[List[str]] = None,
|
||||
deny: Optional[List[str]] = None,
|
||||
):
|
||||
default = (default or "allow").lower()
|
||||
if default not in ("allow", "deny"):
|
||||
raise ValueError(
|
||||
f"send_policy default must be 'allow' or 'deny', got {default!r}"
|
||||
)
|
||||
self.default = default
|
||||
self.allow = list(allow or [])
|
||||
self.deny = list(deny or [])
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
target: str,
|
||||
*,
|
||||
agent_id: str = "",
|
||||
session_id: str = "",
|
||||
origin: Optional[str] = None,
|
||||
) -> SendDecision:
|
||||
if target in self.deny:
|
||||
return SendDecision(
|
||||
allow=False,
|
||||
reason=f"target '{target}' is denied by send_policy",
|
||||
)
|
||||
if self.default == "deny":
|
||||
if target in self.allow:
|
||||
return SendDecision(allow=True)
|
||||
return SendDecision(
|
||||
allow=False,
|
||||
reason=f"target '{target}' is not permitted by send_policy",
|
||||
)
|
||||
return SendDecision(allow=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway idle-dormancy / scale-to-zero (Issue #2332)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class IdleDecision:
|
||||
"""Result of an idle/quiesce evaluation.
|
||||
|
||||
Attributes:
|
||||
idle: Whether the gateway is currently quiescent.
|
||||
reason: Optional human-readable explanation (logged on quiesce).
|
||||
"""
|
||||
|
||||
idle: bool
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class GatewayIdlePolicyProtocol(Protocol):
|
||||
"""Protocol for gateway-process idle/scale-to-zero decisions.
|
||||
|
||||
Pure, import-free decision contract consumed by the wrapper's
|
||||
``BotOS`` run-loop. The wrapper supplies live facts (running turns,
|
||||
last inbound timestamp, background-work flag) and the policy decides
|
||||
whether the whole gateway may safely stand down. Concrete drivers
|
||||
(suspend the compute host, stand transports down, register a wake
|
||||
URL) live in the wrapper; this contract keeps the *decision* testable
|
||||
in isolation without a live gateway.
|
||||
|
||||
A config-driven default (:class:`ScaleToZeroPolicy`) is provided for
|
||||
the common "idle for N minutes with nothing in flight" case.
|
||||
"""
|
||||
|
||||
def should_arm(
|
||||
self,
|
||||
*,
|
||||
transports_quiescable: bool,
|
||||
wake_registered: bool,
|
||||
) -> bool:
|
||||
"""Return whether dormancy may be armed at all.
|
||||
|
||||
Implementations gate on whether transports can be cleanly stood
|
||||
down and a wake path exists, so a gateway never quiesces into a
|
||||
state it cannot resume from.
|
||||
"""
|
||||
...
|
||||
|
||||
def is_idle(
|
||||
self,
|
||||
*,
|
||||
running_turns: int,
|
||||
last_inbound_ts: float,
|
||||
has_background_work: bool,
|
||||
now: float,
|
||||
) -> IdleDecision:
|
||||
"""Return an :class:`IdleDecision` for the supplied facts."""
|
||||
...
|
||||
|
||||
|
||||
class ScaleToZeroPolicy:
|
||||
"""Config-driven idle policy for safe scale-to-zero.
|
||||
|
||||
The default referenced by ``scale_to_zero:`` blocks in ``gateway.yaml``
|
||||
and the ``BotOS(..., idle_policy=...)`` Python surface. It is
|
||||
intentionally minimal and dependency-free so the decision lives in
|
||||
core and is provable in isolation; the wrapper owns the side effects
|
||||
(suspend host, stand transports down, wake endpoint).
|
||||
|
||||
The gateway is considered idle only when *all* guards pass:
|
||||
|
||||
* no in-flight agent turn (``running_turns == 0``),
|
||||
* no live background work (scheduled jobs, delegated subagents,
|
||||
durable outbox drains — ``has_background_work`` is ``False``),
|
||||
* no inbound message for at least ``idle_timeout_minutes``.
|
||||
|
||||
Example::
|
||||
|
||||
ScaleToZeroPolicy(idle_timeout_minutes=5,
|
||||
wake_url="https://my-bot.fly.dev/_wake")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
idle_timeout_minutes: float = 5.0,
|
||||
wake_url: Optional[str] = None,
|
||||
enabled: bool = True,
|
||||
):
|
||||
if idle_timeout_minutes <= 0:
|
||||
raise ValueError(
|
||||
f"idle_timeout_minutes must be > 0, got {idle_timeout_minutes!r}"
|
||||
)
|
||||
self.idle_timeout_minutes = float(idle_timeout_minutes)
|
||||
self.wake_url = wake_url
|
||||
self.enabled = bool(enabled)
|
||||
|
||||
@property
|
||||
def idle_timeout_seconds(self) -> float:
|
||||
return self.idle_timeout_minutes * 60.0
|
||||
|
||||
def should_arm(
|
||||
self,
|
||||
*,
|
||||
transports_quiescable: bool,
|
||||
wake_registered: bool,
|
||||
) -> bool:
|
||||
if not self.enabled:
|
||||
return False
|
||||
# Never arm into a state we cannot resume from.
|
||||
return bool(transports_quiescable and wake_registered)
|
||||
|
||||
def is_idle(
|
||||
self,
|
||||
*,
|
||||
running_turns: int,
|
||||
last_inbound_ts: float,
|
||||
has_background_work: bool,
|
||||
now: float,
|
||||
) -> IdleDecision:
|
||||
if not self.enabled:
|
||||
return IdleDecision(idle=False, reason="scale_to_zero disabled")
|
||||
if running_turns > 0:
|
||||
return IdleDecision(
|
||||
idle=False,
|
||||
reason=f"{running_turns} agent turn(s) in flight",
|
||||
)
|
||||
if has_background_work:
|
||||
return IdleDecision(
|
||||
idle=False,
|
||||
reason="background work in progress",
|
||||
)
|
||||
elapsed = now - last_inbound_ts
|
||||
if elapsed < self.idle_timeout_seconds:
|
||||
remaining = self.idle_timeout_seconds - elapsed
|
||||
return IdleDecision(
|
||||
idle=False,
|
||||
reason=f"last inbound {elapsed:.0f}s ago; {remaining:.0f}s to idle",
|
||||
)
|
||||
return IdleDecision(
|
||||
idle=True,
|
||||
reason=f"idle for {elapsed:.0f}s with nothing in flight",
|
||||
)
|
||||
|
||||
|
||||
# Backward-compatible alias. The canonical name follows the repo's
|
||||
# ``*Protocol`` suffix convention (e.g. ``SendPolicyProtocol``); the old
|
||||
# name is retained so existing imports keep working.
|
||||
GatewayIdlePolicy = GatewayIdlePolicyProtocol
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol Version Negotiation (Issue #2130)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
)
|
||||
@@ -26,7 +26,7 @@ if TYPE_CHECKING:
|
||||
from .parser import parse_schedule
|
||||
from .runner import ScheduleRunner
|
||||
from .loop import ScheduleLoop
|
||||
from .protocols import ScheduleStoreProtocol
|
||||
from .protocols import ScheduleStoreProtocol, JobConditionProtocol, GateResult
|
||||
|
||||
_module_cache = {}
|
||||
|
||||
@@ -69,10 +69,30 @@ def __getattr__(name: str):
|
||||
_module_cache[name] = ConfigYamlScheduleStore
|
||||
return ConfigYamlScheduleStore
|
||||
|
||||
if name == "ScheduleStoreProtocol":
|
||||
from .protocols import ScheduleStoreProtocol
|
||||
_module_cache[name] = ScheduleStoreProtocol
|
||||
return ScheduleStoreProtocol
|
||||
if name in ("ScheduleStoreProtocol", "JobConditionProtocol", "GateResult"):
|
||||
from .protocols import (
|
||||
ScheduleStoreProtocol,
|
||||
JobConditionProtocol,
|
||||
GateResult,
|
||||
)
|
||||
_module_cache["ScheduleStoreProtocol"] = ScheduleStoreProtocol
|
||||
_module_cache["JobConditionProtocol"] = JobConditionProtocol
|
||||
_module_cache["GateResult"] = GateResult
|
||||
return _module_cache[name]
|
||||
|
||||
if name in ("Blueprint", "BlueprintSlot", "BlueprintStoreProtocol"):
|
||||
from .blueprint_defs import Blueprint, BlueprintSlot, BlueprintStoreProtocol
|
||||
_module_cache["Blueprint"] = Blueprint
|
||||
_module_cache["BlueprintSlot"] = BlueprintSlot
|
||||
_module_cache["BlueprintStoreProtocol"] = BlueprintStoreProtocol
|
||||
return _module_cache[name]
|
||||
|
||||
if name in ("Suggestion", "SuggestionStore", "MAX_PENDING_CAP"):
|
||||
from .suggestion_store import Suggestion, SuggestionStore, MAX_PENDING_CAP
|
||||
_module_cache["Suggestion"] = Suggestion
|
||||
_module_cache["SuggestionStore"] = SuggestionStore
|
||||
_module_cache["MAX_PENDING_CAP"] = MAX_PENDING_CAP
|
||||
return _module_cache[name]
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -88,4 +108,12 @@ __all__ = [
|
||||
"ScheduleRunner",
|
||||
"ScheduleLoop",
|
||||
"ScheduleStoreProtocol",
|
||||
"JobConditionProtocol",
|
||||
"GateResult",
|
||||
"Blueprint",
|
||||
"BlueprintSlot",
|
||||
"BlueprintStoreProtocol",
|
||||
"Suggestion",
|
||||
"SuggestionStore",
|
||||
"MAX_PENDING_CAP",
|
||||
]
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Blueprint protocol definitions for PraisonAI Agents.
|
||||
|
||||
This module defines the thin protocol layer for automation blueprints —
|
||||
data structures that carry metadata about parameterized automation
|
||||
templates without any execution logic. Heavy resolution and resource
|
||||
handling lives in ``praisonai.scheduler.blueprint_catalogue``.
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents.scheduler.blueprint_defs import Blueprint, BlueprintSlot
|
||||
|
||||
bp = Blueprint(
|
||||
name="morning-brief",
|
||||
description="Daily morning briefing",
|
||||
slots=[BlueprintSlot(name="hour", type="int", default=8)],
|
||||
prompt_template="Provide a briefing. Focus: {focus}",
|
||||
schedule_template="cron:{minute} {hour} * * {weekdays_expression}",
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
# ── Slot definition ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlueprintSlot:
|
||||
"""A fillable parameter slot in a blueprint template.
|
||||
|
||||
Each slot represents one user-configurable parameter. Slots
|
||||
have types, defaults, and optional choice constraints that the
|
||||
catalogue layer validates at resolution time.
|
||||
"""
|
||||
|
||||
name: str
|
||||
"""Machine-readable slot name (e.g. ``"hour"``, ``"focus"``)."""
|
||||
|
||||
type: str = "str"
|
||||
"""Slot type: ``"int"``, ``"str"``, ``"choice"``, ``"time"``."""
|
||||
|
||||
label: str = ""
|
||||
"""Human-readable label for UI surfaces."""
|
||||
|
||||
default: Any = None
|
||||
"""Default value when the user does not provide one."""
|
||||
|
||||
required: bool = True
|
||||
"""If True, the user must supply a value (unless ``default`` is set)."""
|
||||
|
||||
choices: Optional[List[str]] = None
|
||||
"""Allowed values for ``type="choice"`` slots."""
|
||||
|
||||
description: str = ""
|
||||
"""Help text explaining what this slot controls."""
|
||||
|
||||
|
||||
# ── Blueprint definition ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class Blueprint:
|
||||
"""A parameterized automation template.
|
||||
|
||||
Blueprints are named templates with fillable slots. They carry
|
||||
enough metadata for CLI, YAML, and Python surfaces to present a
|
||||
consistent authoring experience. The catalogue layer resolves
|
||||
slots, materializes prompts, and produces concrete schedule
|
||||
expressions.
|
||||
|
||||
This is a protocol-level dataclass — no execution logic here.
|
||||
"""
|
||||
|
||||
name: str
|
||||
"""Unique blueprint identifier (e.g. ``"morning-brief"``)."""
|
||||
|
||||
version: str = "1.0.0"
|
||||
"""SemVer version string."""
|
||||
|
||||
description: str = ""
|
||||
"""One-paragraph summary shown in listings and help."""
|
||||
|
||||
tags: List[str] = field(default_factory=list)
|
||||
"""Search tags (e.g. ``["daily", "news"]``)."""
|
||||
|
||||
category: str = "general"
|
||||
"""Grouping label for UI surfaces (``"daily"``, ``"monitoring"``, …)."""
|
||||
|
||||
slots: List[BlueprintSlot] = field(default_factory=list)
|
||||
"""Fillable parameters the user can configure."""
|
||||
|
||||
default_deliver: str = ""
|
||||
"""Default delivery target (``"telegram"``, ``"origin"``, etc.)."""
|
||||
|
||||
default_agent: str = ""
|
||||
"""Default agent ID to execute this blueprint."""
|
||||
|
||||
prompt_template: str = ""
|
||||
"""Prompt string with ``{slot_name}`` placeholders filled at resolution."""
|
||||
|
||||
schedule_template: str = ""
|
||||
"""Schedule string with ``{slot_name}`` placeholders.
|
||||
|
||||
Supports a special ``{weekdays_expression}`` placeholder that the
|
||||
catalogue layer converts to a cron day-of-week field.
|
||||
"""
|
||||
|
||||
builtin: bool = True
|
||||
"""True for the built-in catalogue; False for user-provided YAML blueprints."""
|
||||
|
||||
|
||||
# ── Store protocol ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class BlueprintStoreProtocol(Protocol):
|
||||
"""Minimal protocol for discovering and registering blueprints.
|
||||
|
||||
Any object that satisfies this protocol can serve as a blueprint
|
||||
source for the catalogue. The built-in :class:`BlueprintCatalogue`
|
||||
implements this, as could a remote registry or dashboard backend.
|
||||
"""
|
||||
|
||||
def list_blueprints(self, category: Optional[str] = None) -> List[Blueprint]:
|
||||
"""Return all known blueprints, optionally filtered by *category*."""
|
||||
...
|
||||
|
||||
def get_blueprint(self, name: str) -> Optional[Blueprint]:
|
||||
"""Return the blueprint named *name*, or ``None`` if not found."""
|
||||
...
|
||||
|
||||
def register_blueprint(self, bp: Blueprint) -> None:
|
||||
"""Register (or replace) *bp* in the store."""
|
||||
...
|
||||
@@ -166,6 +166,18 @@ class ScheduleJob:
|
||||
to a channel bot (e.g. Telegram chat).
|
||||
origin: Optional original delivery target where the job was created
|
||||
(for "origin" token resolution).
|
||||
pre_run: Optional cheap, deterministic pre-run gate evaluated before
|
||||
the (expensive) model turn — a shell/python command or a
|
||||
registered gate name. When it reports "nothing to do" the
|
||||
tick is recorded as ``skipped`` with no tokens spent and no
|
||||
delivery. When it reports "go", any output it prints is
|
||||
appended to ``message`` as context. A cost/efficiency gate,
|
||||
distinct from the wrapper's safety ``RunPolicy``.
|
||||
condition: Optional natural-language / expression label describing the
|
||||
gate's intent. Advisory metadata only — it is round-tripped
|
||||
and surfaced for readability but is NOT itself enforced; the
|
||||
default :class:`ShellConditionGate` gates on ``pre_run``. A
|
||||
custom ``condition_resolver`` may interpret it.
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
@@ -180,6 +192,8 @@ class ScheduleJob:
|
||||
last_run_at: Optional[float] = None
|
||||
delivery: Optional[DeliveryTarget] = None
|
||||
origin: Optional[DeliveryTarget] = None
|
||||
pre_run: Optional[str] = None
|
||||
condition: Optional[str] = None
|
||||
|
||||
# ── serialisation ────────────────────────────────────────────────
|
||||
|
||||
@@ -200,6 +214,10 @@ class ScheduleJob:
|
||||
d["delivery"] = self.delivery.to_dict()
|
||||
if self.origin is not None:
|
||||
d["origin"] = self.origin.to_dict()
|
||||
if self.pre_run is not None:
|
||||
d["pre_run"] = self.pre_run
|
||||
if self.condition is not None:
|
||||
d["condition"] = self.condition
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
@@ -220,4 +238,6 @@ class ScheduleJob:
|
||||
last_run_at=d.get("last_run_at"),
|
||||
delivery=DeliveryTarget.from_dict(delivery_data) if isinstance(delivery_data, dict) else None,
|
||||
origin=DeliveryTarget.from_dict(origin_data) if isinstance(origin_data, dict) else None,
|
||||
pre_run=d.get("pre_run"),
|
||||
condition=d.get("condition"),
|
||||
)
|
||||
@@ -7,9 +7,49 @@ Any object implementing these methods can be used as a schedule store.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass
|
||||
class GateResult:
|
||||
"""Decision returned by a job's pre-run condition gate.
|
||||
|
||||
Attributes:
|
||||
run: ``True`` to proceed with the (expensive) model turn,
|
||||
``False`` to skip this tick — no tokens spent, no delivery.
|
||||
context: Optional text produced by the gate. When present and
|
||||
``run`` is ``True``, it is appended to the job message so
|
||||
the same cheap check both *gates* the run and *seeds* it
|
||||
with context (e.g. the new emails the agent should summarise).
|
||||
reason: Optional human-readable note recorded with the run
|
||||
(e.g. ``"pre-run gate: nothing to do"``).
|
||||
"""
|
||||
|
||||
run: bool = True
|
||||
context: Optional[str] = None
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class JobConditionProtocol(Protocol):
|
||||
"""Protocol for a cheap, deterministic pre-run gate on a scheduled job.
|
||||
|
||||
A gate decides *whether* a job's model turn should happen at all — a
|
||||
cost/efficiency concern, complementary to (and distinct from) the
|
||||
wrapper's ``RunPolicy``, which is a *safety* gate on *what* a run may do.
|
||||
|
||||
Implementations live in the wrapper (e.g. a shell-command gate) or in a
|
||||
deployment (a Python callable, an MCP/tool probe). The core only owns this
|
||||
contract so every front-end (Python ``ScheduleJob``, YAML loader,
|
||||
agent-callable schedule tools) shares one shape.
|
||||
"""
|
||||
|
||||
def should_run(self, job: Any) -> "GateResult":
|
||||
"""Return a :class:`GateResult` deciding whether ``job`` should run."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ScheduleStoreProtocol(Protocol):
|
||||
"""Protocol for schedule persistence backends.
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Suggestion store — consent-first proposals for proactive automations.
|
||||
|
||||
This module provides a thread-safe, atomic JSON-backed store for
|
||||
pending automation suggestions. The framework may *propose* an
|
||||
automation (e.g., after detecting a recurring manual ask), but it
|
||||
only becomes a real scheduled job when the user explicitly accepts it.
|
||||
|
||||
**"Nothing is ever auto-created."**
|
||||
|
||||
The store enforces:
|
||||
|
||||
* **Max-pending cap** (default 20) — prevents flooding.
|
||||
* **Dedup window** (24 h) — the same blueprint+slots won't be
|
||||
suggested twice within the window.
|
||||
* **Atomic writes** — write to temp file, then replace.
|
||||
|
||||
Storage location: ``~/.praisonai/suggestions.json``
|
||||
|
||||
Usage::
|
||||
|
||||
from praisonaiagents.scheduler.suggestion_store import SuggestionStore, Suggestion
|
||||
|
||||
store = SuggestionStore()
|
||||
store.add(Suggestion(
|
||||
id="sug_abc123",
|
||||
blueprint_name="morning-brief",
|
||||
slots={"hour": 8, "weekdays": "mon-fri"},
|
||||
reason="Daily request pattern detected",
|
||||
))
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
MAX_PENDING_CAP: int = 20
|
||||
"""Maximum number of pending (undismissed, unaccepted) suggestions."""
|
||||
|
||||
DEDUP_WINDOW_SEC: float = 3600 * 24
|
||||
"""Dedup window: don't suggest the same thing twice within 24 hours."""
|
||||
|
||||
DEFAULT_TTL_SEC: float = 3600 * 24 * 3
|
||||
"""Default TTL for a suggestion (3 days). Expired suggestions are
|
||||
pruned from the pending list."""
|
||||
|
||||
|
||||
# ── Suggestion dataclass ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class Suggestion:
|
||||
"""A proposed automation that the user has not yet acted on.
|
||||
|
||||
Only becomes a real schedule job when the user accepts it via
|
||||
:meth:`SuggestionStore.accept`.
|
||||
"""
|
||||
|
||||
id: str
|
||||
"""Unique suggestion identifier (e.g. ``"sug_<random>"``)."""
|
||||
|
||||
blueprint_name: str
|
||||
"""Which blueprint triggered this suggestion."""
|
||||
|
||||
slots: Dict[str, Any] = field(default_factory=dict)
|
||||
"""Filled-in slot values for the blueprint."""
|
||||
|
||||
deliver: str = ""
|
||||
"""Suggested delivery target."""
|
||||
|
||||
reason: str = ""
|
||||
"""Human-readable explanation of why this was suggested."""
|
||||
|
||||
created_at: float = field(default_factory=time.time)
|
||||
"""Unix timestamp when the suggestion was created."""
|
||||
|
||||
expires_at: float = 0.0
|
||||
"""Unix timestamp after which the suggestion auto-expires.
|
||||
``0`` means no expiry."""
|
||||
|
||||
dismissed: bool = False
|
||||
"""True if the user explicitly dismissed this suggestion."""
|
||||
|
||||
accepted: bool = False
|
||||
"""True if the user accepted this suggestion (job has been created)."""
|
||||
|
||||
|
||||
# ── Suggestion store ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SuggestionStore:
|
||||
"""Atomic JSON-backed store for pending automation suggestions.
|
||||
|
||||
Thread-safe via :class:`threading.RLock`. Suggestions are persisted
|
||||
to ``~/.praisonai/suggestions.json`` with atomic writes (write to
|
||||
``.tmp``, then ``os.replace``).
|
||||
"""
|
||||
|
||||
def __init__(self, path: Optional[str] = None):
|
||||
if path is None:
|
||||
from praisonaiagents.paths import get_data_dir
|
||||
path = str(get_data_dir() / "suggestions.json")
|
||||
self._path = path
|
||||
self._lock = threading.RLock()
|
||||
self._suggestions: Dict[str, Suggestion] = {}
|
||||
self._load()
|
||||
|
||||
# -- persistence -----------------------------------------------------------
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load existing suggestions from disk (best-effort)."""
|
||||
if not os.path.exists(self._path):
|
||||
self._suggestions = {}
|
||||
return
|
||||
try:
|
||||
with open(self._path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
new_suggestions = {}
|
||||
for sid, d in data.items():
|
||||
if isinstance(d, dict):
|
||||
try:
|
||||
new_suggestions[sid] = Suggestion(**d)
|
||||
except TypeError:
|
||||
pass # skip malformed entries
|
||||
self._suggestions = new_suggestions
|
||||
except (json.JSONDecodeError, OSError):
|
||||
self._suggestions = {} # corrupt file → start fresh
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Atomically persist the current state to disk."""
|
||||
parent = os.path.dirname(self._path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
data = {sid: asdict(s) for sid, s in self._suggestions.items()}
|
||||
tmp = self._path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp, self._path)
|
||||
|
||||
# -- public API ------------------------------------------------------------
|
||||
|
||||
def add(self, suggestion: Suggestion) -> bool:
|
||||
"""Add a suggestion to the store.
|
||||
|
||||
Returns:
|
||||
``True`` if the suggestion was added, ``False`` if it was
|
||||
rejected due to the pending cap or dedup window.
|
||||
"""
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
|
||||
# Only count active (non-dismissed, non-accepted, non-expired)
|
||||
# suggestions toward the cap and dedup window.
|
||||
active = [
|
||||
s for s in self._suggestions.values()
|
||||
if not s.dismissed and not s.accepted
|
||||
and (s.expires_at == 0 or s.expires_at > now)
|
||||
]
|
||||
if len(active) >= MAX_PENDING_CAP:
|
||||
return False
|
||||
|
||||
# Dedup check: same blueprint + same slots within the window
|
||||
for existing in active:
|
||||
if (
|
||||
existing.blueprint_name == suggestion.blueprint_name
|
||||
and existing.slots == suggestion.slots
|
||||
and (now - existing.created_at) < DEDUP_WINDOW_SEC
|
||||
):
|
||||
return False
|
||||
|
||||
self._suggestions[suggestion.id] = suggestion
|
||||
self._save()
|
||||
return True
|
||||
|
||||
def get(self, suggestion_id: str) -> Optional[Suggestion]:
|
||||
"""Return a suggestion by ID, or ``None``."""
|
||||
with self._lock:
|
||||
return self._suggestions.get(suggestion_id)
|
||||
|
||||
def list_pending(self) -> List[Suggestion]:
|
||||
"""Return all undismissed, unaccepted, non-expired suggestions."""
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
return [
|
||||
s for s in self._suggestions.values()
|
||||
if not s.dismissed and not s.accepted
|
||||
and (s.expires_at == 0 or s.expires_at > now)
|
||||
]
|
||||
|
||||
def accept(self, suggestion_id: str) -> bool:
|
||||
"""Mark a suggestion as accepted.
|
||||
|
||||
Returns:
|
||||
``False`` if the suggestion was not found.
|
||||
"""
|
||||
with self._lock:
|
||||
s = self._suggestions.get(suggestion_id)
|
||||
if s is None:
|
||||
return False
|
||||
s.accepted = True
|
||||
self._save()
|
||||
return True
|
||||
|
||||
def dismiss(self, suggestion_id: str) -> bool:
|
||||
"""Mark a suggestion as dismissed (user declined).
|
||||
|
||||
Returns:
|
||||
``False`` if the suggestion was not found.
|
||||
"""
|
||||
with self._lock:
|
||||
s = self._suggestions.get(suggestion_id)
|
||||
if s is None:
|
||||
return False
|
||||
s.dismissed = True
|
||||
self._save()
|
||||
return True
|
||||
|
||||
def prune_expired(self) -> int:
|
||||
"""Remove expired suggestions. Returns count of pruned entries."""
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
before = len(self._suggestions)
|
||||
self._suggestions = {
|
||||
sid: s for sid, s in self._suggestions.items()
|
||||
if s.expires_at == 0 or s.expires_at > now
|
||||
}
|
||||
pruned = before - len(self._suggestions)
|
||||
if pruned:
|
||||
self._save()
|
||||
return pruned
|
||||
@@ -37,7 +37,7 @@ from dataclasses import asdict, dataclass
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..gateway.protocols import OutboundMessengerProtocol
|
||||
from ..gateway.protocols import OutboundMessengerProtocol, SendPolicyProtocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -195,6 +195,40 @@ def clear_outbound_messenger(token: Token) -> None:
|
||||
_MESSENGER.set(None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outbound send-policy guard (Issue #2226)
|
||||
#
|
||||
# An optional task-local policy authorising where an agent may proactively
|
||||
# send. The running gateway/bot (or a Python ``Bot(send_policy=...)`` caller)
|
||||
# registers it; the built-in ``send_message`` tool consults it *before*
|
||||
# dispatch. Absent a policy, today's behaviour is preserved (allow-all).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SEND_POLICY: ContextVar[Optional["SendPolicyProtocol"]] = ContextVar(
|
||||
"praisonai_send_policy", default=None
|
||||
)
|
||||
|
||||
|
||||
def register_send_policy(
|
||||
policy: Optional["SendPolicyProtocol"],
|
||||
) -> Token:
|
||||
"""Register the active outbound send-policy for this task. Returns a token."""
|
||||
return _SEND_POLICY.set(policy)
|
||||
|
||||
|
||||
def get_send_policy() -> Optional["SendPolicyProtocol"]:
|
||||
"""Return the active send-policy, or ``None`` if no policy is configured."""
|
||||
return _SEND_POLICY.get()
|
||||
|
||||
|
||||
def clear_send_policy(token: Token) -> None:
|
||||
"""Restore the previous send-policy using the token from register."""
|
||||
try:
|
||||
_SEND_POLICY.reset(token)
|
||||
except (LookupError, ValueError):
|
||||
_SEND_POLICY.set(None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway event loop registry (Issue #2183)
|
||||
#
|
||||
@@ -235,6 +269,9 @@ __all__ = [
|
||||
"register_outbound_messenger",
|
||||
"get_outbound_messenger",
|
||||
"clear_outbound_messenger",
|
||||
"register_send_policy",
|
||||
"get_send_policy",
|
||||
"clear_send_policy",
|
||||
"register_gateway_loop",
|
||||
"get_gateway_loop",
|
||||
"clear_gateway_loop",
|
||||
|
||||
@@ -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",
|
||||
@@ -51,6 +53,8 @@ __all__ = [
|
||||
"SkillInvocationPolicyProtocol",
|
||||
"SkillMutatorProtocol",
|
||||
"SkillActivationProtocol",
|
||||
"SkillReviewProtocol",
|
||||
"DefaultSkillReviewPolicy",
|
||||
# Events
|
||||
"SkillDiscoveredEvent",
|
||||
"SkillActivatedEvent",
|
||||
@@ -80,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
|
||||
@@ -105,6 +113,10 @@ def __getattr__(name: str):
|
||||
from .protocols import SkillSourceProtocol, SkillInvocationPolicyProtocol, SkillMutatorProtocol
|
||||
return locals()[name]
|
||||
|
||||
if name in ("SkillReviewProtocol", "DefaultSkillReviewPolicy"):
|
||||
from .protocols import SkillReviewProtocol, DefaultSkillReviewPolicy
|
||||
return locals()[name]
|
||||
|
||||
if name == "SkillActivationProtocol":
|
||||
from .activation import SkillActivationProtocol
|
||||
return SkillActivationProtocol
|
||||
|
||||
@@ -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)
|
||||
@@ -35,11 +35,16 @@ class SkillManager:
|
||||
manager.activate(skill)
|
||||
"""
|
||||
|
||||
def __init__(self, enforcement_level: Optional[EnforcementLevel] = None):
|
||||
def __init__(self, enforcement_level: Optional[EnforcementLevel] = None,
|
||||
write_approval: Optional[bool] = None):
|
||||
"""Initialize the SkillManager.
|
||||
|
||||
Args:
|
||||
enforcement_level: Capability enforcement level (defaults to environment-based)
|
||||
write_approval: When True, skill mutations are staged for human
|
||||
approval by default (``propose=True``). When None, resolved from
|
||||
the ``SKILL_WRITE_APPROVAL`` environment variable (default True
|
||||
for safe-by-default behaviour).
|
||||
"""
|
||||
self._skills: Dict[str, LoadedSkill] = {}
|
||||
self._loader = SkillLoader()
|
||||
@@ -51,6 +56,19 @@ class SkillManager:
|
||||
enforcement_level = self._get_default_enforcement_level()
|
||||
self._validator = CapabilityValidator(enforcement_level)
|
||||
|
||||
# Resolve safe-by-default write-approval policy.
|
||||
if write_approval is None:
|
||||
write_approval = self._get_default_write_approval()
|
||||
self._write_approval = write_approval
|
||||
|
||||
# Cap the pending-mutation store to bound resource use (configurable
|
||||
# via SKILL_MAX_PENDING; defaults to 100).
|
||||
import os
|
||||
try:
|
||||
self._max_pending = max(1, int(os.getenv('SKILL_MAX_PENDING', '100')))
|
||||
except (TypeError, ValueError):
|
||||
self._max_pending = 100
|
||||
|
||||
@property
|
||||
def skills(self) -> List[LoadedSkill]:
|
||||
"""Get all loaded skills."""
|
||||
@@ -226,6 +244,7 @@ class SkillManager:
|
||||
body = render_shell_blocks(
|
||||
body, enabled=shell_exec, shell=shell, cwd=skill_dir,
|
||||
)
|
||||
self._record_use(skill)
|
||||
return body
|
||||
|
||||
def to_prompt(self) -> str:
|
||||
@@ -253,6 +272,13 @@ class SkillManager:
|
||||
if not skill.is_activated:
|
||||
self.activate(skill)
|
||||
|
||||
# Only count a genuinely usable skill: a skill that fails to activate
|
||||
# (instructions is None) must not accrue use telemetry, otherwise a
|
||||
# broken skill looks active to the lifecycle curator.
|
||||
if skill.instructions is None:
|
||||
return None
|
||||
|
||||
self._record_use(skill)
|
||||
return skill.instructions
|
||||
|
||||
def load_resources(self, name: str) -> bool:
|
||||
@@ -271,17 +297,32 @@ class SkillManager:
|
||||
self._loader.load_all_resources(skill)
|
||||
return True
|
||||
|
||||
def create_skill(self, name: str, content: str, category: str = None) -> dict:
|
||||
def create_skill(self, name: str, content: str, category: str = None,
|
||||
agent_created: bool = True,
|
||||
propose: Optional[bool] = None) -> dict:
|
||||
"""Create a new skill with the given content.
|
||||
|
||||
Args:
|
||||
name: Skill name (must be valid identifier)
|
||||
content: Skill instructions/content for SKILL.md
|
||||
category: Optional skill category
|
||||
agent_created: Provenance flag marking the skill as agent-authored.
|
||||
Defaults to True since skills created via this API originate
|
||||
from the agent's self-improvement loop. Lifecycle curator
|
||||
plugins only ever touch agent-created skills.
|
||||
propose: If True, stage the mutation for human approval instead of
|
||||
writing to disk. Defaults to the manager's ``write_approval``
|
||||
policy (safe-by-default). Pass ``propose=False`` to write
|
||||
directly in trusted/local contexts.
|
||||
|
||||
Returns:
|
||||
Dict with success status and skill info
|
||||
"""
|
||||
if self._should_propose(propose):
|
||||
return self._stage_pending(
|
||||
"create", name, content=content, category=category,
|
||||
agent_created=agent_created,
|
||||
)
|
||||
try:
|
||||
# Validate name
|
||||
if not self._validate_skill_name(name):
|
||||
@@ -295,28 +336,27 @@ class SkillManager:
|
||||
if len(content) > 100_000:
|
||||
return {"success": False, "error": "Skill content exceeds maximum size (100KB)"}
|
||||
|
||||
# Create skill directory
|
||||
skill_dirs = get_default_skill_dirs()
|
||||
base_dir = skill_dirs[0] if skill_dirs else "~/.praisonai/skills"
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
base_path = Path(base_dir).expanduser()
|
||||
base_path.mkdir(parents=True, exist_ok=True)
|
||||
# Create skill directory (same base as the pending store target)
|
||||
base_path = self._skills_base_dir()
|
||||
|
||||
skill_path = base_path / name
|
||||
skill_path.mkdir(exist_ok=True)
|
||||
|
||||
# Write SKILL.md with frontmatter
|
||||
# Write SKILL.md with frontmatter (incl. provenance + usage telemetry)
|
||||
created_at = self._now_iso()
|
||||
skill_content = f"""---
|
||||
name: {name}
|
||||
version: 1.0.0
|
||||
"""
|
||||
if category:
|
||||
skill_content += f"category: {category}\n"
|
||||
skill_content += f"""description: Generated skill
|
||||
author: agent
|
||||
---
|
||||
skill_content += "description: Generated skill\n"
|
||||
skill_content += "author: agent\n"
|
||||
skill_content += f"agent-created: {str(bool(agent_created)).lower()}\n"
|
||||
skill_content += f'created-at: "{created_at}"\n'
|
||||
skill_content += "use-count: 0\n"
|
||||
skill_content += "patch-count: 0\n"
|
||||
skill_content += f"""---
|
||||
|
||||
{content}
|
||||
"""
|
||||
@@ -334,16 +374,22 @@ author: agent
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Error creating skill: {str(e)}"}
|
||||
|
||||
def edit_skill(self, name: str, content: str) -> dict:
|
||||
def edit_skill(self, name: str, content: str,
|
||||
propose: Optional[bool] = None) -> dict:
|
||||
"""Edit an existing skill's content (full rewrite).
|
||||
|
||||
Args:
|
||||
name: Skill name to edit
|
||||
content: New skill content
|
||||
propose: If True, stage the mutation for human approval instead of
|
||||
writing to disk. Defaults to the manager's ``write_approval``
|
||||
policy. Pass ``propose=False`` to write directly.
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
if self._should_propose(propose):
|
||||
return self._stage_pending("edit", name, content=content)
|
||||
try:
|
||||
skill = self.get_skill(name)
|
||||
if not skill:
|
||||
@@ -364,6 +410,9 @@ author: agent
|
||||
with open(skill_file, 'r', encoding='utf-8') as f:
|
||||
existing_content = f.read()
|
||||
|
||||
# Snapshot prior content so a broken mutation can be rolled back.
|
||||
self._save_backup(skill.properties.path, existing_content)
|
||||
|
||||
# Extract frontmatter (preserve verbatim, only strip leading fence)
|
||||
frontmatter = ""
|
||||
if existing_content.startswith('---\n'):
|
||||
@@ -379,6 +428,7 @@ author: agent
|
||||
# Reload the skill
|
||||
skill.instructions = None # Clear cached content
|
||||
self.activate(skill)
|
||||
self._record_patch(skill)
|
||||
|
||||
return {"success": True, "skill": name}
|
||||
|
||||
@@ -386,7 +436,8 @@ author: agent
|
||||
return {"success": False, "error": f"Error editing skill: {str(e)}"}
|
||||
|
||||
def patch_skill(self, name: str, old_string: str, new_string: str,
|
||||
file_path: str = None, replace_all: bool = False) -> dict:
|
||||
file_path: str = None, replace_all: bool = False,
|
||||
propose: Optional[bool] = None) -> dict:
|
||||
"""Apply a patch to a skill using fuzzy find-and-replace.
|
||||
|
||||
Args:
|
||||
@@ -395,10 +446,18 @@ author: agent
|
||||
new_string: Replacement string
|
||||
file_path: Optional relative path within skill (defaults to SKILL.md)
|
||||
replace_all: Replace all occurrences
|
||||
propose: If True, stage the mutation for human approval instead of
|
||||
writing to disk. Defaults to the manager's ``write_approval``
|
||||
policy. Pass ``propose=False`` to apply directly.
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
if self._should_propose(propose):
|
||||
return self._stage_pending(
|
||||
"patch", name, old_string=old_string, new_string=new_string,
|
||||
file_path=file_path, replace_all=replace_all,
|
||||
)
|
||||
try:
|
||||
skill = self.get_skill(name)
|
||||
if not skill:
|
||||
@@ -436,12 +495,18 @@ author: agent
|
||||
# Replace only first occurrence
|
||||
new_content = content.replace(old_string, new_string, 1)
|
||||
|
||||
is_skill_md = file_path is None or file_path == "SKILL.md"
|
||||
# Snapshot the SKILL.md before mutating so it can be rolled back.
|
||||
if is_skill_md:
|
||||
self._save_backup(skill.properties.path, content)
|
||||
|
||||
self._write_skill_atomically(target_file, new_content)
|
||||
|
||||
# Clear cached content if SKILL.md was modified
|
||||
if file_path is None or file_path == "SKILL.md":
|
||||
if is_skill_md:
|
||||
skill.instructions = None
|
||||
self.activate(skill)
|
||||
self._record_patch(skill)
|
||||
|
||||
return {"success": True, "skill": name, "replacements": 1}
|
||||
else:
|
||||
@@ -450,15 +515,29 @@ author: agent
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Error patching skill: {str(e)}"}
|
||||
|
||||
def delete_skill(self, name: str) -> dict:
|
||||
"""Delete a skill and its directory.
|
||||
def delete_skill(self, name: str, hard: bool = False,
|
||||
propose: Optional[bool] = None) -> dict:
|
||||
"""Remove a skill.
|
||||
|
||||
By default this is a *recoverable* archive: the skill directory is
|
||||
moved aside into the archive store and can be restored later. This
|
||||
keeps the self-improvement loop safe — a skill is never lost. Pass
|
||||
``hard=True`` to permanently delete the directory (legacy behaviour).
|
||||
|
||||
Args:
|
||||
name: Skill name to delete
|
||||
hard: If True, permanently remove the directory (unrecoverable)
|
||||
propose: If True, stage the deletion for human approval instead of
|
||||
removing from disk. Defaults to the manager's ``write_approval``
|
||||
policy. Pass ``propose=False`` to delete directly.
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
if self._should_propose(propose):
|
||||
return self._stage_pending("delete", name, hard=hard)
|
||||
if not hard:
|
||||
return self.archive_skill(name)
|
||||
try:
|
||||
skill = self.get_skill(name)
|
||||
if not skill:
|
||||
@@ -476,18 +555,164 @@ author: agent
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Error deleting skill: {str(e)}"}
|
||||
|
||||
def archive_skill(self, name: str) -> dict:
|
||||
"""Archive a skill (recoverable, never hard-deleted).
|
||||
|
||||
Moves the skill directory into the archive store and drops it from
|
||||
the active in-memory index. Use :meth:`restore_skill` to recover it.
|
||||
Lifecycle curator plugins call this for agent-created skills that have
|
||||
gone stale.
|
||||
|
||||
Args:
|
||||
name: Skill name to archive
|
||||
|
||||
Returns:
|
||||
Dict with success status and archive path
|
||||
"""
|
||||
try:
|
||||
skill = self.get_skill(name)
|
||||
if not skill:
|
||||
return {"success": False, "error": f"Skill '{name}' not found"}
|
||||
|
||||
if not skill.properties.path:
|
||||
return {"success": False, "error": f"Cannot archive skill '{name}' - no path available"}
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
archive_dir = self._archive_dir()
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = archive_dir / name
|
||||
# Avoid clobbering a previous archive of the same name.
|
||||
if dest.exists():
|
||||
from datetime import datetime, timezone
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||||
dest = archive_dir / f"{name}.{stamp}"
|
||||
|
||||
shutil.move(str(skill.properties.path), str(dest))
|
||||
self._skills.pop(name, None)
|
||||
|
||||
return {"success": True, "skill": name, "archive_path": str(dest)}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Error archiving skill: {str(e)}"}
|
||||
|
||||
def restore_skill(self, name: str, skill_dir: Optional[str] = None) -> dict:
|
||||
"""Restore a previously archived skill back to active use.
|
||||
|
||||
Args:
|
||||
name: Archived skill name
|
||||
skill_dir: Optional destination directory (defaults to the first
|
||||
default skill dir)
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
try:
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
archive_dir = self._archive_dir()
|
||||
src = archive_dir / name
|
||||
if not src.exists() or not src.is_dir():
|
||||
return {"success": False, "error": f"Archived skill '{name}' not found"}
|
||||
|
||||
if skill_dir:
|
||||
base_path = Path(skill_dir).expanduser()
|
||||
else:
|
||||
skill_dirs = get_default_skill_dirs()
|
||||
base_dir = skill_dirs[0] if skill_dirs else "~/.praisonai/skills"
|
||||
base_path = Path(base_dir).expanduser()
|
||||
base_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dest = base_path / name
|
||||
if dest.exists():
|
||||
return {"success": False, "error": f"Skill '{name}' already exists at destination"}
|
||||
|
||||
shutil.move(str(src), str(dest))
|
||||
loaded = self.add_skill(str(dest))
|
||||
if loaded:
|
||||
return {"success": True, "skill": name, "path": str(dest)}
|
||||
return {"success": False, "error": "Failed to load restored skill"}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Error restoring skill: {str(e)}"}
|
||||
|
||||
def list_archived_skills(self) -> List[str]:
|
||||
"""List the names of skills currently in the archive store."""
|
||||
archive_dir = self._archive_dir()
|
||||
if not archive_dir.exists():
|
||||
return []
|
||||
try:
|
||||
return sorted(
|
||||
p.name for p in archive_dir.iterdir() if p.is_dir()
|
||||
)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
def rollback_skill(self, name: str) -> dict:
|
||||
"""Roll a skill's SKILL.md back to its content before the last mutation.
|
||||
|
||||
Restores the snapshot saved by the most recent ``edit_skill`` or
|
||||
``patch_skill`` call, undoing a mutation that broke a previously
|
||||
working skill.
|
||||
|
||||
Note: this is a *single-step* undo. Only the most recent mutation is
|
||||
snapshotted; sequential ``edit``/``patch`` calls overwrite the prior
|
||||
snapshot, so rollback cannot step back more than one mutation.
|
||||
|
||||
Args:
|
||||
name: Skill name to roll back
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
try:
|
||||
skill = self.get_skill(name)
|
||||
if not skill:
|
||||
return {"success": False, "error": f"Skill '{name}' not found"}
|
||||
|
||||
if not skill.properties.path:
|
||||
return {"success": False, "error": f"Cannot roll back skill '{name}' - no path available"}
|
||||
|
||||
backup_file = self._backup_path(skill.properties.path)
|
||||
if not backup_file.exists():
|
||||
return {"success": False, "error": f"No rollback snapshot for skill '{name}'"}
|
||||
|
||||
previous = backup_file.read_text(encoding="utf-8")
|
||||
skill_file = skill.properties.path / "SKILL.md"
|
||||
self._write_skill_atomically(skill_file, previous)
|
||||
|
||||
# Reload from the restored content.
|
||||
skill.instructions = None
|
||||
self.activate(skill)
|
||||
|
||||
return {"success": True, "skill": name}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Error rolling back skill: {str(e)}"}
|
||||
|
||||
def write_skill_file(self, name: str, file_path: str, file_content: str) -> dict:
|
||||
def write_skill_file(self, name: str, file_path: str, file_content: str,
|
||||
propose: Optional[bool] = None) -> dict:
|
||||
"""Write a file within a skill's directory.
|
||||
|
||||
Args:
|
||||
name: Skill name
|
||||
file_path: Relative path within skill (must be under allowed subdirs)
|
||||
file_content: File content to write
|
||||
propose: If True, stage the write for human approval instead of
|
||||
writing to disk. Defaults to the manager's ``write_approval``
|
||||
policy. Pass ``propose=False`` to write directly.
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
if self._should_propose(propose):
|
||||
return self._stage_pending(
|
||||
"write_file", name, file_path=file_path,
|
||||
file_content=file_content,
|
||||
)
|
||||
try:
|
||||
skill = self.get_skill(name)
|
||||
if not skill:
|
||||
@@ -525,16 +750,22 @@ author: agent
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Error writing skill file: {str(e)}"}
|
||||
|
||||
def remove_skill_file(self, name: str, file_path: str) -> dict:
|
||||
def remove_skill_file(self, name: str, file_path: str,
|
||||
propose: Optional[bool] = None) -> dict:
|
||||
"""Remove a file from a skill's directory.
|
||||
|
||||
Args:
|
||||
name: Skill name
|
||||
file_path: Relative path within skill to remove
|
||||
propose: If True, stage the removal for human approval instead of
|
||||
removing from disk. Defaults to the manager's ``write_approval``
|
||||
policy. Pass ``propose=False`` to remove directly.
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
if self._should_propose(propose):
|
||||
return self._stage_pending("remove_file", name, file_path=file_path)
|
||||
try:
|
||||
skill = self.get_skill(name)
|
||||
if not skill:
|
||||
@@ -560,6 +791,117 @@ author: agent
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Error removing skill file: {str(e)}"}
|
||||
|
||||
@staticmethod
|
||||
def _now_iso() -> str:
|
||||
"""Return the current UTC time as an ISO 8601 string."""
|
||||
from datetime import datetime, timezone
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def _archive_dir(self):
|
||||
"""Directory where archived skills are stored (recoverable)."""
|
||||
from pathlib import Path
|
||||
skill_dirs = get_default_skill_dirs()
|
||||
base_dir = skill_dirs[0] if skill_dirs else "~/.praisonai/skills"
|
||||
return Path(base_dir).expanduser().parent / "skills_archive"
|
||||
|
||||
@staticmethod
|
||||
def _backup_path(skill_path):
|
||||
"""Path to the rollback snapshot for a skill's SKILL.md."""
|
||||
return skill_path / ".skill.bak"
|
||||
|
||||
def _save_backup(self, skill_path, content: str) -> None:
|
||||
"""Persist a rollback snapshot of the current SKILL.md content."""
|
||||
try:
|
||||
self._write_skill_atomically(self._backup_path(skill_path), content)
|
||||
except Exception:
|
||||
logger.debug("Failed to save rollback snapshot for %s", skill_path, exc_info=True)
|
||||
|
||||
def _record_use(self, skill) -> None:
|
||||
"""Increment in-memory usage telemetry and persist to frontmatter."""
|
||||
try:
|
||||
props = skill.properties
|
||||
props.use_count = (props.use_count or 0) + 1
|
||||
props.last_used = self._now_iso()
|
||||
self._update_frontmatter_fields(
|
||||
props.path,
|
||||
{"use-count": props.use_count, "last-used": props.last_used},
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to record skill use telemetry", exc_info=True)
|
||||
|
||||
def _record_patch(self, skill) -> None:
|
||||
"""Increment in-memory patch telemetry and persist to frontmatter."""
|
||||
try:
|
||||
props = skill.properties
|
||||
props.patch_count = (props.patch_count or 0) + 1
|
||||
self._update_frontmatter_fields(
|
||||
props.path, {"patch-count": props.patch_count}
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to record skill patch telemetry", exc_info=True)
|
||||
|
||||
def _update_frontmatter_fields(self, skill_path, fields: dict) -> None:
|
||||
"""Update or insert simple scalar keys in a SKILL.md frontmatter block.
|
||||
|
||||
Operates line-wise so existing (possibly hand-authored) frontmatter is
|
||||
preserved verbatim. No-op if there is no path or no frontmatter.
|
||||
"""
|
||||
if skill_path is None or not fields:
|
||||
return
|
||||
from .parser import find_skill_md
|
||||
skill_md = find_skill_md(skill_path)
|
||||
if skill_md is None:
|
||||
return
|
||||
try:
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return
|
||||
if not content.startswith("---"):
|
||||
return
|
||||
# Split only on a fence that sits on its own line so embedded "---"
|
||||
# sequences inside frontmatter values (e.g. "foo---bar") cannot
|
||||
# fragment the block and corrupt the file. The opening fence is the
|
||||
# leading "---"; the closing fence is the next "\n---\n".
|
||||
after_open = content[3:]
|
||||
fm_body, sep, body = after_open.partition("\n---\n")
|
||||
if not sep:
|
||||
return
|
||||
|
||||
def _fmt(value):
|
||||
# Quote timestamp-like strings so YAML preserves them as strings
|
||||
# (e.g. the 'T' separator) rather than coercing to a datetime.
|
||||
if isinstance(value, str) and (":" in value or "-" in value):
|
||||
return f'"{value}"'
|
||||
return value
|
||||
|
||||
fm_lines = fm_body.split("\n")
|
||||
remaining = dict(fields)
|
||||
new_lines = []
|
||||
for line in fm_lines:
|
||||
stripped = line.strip()
|
||||
replaced = False
|
||||
if stripped and ":" in stripped and not stripped.startswith("#"):
|
||||
key = stripped.split(":", 1)[0].strip()
|
||||
if key in remaining:
|
||||
new_lines.append(f"{key}: {_fmt(remaining.pop(key))}")
|
||||
replaced = True
|
||||
if not replaced:
|
||||
new_lines.append(line)
|
||||
|
||||
# Insert any keys that were not already present, before the trailing
|
||||
# blank line that precedes the closing fence.
|
||||
for key, value in remaining.items():
|
||||
insert_at = len(new_lines)
|
||||
while insert_at > 0 and new_lines[insert_at - 1].strip() == "":
|
||||
insert_at -= 1
|
||||
new_lines.insert(insert_at, f"{key}: {_fmt(value)}")
|
||||
|
||||
new_content = "---" + "\n".join(new_lines) + "\n---\n" + body
|
||||
try:
|
||||
self._write_skill_atomically(skill_md, new_content)
|
||||
except Exception:
|
||||
logger.debug("Failed to persist frontmatter telemetry for %s", skill_path, exc_info=True)
|
||||
|
||||
def _validate_skill_name(self, name: str) -> bool:
|
||||
"""Validate skill name according to security constraints."""
|
||||
import re
|
||||
@@ -669,7 +1011,301 @@ author: agent
|
||||
}
|
||||
|
||||
return level_map.get(level_str, EnforcementLevel.WARN)
|
||||
|
||||
|
||||
def _get_default_write_approval(self) -> bool:
|
||||
"""Resolve the default write-approval policy.
|
||||
|
||||
Safe-by-default: skill mutations are staged for approval unless the
|
||||
``SKILL_WRITE_APPROVAL`` environment variable explicitly disables it
|
||||
(e.g. ``0``/``false``/``off`` for trusted local contexts).
|
||||
"""
|
||||
import os
|
||||
raw = os.getenv('SKILL_WRITE_APPROVAL')
|
||||
if raw is None:
|
||||
return True
|
||||
return raw.strip().lower() not in ('0', 'false', 'off', 'no', 'disabled')
|
||||
|
||||
# ── Approval gate (SkillMutatorProtocol) ──────────────────────────
|
||||
|
||||
def _should_propose(self, propose: Optional[bool]) -> bool:
|
||||
"""Decide whether a mutation should be staged for approval."""
|
||||
if propose is None:
|
||||
return self._write_approval
|
||||
return bool(propose)
|
||||
|
||||
def _skills_base_dir(self):
|
||||
"""Return the base skills directory where mutations are written.
|
||||
|
||||
Always targets the user-owned skills directory (honours
|
||||
``PRAISONAI_HOME``). ``get_default_skill_dirs()`` is intentionally not
|
||||
used here because its first entry can be a project-scoped or
|
||||
admin-managed (e.g. ``/etc/praison/skills``) location that mutations
|
||||
must never write into.
|
||||
"""
|
||||
from ..paths import get_skills_dir
|
||||
base = get_skills_dir()
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base
|
||||
|
||||
def _pending_store_path(self):
|
||||
"""Return the path to the JSON-backed pending-mutation store."""
|
||||
return self._skills_base_dir() / ".pending_skills.json"
|
||||
|
||||
def _audit_log_path(self):
|
||||
"""Return the path to the append-only skill-mutation audit log."""
|
||||
return self._pending_store_path().parent / ".skill_audit.log"
|
||||
|
||||
def _load_pending(self) -> Dict[str, dict]:
|
||||
"""Load the pending-mutation store (id -> record)."""
|
||||
import json
|
||||
store = self._pending_store_path()
|
||||
if not store.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(store, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to read pending skills store: %s", e)
|
||||
return {}
|
||||
|
||||
def _save_pending(self, pending: Dict[str, dict]) -> None:
|
||||
"""Persist the pending-mutation store atomically."""
|
||||
import json
|
||||
self._write_skill_atomically(
|
||||
self._pending_store_path(), json.dumps(pending, indent=2)
|
||||
)
|
||||
|
||||
def _audit(self, event: str, record: dict) -> None:
|
||||
"""Append a single audit entry for a skill mutation decision."""
|
||||
import json
|
||||
import time
|
||||
try:
|
||||
entry = {
|
||||
"event": event,
|
||||
"timestamp": time.time(),
|
||||
"id": record.get("id"),
|
||||
"action": record.get("action"),
|
||||
"name": record.get("name"),
|
||||
}
|
||||
with open(self._audit_log_path(), 'a', encoding='utf-8') as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
except OSError as e:
|
||||
logger.debug("Failed to write skill audit log: %s", e)
|
||||
|
||||
def _stage_pending(self, action: str, name: str, **payload) -> dict:
|
||||
"""Stage a skill mutation in the pending store instead of writing.
|
||||
|
||||
Returns a dict with ``status='pending'`` and a short ``id`` for the
|
||||
human approval flow (``/skills approve <id>``).
|
||||
"""
|
||||
import secrets
|
||||
import time
|
||||
|
||||
# Validate the staged payload up front using the same checks as the
|
||||
# direct-write path, so invalid/oversized proposals never reach the
|
||||
# pending store or audit log.
|
||||
if action in ("create", "edit", "patch", "delete",
|
||||
"write_file", "remove_file"):
|
||||
if not self._validate_skill_name(name):
|
||||
return {"success": False, "error": f"Invalid skill name: {name}"}
|
||||
if action in ("create", "edit") and len(payload.get("content") or "") > 100_000:
|
||||
return {"success": False, "error": "Skill content exceeds maximum size (100KB)"}
|
||||
if action == "write_file" and len(payload.get("file_content") or "") > 100_000:
|
||||
return {"success": False, "error": "File content exceeds maximum size (100KB)"}
|
||||
|
||||
pending = self._load_pending()
|
||||
if len(pending) >= self._max_pending:
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"Pending skill store is full ({self._max_pending} entries); "
|
||||
"approve or reject existing proposals first."
|
||||
),
|
||||
}
|
||||
request_id = f"skl-{secrets.token_hex(4)}"
|
||||
record = {
|
||||
"id": request_id,
|
||||
"action": action,
|
||||
"name": name,
|
||||
"status": "pending",
|
||||
"created_at": time.time(),
|
||||
"payload": {k: v for k, v in payload.items() if v is not None},
|
||||
}
|
||||
pending[request_id] = record
|
||||
self._save_pending(pending)
|
||||
self._audit("proposed", record)
|
||||
logger.info(
|
||||
"Skill mutation staged for approval: %s (action=%s, skill=%s)",
|
||||
request_id, action, name,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"status": "pending",
|
||||
"id": request_id,
|
||||
"action": action,
|
||||
"skill": name,
|
||||
}
|
||||
|
||||
def list_pending(self) -> List[dict]:
|
||||
"""List all pending skill mutations awaiting approval.
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: id, action, name, status, created_at.
|
||||
"""
|
||||
pending = self._load_pending()
|
||||
return [
|
||||
{
|
||||
"id": r.get("id"),
|
||||
"action": r.get("action"),
|
||||
"name": r.get("name"),
|
||||
"status": r.get("status", "pending"),
|
||||
"created_at": r.get("created_at"),
|
||||
}
|
||||
for r in pending.values()
|
||||
]
|
||||
|
||||
def _resolve_pending_id(self, identifier: str, pending: Dict[str, dict]):
|
||||
"""Resolve a pending record by id, or by skill name as a fallback."""
|
||||
if identifier in pending:
|
||||
return identifier
|
||||
# Fall back to matching by skill name (most recent wins).
|
||||
matches = [
|
||||
r for r in pending.values() if r.get("name") == identifier
|
||||
]
|
||||
if matches:
|
||||
matches.sort(key=lambda r: r.get("created_at", 0), reverse=True)
|
||||
return matches[0].get("id")
|
||||
return None
|
||||
|
||||
def approve(self, identifier: str) -> dict:
|
||||
"""Approve a pending skill mutation and apply it to disk.
|
||||
|
||||
Args:
|
||||
identifier: The pending mutation id (preferred) or skill name.
|
||||
|
||||
Returns:
|
||||
Dict with the result of applying the mutation.
|
||||
"""
|
||||
pending = self._load_pending()
|
||||
request_id = self._resolve_pending_id(identifier, pending)
|
||||
if request_id is None:
|
||||
return {"success": False, "error": f"No pending mutation: {identifier}"}
|
||||
|
||||
# Apply first; only remove + audit "approved" once the mutation
|
||||
# actually succeeds, so a failure neither loses the proposal nor
|
||||
# records a false approval.
|
||||
record = pending[request_id]
|
||||
result = self._apply_pending(record)
|
||||
result.setdefault("id", request_id)
|
||||
if result.get("success"):
|
||||
pending.pop(request_id, None)
|
||||
self._save_pending(pending)
|
||||
self._audit("approved", record)
|
||||
else:
|
||||
self._audit("approval_failed", record)
|
||||
return result
|
||||
|
||||
def reject(self, identifier: str) -> dict:
|
||||
"""Reject a pending skill mutation without applying it.
|
||||
|
||||
Args:
|
||||
identifier: The pending mutation id (preferred) or skill name.
|
||||
|
||||
Returns:
|
||||
Dict confirming the rejection.
|
||||
"""
|
||||
pending = self._load_pending()
|
||||
request_id = self._resolve_pending_id(identifier, pending)
|
||||
if request_id is None:
|
||||
return {"success": False, "error": f"No pending mutation: {identifier}"}
|
||||
|
||||
record = pending.pop(request_id)
|
||||
self._save_pending(pending)
|
||||
self._audit("rejected", record)
|
||||
return {
|
||||
"success": True,
|
||||
"status": "rejected",
|
||||
"id": request_id,
|
||||
"action": record.get("action"),
|
||||
"skill": record.get("name"),
|
||||
}
|
||||
|
||||
def _apply_pending(self, record: dict) -> dict:
|
||||
"""Apply an approved mutation directly (bypassing the approval gate)."""
|
||||
action = record.get("action")
|
||||
name = record.get("name")
|
||||
payload = record.get("payload", {})
|
||||
|
||||
if action == "create":
|
||||
return self.create_skill(
|
||||
name, payload.get("content", ""),
|
||||
payload.get("category"),
|
||||
agent_created=payload.get("agent_created", True),
|
||||
propose=False,
|
||||
)
|
||||
if action == "edit":
|
||||
return self.edit_skill(name, payload.get("content", ""), propose=False)
|
||||
if action == "patch":
|
||||
return self.patch_skill(
|
||||
name, payload.get("old_string", ""),
|
||||
payload.get("new_string", ""),
|
||||
payload.get("file_path"),
|
||||
payload.get("replace_all", False),
|
||||
propose=False,
|
||||
)
|
||||
if action == "delete":
|
||||
return self.delete_skill(
|
||||
name, hard=payload.get("hard", False), propose=False,
|
||||
)
|
||||
if action == "write_file":
|
||||
return self.write_skill_file(
|
||||
name, payload.get("file_path", ""),
|
||||
payload.get("file_content", ""), propose=False,
|
||||
)
|
||||
if action == "remove_file":
|
||||
return self.remove_skill_file(
|
||||
name, payload.get("file_path", ""), propose=False,
|
||||
)
|
||||
return {"success": False, "error": f"Unknown action: {action}"}
|
||||
|
||||
# ── SkillMutatorProtocol aliases ──────────────────────────────────
|
||||
|
||||
def create(self, name: str, content: str, category: Optional[str] = None,
|
||||
propose: Optional[bool] = None) -> dict:
|
||||
"""Protocol alias for :meth:`create_skill`."""
|
||||
return self.create_skill(name, content, category, propose=propose)
|
||||
|
||||
def edit(self, name: str, content: str,
|
||||
propose: Optional[bool] = None) -> dict:
|
||||
"""Protocol alias for :meth:`edit_skill`."""
|
||||
return self.edit_skill(name, content, propose=propose)
|
||||
|
||||
def patch(self, name: str, old_string: str, new_string: str,
|
||||
file_path: Optional[str] = None, replace_all: bool = False,
|
||||
propose: Optional[bool] = None) -> dict:
|
||||
"""Protocol alias for :meth:`patch_skill`."""
|
||||
return self.patch_skill(
|
||||
name, old_string, new_string, file_path, replace_all,
|
||||
propose=propose,
|
||||
)
|
||||
|
||||
def delete(self, name: str, propose: Optional[bool] = None) -> dict:
|
||||
"""Protocol alias for :meth:`delete_skill`."""
|
||||
return self.delete_skill(name, propose=propose)
|
||||
|
||||
def write_file(self, name: str, file_path: str, file_content: str,
|
||||
propose: Optional[bool] = None) -> dict:
|
||||
"""Protocol alias for :meth:`write_skill_file`."""
|
||||
return self.write_skill_file(
|
||||
name, file_path, file_content, propose=propose,
|
||||
)
|
||||
|
||||
def remove_file(self, name: str, file_path: str,
|
||||
propose: Optional[bool] = None) -> dict:
|
||||
"""Protocol alias for :meth:`remove_skill_file`."""
|
||||
return self.remove_skill_file(name, file_path, propose=propose)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all loaded skills."""
|
||||
self._skills.clear()
|
||||
|
||||
@@ -151,6 +151,13 @@ class SkillProperties:
|
||||
shell: Optional[str] = None # "bash" | "powershell"
|
||||
# Capability requirements (new)
|
||||
requirements: Optional[SkillRequirements] = None
|
||||
# Provenance + usage telemetry (read by lifecycle/curator plugins).
|
||||
# These are protocol-only data fields; retention policy lives in plugins.
|
||||
agent_created: bool = False
|
||||
created_at: Optional[str] = None # ISO 8601 timestamp
|
||||
use_count: int = 0
|
||||
last_used: Optional[str] = None # ISO 8601 timestamp
|
||||
patch_count: int = 0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary, excluding None values and empty metadata."""
|
||||
@@ -185,8 +192,39 @@ class SkillProperties:
|
||||
result["paths"] = self.paths
|
||||
if self.shell is not None:
|
||||
result["shell"] = self.shell
|
||||
if self.agent_created:
|
||||
result["agent-created"] = True
|
||||
if self.created_at is not None:
|
||||
result["created-at"] = self.created_at
|
||||
if self.use_count:
|
||||
result["use-count"] = self.use_count
|
||||
if self.last_used is not None:
|
||||
result["last-used"] = self.last_used
|
||||
if self.patch_count:
|
||||
result["patch-count"] = self.patch_count
|
||||
return result
|
||||
|
||||
@property
|
||||
def idle_days(self) -> Optional[float]:
|
||||
"""Days since the skill was last used (or created if never used).
|
||||
|
||||
Returns None when no timestamp is available. Used by lifecycle
|
||||
curator plugins to decide active -> stale -> archived transitions.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ref = self.last_used or self.created_at
|
||||
if not ref:
|
||||
return None
|
||||
try:
|
||||
ts = datetime.fromisoformat(ref)
|
||||
except ValueError:
|
||||
return None
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
delta = datetime.now(timezone.utc) - ts
|
||||
return delta.total_seconds() / 86400.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillMetadata:
|
||||
|
||||
@@ -123,6 +123,17 @@ def read_properties(skill_dir: Path) -> SkillProperties:
|
||||
return v.strip().lower() in ("true", "yes", "1", "on")
|
||||
return default
|
||||
|
||||
def _coerce_int(v, default=0):
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def _coerce_str(v):
|
||||
if v is None:
|
||||
return None
|
||||
return str(v)
|
||||
|
||||
return SkillProperties(
|
||||
name=name.strip(),
|
||||
description=description.strip(),
|
||||
@@ -145,4 +156,14 @@ def read_properties(skill_dir: Path) -> SkillProperties:
|
||||
shell=metadata.get("shell"),
|
||||
# Parse capability requirements
|
||||
requirements=SkillRequirements.from_frontmatter(metadata),
|
||||
# Provenance + usage telemetry (accept hyphen or underscore forms)
|
||||
agent_created=_coerce_bool(
|
||||
metadata.get("agent-created", metadata.get("agent_created")), False
|
||||
),
|
||||
created_at=_coerce_str(metadata.get("created-at", metadata.get("created_at"))),
|
||||
use_count=_coerce_int(metadata.get("use-count", metadata.get("use_count")), 0),
|
||||
last_used=_coerce_str(metadata.get("last-used", metadata.get("last_used"))),
|
||||
patch_count=_coerce_int(
|
||||
metadata.get("patch-count", metadata.get("patch_count")), 0
|
||||
),
|
||||
)
|
||||
@@ -175,6 +175,70 @@ class SkillsCatalogProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SkillReviewProtocol(Protocol):
|
||||
"""Policy for the autonomous skill self-improvement loop.
|
||||
|
||||
After a task finishes, an opt-in guarded review pass asks the agent
|
||||
whether the session revealed a reusable technique worth capturing as a
|
||||
skill. ``should_review`` decides *whether* to run the pass and
|
||||
``review_prompt`` produces the directive given to the agent. Both take a
|
||||
lightweight trajectory dict so wrappers/plugins can swap the policy
|
||||
without depending on core internals.
|
||||
"""
|
||||
|
||||
def should_review(self, trajectory: Dict[str, Any]) -> bool:
|
||||
"""Return True if the finished session warrants a skill review."""
|
||||
...
|
||||
|
||||
def review_prompt(self, trajectory: Dict[str, Any]) -> str:
|
||||
"""Return the directive prompt for the guarded review turn."""
|
||||
...
|
||||
|
||||
|
||||
class DefaultSkillReviewPolicy:
|
||||
"""Default, conservative skill-review policy.
|
||||
|
||||
Triggers only when the session did real work (at least ``min_tool_calls``
|
||||
tool invocations, default 1) and asks the agent to create or patch a
|
||||
single skill if — and only if — a durable, reusable technique emerged.
|
||||
"""
|
||||
|
||||
#: Hard cap on how much of the original prompt is echoed into the review
|
||||
#: directive. Bounds token cost and shrinks the prompt-injection surface.
|
||||
MAX_PROMPT_CHARS = 500
|
||||
|
||||
def __init__(self, min_tool_calls: int = 1):
|
||||
# Clamp to >= 1: a value of 0 would trigger a review LLM call after
|
||||
# every single chat() (even no-op turns that used no tools), silently
|
||||
# doubling API cost. The minimum unit of "real work" is one tool call.
|
||||
self.min_tool_calls = max(1, int(min_tool_calls))
|
||||
|
||||
def should_review(self, trajectory: Dict[str, Any]) -> bool:
|
||||
tools_used = trajectory.get("tools_used") or []
|
||||
return len(tools_used) >= self.min_tool_calls
|
||||
|
||||
def review_prompt(self, trajectory: Dict[str, Any]) -> str:
|
||||
prompt = str(trajectory.get("prompt", "")).strip()
|
||||
if len(prompt) > self.MAX_PROMPT_CHARS:
|
||||
prompt = prompt[: self.MAX_PROMPT_CHARS] + "…"
|
||||
tools_used = trajectory.get("tools_used") or []
|
||||
tools_str = ", ".join(str(t) for t in tools_used) if tools_used else "none"
|
||||
return (
|
||||
"You have just finished a task. Reflect ONLY on whether this "
|
||||
"session revealed a durable, reusable technique, or exposed a "
|
||||
"loaded skill that was wrong or incomplete.\n\n"
|
||||
f"Original task: {prompt}\n"
|
||||
f"Tools used: {tools_str}\n\n"
|
||||
"If — and only if — there is a genuinely reusable capability worth "
|
||||
"saving, call the `skill_manage` tool to create a new skill or "
|
||||
"patch an existing one. Use a clear, hyphenated skill name and a "
|
||||
"concise SKILL.md body capturing the reusable steps. "
|
||||
"If nothing durable was learned, reply with exactly 'NO_SKILL' "
|
||||
"and do not call any tool."
|
||||
)
|
||||
|
||||
|
||||
def list_skills_for_api() -> List[Dict[str, Any]]:
|
||||
"""Default catalog adapter using SkillManager when available."""
|
||||
try:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Shared concurrency primitives for the built-in file/edit tools.
|
||||
|
||||
This module provides process-wide, per-canonical-path locking and an
|
||||
automatic last-read-hash registry so that the built-in ``FileTools`` and
|
||||
``EditTools`` can guarantee two production-robustness properties **by
|
||||
default**:
|
||||
|
||||
1. **Per-file concurrency safety** — a keyed lock per canonical file path
|
||||
serialises read-modify-write on the same file, so parallel tool calls or
|
||||
multi-agent teams sharing a workspace cannot interleave and corrupt a
|
||||
shared file. Different files still proceed in parallel.
|
||||
2. **Automatic stale-write protection** — a read records the on-disk content
|
||||
hash for that path; a later edit/write recomputes the on-disk hash and
|
||||
aborts on mismatch unless the caller opts out (``force=True``) or supplies
|
||||
an explicit ``expected_hash``.
|
||||
|
||||
The state lives at module scope so it is shared across every ``FileTools`` /
|
||||
``EditTools`` instance (and the module-level default instances), which is what
|
||||
makes the guarantee hold across separate tool calls in an agent loop.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import weakref
|
||||
from collections import OrderedDict
|
||||
|
||||
# Guards access to the registries below.
|
||||
_registry_lock = threading.Lock()
|
||||
|
||||
# Per-canonical-path re-entrant locks. ``WeakValueDictionary`` lets unused
|
||||
# locks be garbage collected once no caller holds a reference, avoiding
|
||||
# unbounded growth for long-running agents touching many files.
|
||||
_path_locks: "weakref.WeakValueDictionary[str, threading.RLock]" = (
|
||||
weakref.WeakValueDictionary()
|
||||
)
|
||||
|
||||
# Maximum number of distinct paths whose last-read hash is retained. The map
|
||||
# is bounded with LRU-style eviction so a long-running agent reading thousands
|
||||
# of distinct files cannot grow it without bound. A path evicted here simply
|
||||
# loses its automatic staleness guard for that one path (the next read re-arms
|
||||
# it); correctness is preserved, only the convenience guard is dropped.
|
||||
_MAX_READ_HASHES = 4096
|
||||
|
||||
# Automatic last-read content hash per canonical path. Populated on read,
|
||||
# consulted on write/edit for the default staleness guard. Insertion-ordered
|
||||
# so the oldest entry can be evicted once the cap is exceeded.
|
||||
_read_hashes: "OrderedDict[str, str]" = OrderedDict()
|
||||
|
||||
|
||||
def get_lock(canonical_path: str) -> threading.RLock:
|
||||
"""Return the shared re-entrant lock for ``canonical_path``.
|
||||
|
||||
The same lock object is returned for the same path across all callers, so
|
||||
independent ``FileTools``/``EditTools`` instances serialise correctly on a
|
||||
shared file. Re-entrant so a single thread can nest read+edit safely.
|
||||
"""
|
||||
with _registry_lock:
|
||||
lock = _path_locks.get(canonical_path)
|
||||
if lock is None:
|
||||
lock = threading.RLock()
|
||||
_path_locks[canonical_path] = lock
|
||||
return lock
|
||||
|
||||
|
||||
def record_read_hash(canonical_path: str, content_hash: str) -> None:
|
||||
"""Record the content hash observed when ``canonical_path`` was read.
|
||||
|
||||
The map is bounded: once it exceeds ``_MAX_READ_HASHES`` the oldest entry
|
||||
is evicted, so long-running agents reading many distinct files do not grow
|
||||
it without bound.
|
||||
"""
|
||||
with _registry_lock:
|
||||
_read_hashes[canonical_path] = content_hash
|
||||
_read_hashes.move_to_end(canonical_path)
|
||||
while len(_read_hashes) > _MAX_READ_HASHES:
|
||||
_read_hashes.popitem(last=False)
|
||||
|
||||
|
||||
def get_read_hash(canonical_path: str):
|
||||
"""Return the last recorded read hash for ``canonical_path`` (or None)."""
|
||||
with _registry_lock:
|
||||
value = _read_hashes.get(canonical_path)
|
||||
if value is not None:
|
||||
_read_hashes.move_to_end(canonical_path)
|
||||
return value
|
||||
|
||||
|
||||
def clear_read_hash(canonical_path: str) -> None:
|
||||
"""Forget any recorded read hash for ``canonical_path`` (e.g. on delete)."""
|
||||
with _registry_lock:
|
||||
_read_hashes.pop(canonical_path, None)
|
||||
@@ -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.
|
||||
|
||||
@@ -12,24 +12,65 @@ import hashlib
|
||||
import difflib
|
||||
from typing import List, Optional, Tuple
|
||||
from ..approval import require_approval
|
||||
from . import _file_locks
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Minimum similarity ratio for an accepted block-anchor (fuzzy) match.
|
||||
_BLOCK_ANCHOR_THRESHOLD = 0.7
|
||||
|
||||
# Maximum characters of diagnostics output appended to a tool result.
|
||||
_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."""
|
||||
|
||||
def __init__(self, workspace=None):
|
||||
def __init__(self, workspace=None, post_edit_diagnostics: str = "auto"):
|
||||
"""Initialize EditTools with optional workspace containment.
|
||||
|
||||
Args:
|
||||
workspace: Optional Workspace instance for path containment
|
||||
post_edit_diagnostics: When to run a lightweight, language-appropriate
|
||||
check on a file after a successful edit/patch and append the
|
||||
results to the tool output. One of:
|
||||
- ``"auto"`` (default): run a checker if one is available, but
|
||||
only append a ``Diagnostics`` section when problems are found
|
||||
(so clean edits return the plain success string — backward
|
||||
compatible).
|
||||
- ``"on"``: always append a ``Diagnostics`` section when a
|
||||
checker is available, even if it reports no problems.
|
||||
- ``"off"``: never run diagnostics (zero overhead).
|
||||
Checkers are auto-detected by file type and silently skipped
|
||||
when unavailable; a missing linter never fails the edit.
|
||||
"""
|
||||
self._workspace = workspace
|
||||
self._file_cache = {} # Cache for staleness checking
|
||||
mode = (post_edit_diagnostics or "auto")
|
||||
if isinstance(mode, str):
|
||||
mode = mode.lower()
|
||||
if mode not in ("auto", "on", "off"):
|
||||
mode = "auto"
|
||||
self._post_edit_diagnostics = mode
|
||||
|
||||
def _validate_path(self, filepath: str) -> str:
|
||||
"""Validate and resolve a file path within workspace constraints."""
|
||||
@@ -116,7 +157,272 @@ class EditTools:
|
||||
diff_lines.append(line)
|
||||
|
||||
return ''.join(diff_lines) if diff_lines else "No changes detected"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Post-edit diagnostics
|
||||
#
|
||||
# After a successful mutation we optionally run a lightweight, language
|
||||
# appropriate check on the single modified file and surface concise
|
||||
# diagnostics so an agent can self-correct within the same loop. All
|
||||
# imports are deferred and any failure is swallowed: a missing or broken
|
||||
# checker must never turn a successful edit into a failure.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _diagnostics_command(safe_path: str) -> Optional[Tuple[str, List[str]]]:
|
||||
"""Resolve a (tool_name, argv) checker for ``safe_path`` by extension.
|
||||
|
||||
Returns ``None`` when no checker is available for the file type or none
|
||||
of the candidate executables are installed. Lazy-imports ``shutil``.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
ext = os.path.splitext(safe_path)[1].lower()
|
||||
# Ordered candidates per extension: first installed executable wins.
|
||||
candidates: List[Tuple[str, List[str]]] = []
|
||||
if ext in (".py", ".pyi"):
|
||||
if shutil.which("ruff"):
|
||||
# Restrict to error-class rules (pyflakes/syntax) so that
|
||||
# stylistically-imperfect-but-valid edits do not flood output;
|
||||
# this keeps behaviour close to a syntax gate. ``--no-cache``
|
||||
# avoids polluting the workspace.
|
||||
candidates.append((
|
||||
"ruff",
|
||||
["ruff", "check", "--quiet", "--no-cache",
|
||||
"--select", "E9,F63,F7,F82", safe_path],
|
||||
))
|
||||
# py_compile is always available via the running interpreter and
|
||||
# catches syntax errors with zero third-party dependencies.
|
||||
import sys
|
||||
candidates.append(("py_compile", [sys.executable, "-m", "py_compile", safe_path]))
|
||||
elif ext in (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"):
|
||||
if shutil.which("eslint"):
|
||||
# ``--no-config-lookup`` prevents ESLint from discovering and
|
||||
# executing repository-controlled config/plugins (arbitrary code
|
||||
# execution risk in untrusted workspaces).
|
||||
candidates.append((
|
||||
"eslint",
|
||||
["eslint", "--no-config-lookup", safe_path],
|
||||
))
|
||||
if shutil.which("tsc") and ext in (".ts", ".tsx"):
|
||||
# Per-file invocation without a project ``tsconfig.json``: relax
|
||||
# project-config coupling so a single valid file does not report
|
||||
# spurious "no inputs"/missing-lib errors.
|
||||
candidates.append((
|
||||
"tsc",
|
||||
["tsc", "--noEmit", "--skipLibCheck", "--allowJs",
|
||||
"--target", "ESNext", "--moduleResolution", "node",
|
||||
safe_path],
|
||||
))
|
||||
elif ext == ".json":
|
||||
# Validate JSON with the stdlib; emitted via a tiny inline check.
|
||||
import sys
|
||||
candidates.append((
|
||||
"json",
|
||||
[sys.executable, "-c",
|
||||
"import json,sys;json.load(open(sys.argv[1]))", safe_path],
|
||||
))
|
||||
|
||||
for tool_name, argv in candidates:
|
||||
exe = argv[0]
|
||||
if os.path.isabs(exe) or shutil.which(exe):
|
||||
return tool_name, argv
|
||||
return None
|
||||
|
||||
@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
|
||||
|
||||
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:
|
||||
return ""
|
||||
tool_name, argv = resolved
|
||||
|
||||
import subprocess
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
argv,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_DIAGNOSTICS_TIMEOUT,
|
||||
cwd=os.path.dirname(safe_path) or None,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.debug("Diagnostics skipped for %s: %s", display_path, e)
|
||||
return ""
|
||||
|
||||
output = (proc.stdout or "") + (proc.stderr or "")
|
||||
output = output.strip()
|
||||
# Replace absolute paths with the display path for cleaner output.
|
||||
if output and safe_path != display_path:
|
||||
output = output.replace(safe_path, display_path)
|
||||
|
||||
has_problems = proc.returncode != 0 or bool(output)
|
||||
if not has_problems:
|
||||
if self._post_edit_diagnostics == "on":
|
||||
return f"\n\nDiagnostics ({tool_name}): no problems found"
|
||||
return ""
|
||||
|
||||
if not output:
|
||||
output = f"check failed (exit code {proc.returncode})"
|
||||
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)
|
||||
return ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fuzzy matching ladder
|
||||
#
|
||||
@@ -274,15 +580,24 @@ class EditTools:
|
||||
|
||||
@require_approval(risk_level="high")
|
||||
def edit_file(self, filepath: str, old_string: str, new_string: str,
|
||||
replace_all: bool = False, expected_hash: Optional[str] = None) -> str:
|
||||
replace_all: bool = False, expected_hash: Optional[str] = None,
|
||||
force: bool = False) -> str:
|
||||
"""Edit a file by replacing text using precise find-and-replace.
|
||||
|
||||
The entire read-modify-write runs under a per-file lock, so concurrent
|
||||
edits to the same path (parallel tools / multi-agent teams) serialise
|
||||
instead of corrupting the file. By default the edit also aborts if the
|
||||
file changed on disk since it was last read via this tool, preventing
|
||||
silent clobbering of externally-modified files.
|
||||
|
||||
Args:
|
||||
filepath: Path to the file to edit
|
||||
old_string: Text to find and replace
|
||||
new_string: Replacement text
|
||||
replace_all: Whether to replace all occurrences (default: first only)
|
||||
expected_hash: Optional SHA256 hash of expected content for staleness check
|
||||
force: Bypass the automatic staleness guard for an intentional
|
||||
blind write (an explicit ``expected_hash`` is still honoured)
|
||||
|
||||
Returns:
|
||||
Success message with diff or error description
|
||||
@@ -293,88 +608,109 @@ class EditTools:
|
||||
if not os.path.exists(safe_path):
|
||||
return f"Error: File not found: {filepath}"
|
||||
|
||||
# Detect BOM and read file content
|
||||
with open(safe_path, 'rb') as f:
|
||||
raw_bytes = f.read()
|
||||
|
||||
# Check for UTF-8 BOM only (we don't support UTF-16)
|
||||
bom = b''
|
||||
if raw_bytes.startswith(b'\xef\xbb\xbf'): # UTF-8 BOM
|
||||
bom = b'\xef\xbb\xbf'
|
||||
raw_bytes = raw_bytes[3:]
|
||||
elif raw_bytes.startswith(b'\xff\xfe') or raw_bytes.startswith(b'\xfe\xff'):
|
||||
# UTF-16 BOM detected - not supported
|
||||
return "Error: UTF-16 encoding is not supported. Please convert the file to UTF-8."
|
||||
|
||||
# Decode content (UTF-8 only)
|
||||
content = raw_bytes.decode('utf-8')
|
||||
|
||||
# Detect line endings
|
||||
line_ending = self._detect_line_ending(content)
|
||||
|
||||
# Staleness check
|
||||
if expected_hash is not None:
|
||||
# Serialise the whole read-modify-write on this canonical path so
|
||||
# concurrent edits/writes cannot interleave and corrupt the file.
|
||||
with _file_locks.get_lock(safe_path):
|
||||
# Detect BOM and read file content
|
||||
with open(safe_path, 'rb') as f:
|
||||
raw_bytes = f.read()
|
||||
|
||||
# Check for UTF-8 BOM only (we don't support UTF-16)
|
||||
bom = b''
|
||||
if raw_bytes.startswith(b'\xef\xbb\xbf'): # UTF-8 BOM
|
||||
bom = b'\xef\xbb\xbf'
|
||||
raw_bytes = raw_bytes[3:]
|
||||
elif raw_bytes.startswith(b'\xff\xfe') or raw_bytes.startswith(b'\xfe\xff'):
|
||||
# UTF-16 BOM detected - not supported
|
||||
return "Error: UTF-16 encoding is not supported. Please convert the file to UTF-8."
|
||||
|
||||
# Decode content (UTF-8 only)
|
||||
content = raw_bytes.decode('utf-8')
|
||||
|
||||
# Detect line endings
|
||||
line_ending = self._detect_line_ending(content)
|
||||
|
||||
current_hash = self._compute_content_hash(content)
|
||||
if current_hash != expected_hash:
|
||||
return (f"Error: File has been modified since last read. "
|
||||
f"Please re-read the file before editing. "
|
||||
f"Expected hash: {expected_hash[:8]}..., "
|
||||
f"Current hash: {current_hash[:8]}...")
|
||||
|
||||
# Cache the current content hash for future staleness checks
|
||||
self._file_cache[safe_path] = self._compute_content_hash(content)
|
||||
|
||||
# Validation
|
||||
if old_string == "":
|
||||
return "Error: old_string must be non-empty"
|
||||
|
||||
# Locate the target using the fuzzy matching ladder. Exact
|
||||
# substring matches are preferred and behave exactly as before;
|
||||
# fuzzy strategies only engage when an exact match is not found.
|
||||
spans = self._find_spans(content, old_string)
|
||||
|
||||
if not spans:
|
||||
preview = old_string[:50] + ("..." if len(old_string) > 50 else "")
|
||||
return f"Error: String not found in file: '{preview}'"
|
||||
|
||||
occurrences = len(spans)
|
||||
|
||||
# Check for ambiguous match
|
||||
if occurrences > 1 and not replace_all:
|
||||
preview = old_string[:30] + ("..." if len(old_string) > 30 else "")
|
||||
return (f"Error: Ambiguous match - '{preview}' occurs {occurrences} times. "
|
||||
f"Please provide more surrounding context to make the match unique, "
|
||||
f"or use replace_all=True to replace all occurrences.")
|
||||
|
||||
# Perform replacement using located spans (apply right-to-left so
|
||||
# earlier offsets remain valid as we splice in new_string).
|
||||
if replace_all:
|
||||
new_content = content
|
||||
for start, end in sorted(spans, reverse=True):
|
||||
new_content = new_content[:start] + new_string + new_content[end:]
|
||||
replacements = occurrences
|
||||
else:
|
||||
start, end = spans[0]
|
||||
new_content = content[:start] + new_string + content[end:]
|
||||
replacements = 1
|
||||
|
||||
# Generate diff
|
||||
diff = self._render_diff(content, new_content, filepath)
|
||||
|
||||
# Normalize line endings for the new content
|
||||
if line_ending == '\r\n':
|
||||
new_content = new_content.replace('\r\n', '\n').replace('\n', '\r\n')
|
||||
|
||||
# Write updated content back with BOM if present
|
||||
with open(safe_path, 'wb') as f:
|
||||
if bom:
|
||||
f.write(bom)
|
||||
f.write(new_content.encode('utf-8'))
|
||||
|
||||
# Update cache with new content hash
|
||||
self._file_cache[safe_path] = self._compute_content_hash(new_content)
|
||||
|
||||
return f"Success: Made {replacements} replacement(s) in {filepath}\n\nDiff:\n{diff}"
|
||||
|
||||
# Explicit staleness check (caller-supplied hash takes priority).
|
||||
if expected_hash is not None:
|
||||
if current_hash != expected_hash:
|
||||
return (f"Error: File has been modified since last read. "
|
||||
f"Please re-read the file before editing. "
|
||||
f"Expected hash: {expected_hash[:8]}..., "
|
||||
f"Current hash: {current_hash[:8]}...")
|
||||
# Automatic staleness guard: if a prior read recorded a hash for
|
||||
# this path, abort when the on-disk content has since changed.
|
||||
elif not force:
|
||||
recorded = _file_locks.get_read_hash(safe_path)
|
||||
if recorded is not None and recorded != current_hash:
|
||||
return (f"Error: File changed since it was read - "
|
||||
f"re-read before editing (or pass force=True to override). "
|
||||
f"Recorded hash: {recorded[:8]}..., "
|
||||
f"Current hash: {current_hash[:8]}...")
|
||||
|
||||
# Cache the current content hash for future staleness checks
|
||||
self._file_cache[safe_path] = current_hash
|
||||
_file_locks.record_read_hash(safe_path, current_hash)
|
||||
|
||||
# Validation
|
||||
if old_string == "":
|
||||
return "Error: old_string must be non-empty"
|
||||
|
||||
# Locate the target using the fuzzy matching ladder. Exact
|
||||
# substring matches are preferred and behave exactly as before;
|
||||
# fuzzy strategies only engage when an exact match is not found.
|
||||
spans = self._find_spans(content, old_string)
|
||||
|
||||
if not spans:
|
||||
preview = old_string[:50] + ("..." if len(old_string) > 50 else "")
|
||||
return f"Error: String not found in file: '{preview}'"
|
||||
|
||||
occurrences = len(spans)
|
||||
|
||||
# Check for ambiguous match
|
||||
if occurrences > 1 and not replace_all:
|
||||
preview = old_string[:30] + ("..." if len(old_string) > 30 else "")
|
||||
return (f"Error: Ambiguous match - '{preview}' occurs {occurrences} times. "
|
||||
f"Please provide more surrounding context to make the match unique, "
|
||||
f"or use replace_all=True to replace all occurrences.")
|
||||
|
||||
# Perform replacement using located spans (apply right-to-left so
|
||||
# earlier offsets remain valid as we splice in new_string).
|
||||
if replace_all:
|
||||
new_content = content
|
||||
for start, end in sorted(spans, reverse=True):
|
||||
new_content = new_content[:start] + new_string + new_content[end:]
|
||||
replacements = occurrences
|
||||
else:
|
||||
start, end = spans[0]
|
||||
new_content = content[:start] + new_string + content[end:]
|
||||
replacements = 1
|
||||
|
||||
# Generate diff
|
||||
diff = self._render_diff(content, new_content, filepath)
|
||||
|
||||
# Normalize line endings for the new content
|
||||
if line_ending == '\r\n':
|
||||
new_content = new_content.replace('\r\n', '\n').replace('\n', '\r\n')
|
||||
|
||||
# Write updated content back with BOM if present
|
||||
with open(safe_path, 'wb') as f:
|
||||
if bom:
|
||||
f.write(bom)
|
||||
f.write(new_content.encode('utf-8'))
|
||||
|
||||
# Update caches with the new content hash so a follow-up edit
|
||||
# in the same session is not flagged as stale. Hash the exact
|
||||
# on-disk form (CRLF preserved) so a subsequent binary read of
|
||||
# the same file produces an identical hash and is not falsely
|
||||
# flagged as stale.
|
||||
new_hash = self._compute_content_hash(new_content)
|
||||
self._file_cache[safe_path] = new_hash
|
||||
_file_locks.record_read_hash(safe_path, new_hash)
|
||||
|
||||
result = f"Success: Made {replacements} replacement(s) in {filepath}\n\nDiff:\n{diff}"
|
||||
return result + self._run_diagnostics(safe_path, filepath)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error editing file {filepath}: {str(e)}"
|
||||
@@ -463,7 +799,7 @@ class EditTools:
|
||||
return ops
|
||||
|
||||
@require_approval(risk_level="high")
|
||||
def apply_patch(self, patch: str) -> str:
|
||||
def apply_patch(self, patch: str, force: bool = False) -> str:
|
||||
"""Apply a structured multi-file patch atomically.
|
||||
|
||||
The patch may Add, Update, and Delete multiple files in a single
|
||||
@@ -473,12 +809,20 @@ class EditTools:
|
||||
the filesystem is left in its original state. BOM and CRLF line
|
||||
endings are preserved on Update, matching ``edit_file``.
|
||||
|
||||
Every affected path is locked for the duration of validate+commit, so
|
||||
a concurrent edit/write to any of those files serialises rather than
|
||||
racing. Updated files are also checked against their last-read hash by
|
||||
default; pass ``force=True`` to bypass that staleness guard.
|
||||
|
||||
Args:
|
||||
patch: Structured patch text (see ``_parse_patch`` for format).
|
||||
force: Bypass the automatic staleness guard on Update operations.
|
||||
|
||||
Returns:
|
||||
Combined success message with diffs, or an error description.
|
||||
"""
|
||||
import contextlib
|
||||
|
||||
try:
|
||||
try:
|
||||
ops = self._parse_patch(patch)
|
||||
@@ -488,6 +832,24 @@ class EditTools:
|
||||
if not ops:
|
||||
return "Error: Patch contains no operations"
|
||||
|
||||
# Acquire per-path locks for every affected file up front, in a
|
||||
# stable (sorted) order to avoid deadlock when two patches share
|
||||
# files. Held across validate+commit so no concurrent edit/write
|
||||
# can interleave with this atomic patch.
|
||||
lock_paths = sorted({self._validate_path(op["path"]) for op in ops})
|
||||
with contextlib.ExitStack() as stack:
|
||||
for lp in lock_paths:
|
||||
stack.enter_context(_file_locks.get_lock(lp))
|
||||
return self._apply_patch_locked(ops, force)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error applying patch: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return error_msg
|
||||
|
||||
def _apply_patch_locked(self, ops, force: bool) -> str:
|
||||
"""Validate and commit ``ops`` while holding the per-path locks."""
|
||||
try:
|
||||
# Phase 1: validate every operation and compute new content.
|
||||
# Each planned entry: (kind, safe_path, display, encoded_bytes,
|
||||
# diff, content_hash). encoded_bytes is None for deletes.
|
||||
@@ -506,6 +868,21 @@ class EditTools:
|
||||
elif op["op"] == "delete":
|
||||
if not os.path.exists(safe_path):
|
||||
return f"Error: Cannot delete '{op['path']}': file not found"
|
||||
# Automatic staleness guard: don't silently delete a file
|
||||
# that changed on disk since it was last read via these tools.
|
||||
if not force:
|
||||
recorded = _file_locks.get_read_hash(safe_path)
|
||||
if recorded is not None:
|
||||
with open(safe_path, 'rb') as f:
|
||||
raw_bytes = f.read()
|
||||
try:
|
||||
original, _ = self._decode_with_bom(raw_bytes)
|
||||
except ValueError as e:
|
||||
return f"Error: Cannot delete '{op['path']}': {e}"
|
||||
if recorded != self._compute_content_hash(original):
|
||||
return (f"Error: Cannot delete '{op['path']}': file changed "
|
||||
f"since it was read - re-read before editing "
|
||||
f"(or pass force=True to override).")
|
||||
planned.append(("delete", safe_path, op["path"], None, "", None))
|
||||
|
||||
elif op["op"] == "update":
|
||||
@@ -518,6 +895,15 @@ class EditTools:
|
||||
original, bom = self._decode_with_bom(raw_bytes)
|
||||
except ValueError as e:
|
||||
return f"Error: Cannot update '{op['path']}': {e}"
|
||||
# Automatic staleness guard: abort if the file changed on
|
||||
# disk since it was last read via these tools.
|
||||
if not force:
|
||||
original_hash = self._compute_content_hash(original)
|
||||
recorded = _file_locks.get_read_hash(safe_path)
|
||||
if recorded is not None and recorded != original_hash:
|
||||
return (f"Error: Cannot update '{op['path']}': file changed "
|
||||
f"since it was read - re-read before editing "
|
||||
f"(or pass force=True to override).")
|
||||
line_ending = self._detect_line_ending(original)
|
||||
updated = original
|
||||
for old_block, new_block in op["hunks"]:
|
||||
@@ -534,9 +920,13 @@ class EditTools:
|
||||
start, end = spans[0]
|
||||
updated = updated[:start] + new_block + updated[end:]
|
||||
encoded = self._encode_preserving(updated, line_ending, bom)
|
||||
# Hash the exact on-disk form (CRLF preserved, BOM stripped)
|
||||
# so a subsequent binary read of this file produces an
|
||||
# identical hash and is not falsely flagged as stale.
|
||||
on_disk_form = encoded[len(bom):].decode('utf-8')
|
||||
planned.append(("update", safe_path, op["path"], encoded,
|
||||
self._render_diff(original, updated, op["path"]),
|
||||
self._compute_content_hash(updated)))
|
||||
self._compute_content_hash(on_disk_form)))
|
||||
|
||||
# Phase 2: commit all operations atomically. Writes are staged to
|
||||
# temp files in the same directory and swapped in with os.replace;
|
||||
@@ -553,6 +943,7 @@ class EditTools:
|
||||
os.replace(safe_path, backup)
|
||||
applied.append(("delete", safe_path, backup))
|
||||
self._file_cache.pop(safe_path, None)
|
||||
_file_locks.clear_read_hash(safe_path)
|
||||
messages.append(f"Deleted {display}")
|
||||
else:
|
||||
os.makedirs(os.path.dirname(safe_path) or ".", exist_ok=True)
|
||||
@@ -576,8 +967,10 @@ class EditTools:
|
||||
raise
|
||||
applied.append(("write", safe_path, backup))
|
||||
self._file_cache[safe_path] = content_hash
|
||||
_file_locks.record_read_hash(safe_path, content_hash)
|
||||
verb = "Added" if kind == "add" else "Updated"
|
||||
messages.append(f"{verb} {display}\n{diff}")
|
||||
diagnostics = self._run_diagnostics(safe_path, display)
|
||||
messages.append(f"{verb} {display}\n{diff}{diagnostics}")
|
||||
except Exception:
|
||||
# Roll back in reverse order to restore the original state.
|
||||
for entry in reversed(applied):
|
||||
@@ -627,19 +1020,26 @@ class EditTools:
|
||||
if not os.path.exists(safe_path):
|
||||
return f"Error: File not found: {filepath}", ""
|
||||
|
||||
# Read in binary mode to match edit_file behavior
|
||||
with open(safe_path, 'rb') as f:
|
||||
raw_bytes = f.read()
|
||||
|
||||
# Strip UTF-8 BOM if present (matching edit_file logic)
|
||||
if raw_bytes.startswith(b'\xef\xbb\xbf'):
|
||||
raw_bytes = raw_bytes[3:]
|
||||
|
||||
# Decode content (UTF-8 only, matching edit_file)
|
||||
content = raw_bytes.decode('utf-8')
|
||||
|
||||
content_hash = self._compute_content_hash(content)
|
||||
self._file_cache[safe_path] = content_hash
|
||||
# Serialise with concurrent writers on this path so the read (and
|
||||
# the hash it records) reflects a stable, fully-written file rather
|
||||
# than content observed mid-write.
|
||||
with _file_locks.get_lock(safe_path):
|
||||
# Read in binary mode to match edit_file behavior
|
||||
with open(safe_path, 'rb') as f:
|
||||
raw_bytes = f.read()
|
||||
|
||||
# Strip UTF-8 BOM if present (matching edit_file logic)
|
||||
if raw_bytes.startswith(b'\xef\xbb\xbf'):
|
||||
raw_bytes = raw_bytes[3:]
|
||||
|
||||
# Decode content (UTF-8 only, matching edit_file)
|
||||
content = raw_bytes.decode('utf-8')
|
||||
|
||||
content_hash = self._compute_content_hash(content)
|
||||
self._file_cache[safe_path] = content_hash
|
||||
# Record the read hash in the shared registry so a later
|
||||
# edit/write to this path engages the staleness guard by default.
|
||||
_file_locks.record_read_hash(safe_path, content_hash)
|
||||
|
||||
return content, content_hash
|
||||
|
||||
@@ -718,7 +1118,8 @@ _edit_tools = EditTools()
|
||||
|
||||
@require_approval(risk_level="high")
|
||||
def edit_file(filepath: str, old_string: str, new_string: str,
|
||||
replace_all: bool = False, expected_hash: Optional[str] = None) -> str:
|
||||
replace_all: bool = False, expected_hash: Optional[str] = None,
|
||||
force: bool = False) -> str:
|
||||
"""Edit a file by replacing text using precise find-and-replace.
|
||||
|
||||
Args:
|
||||
@@ -727,25 +1128,28 @@ def edit_file(filepath: str, old_string: str, new_string: str,
|
||||
new_string: Replacement text
|
||||
replace_all: Whether to replace all occurrences (default: first only)
|
||||
expected_hash: Optional SHA256 hash of expected content for staleness check
|
||||
force: Bypass the automatic staleness guard for an intentional blind write
|
||||
|
||||
Returns:
|
||||
Success message with diff or error description
|
||||
"""
|
||||
return _edit_tools.edit_file(filepath, old_string, new_string, replace_all, expected_hash)
|
||||
return _edit_tools.edit_file(filepath, old_string, new_string, replace_all,
|
||||
expected_hash, force)
|
||||
|
||||
|
||||
@require_approval(risk_level="high")
|
||||
def apply_patch(patch: str) -> str:
|
||||
def apply_patch(patch: str, force: bool = False) -> str:
|
||||
"""Apply a structured multi-file patch atomically (Add/Update/Delete).
|
||||
|
||||
Args:
|
||||
patch: Structured patch text with ``*** Add File:``,
|
||||
``*** Update File:`` and ``*** Delete File:`` sections.
|
||||
force: Bypass the automatic staleness guard on Update operations.
|
||||
|
||||
Returns:
|
||||
Combined success message with diffs or an error description.
|
||||
"""
|
||||
return _edit_tools.apply_patch(patch)
|
||||
return _edit_tools.apply_patch(patch, force)
|
||||
|
||||
|
||||
def read_file(filepath: str) -> Tuple[str, str]:
|
||||
@@ -775,13 +1179,15 @@ def search_files(directory: str, pattern: str,
|
||||
return _edit_tools.search_files(directory, pattern, file_pattern)
|
||||
|
||||
|
||||
def create_edit_tools(workspace=None) -> EditTools:
|
||||
def create_edit_tools(workspace=None, post_edit_diagnostics: str = "auto") -> EditTools:
|
||||
"""Create EditTools instance with optional workspace containment.
|
||||
|
||||
Args:
|
||||
workspace: Optional Workspace instance for path containment
|
||||
post_edit_diagnostics: Diagnostics mode ("auto"|"on"|"off"); see
|
||||
``EditTools.__init__`` for details.
|
||||
|
||||
Returns:
|
||||
EditTools instance configured with workspace
|
||||
"""
|
||||
return EditTools(workspace=workspace)
|
||||
return EditTools(workspace=workspace, post_edit_diagnostics=post_edit_diagnostics)
|
||||
@@ -12,11 +12,13 @@ content = read_file("example.txt")
|
||||
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
from typing import List, Dict, Union, Optional
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import logging
|
||||
from ..approval import require_approval
|
||||
from . import _file_locks
|
||||
|
||||
class FileTools:
|
||||
"""Tools for file operations including read, write, list, and information."""
|
||||
@@ -76,7 +78,23 @@ class FileTools:
|
||||
raise ValueError(f"Path traversal detected: {filepath} escapes workspace {cwd}")
|
||||
|
||||
return absolute
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _content_hash(safe_path: str, encoding: str = 'utf-8') -> str:
|
||||
"""Hash the on-disk content in the same form EditTools records.
|
||||
|
||||
Reads bytes, strips a UTF-8 BOM, and hashes the decoded string with
|
||||
line endings preserved. This matches ``EditTools`` (which reads in
|
||||
binary mode) so the shared staleness registry stays consistent when a
|
||||
file is read by one tool and written by the other.
|
||||
"""
|
||||
with open(safe_path, 'rb') as f:
|
||||
raw_bytes = f.read()
|
||||
if raw_bytes.startswith(b'\xef\xbb\xbf'):
|
||||
raw_bytes = raw_bytes[3:]
|
||||
content = raw_bytes.decode(encoding)
|
||||
return hashlib.sha256(content.encode('utf-8')).hexdigest()
|
||||
|
||||
def read_file(self, filepath: str, encoding: str = 'utf-8') -> str:
|
||||
"""
|
||||
Read content from a file.
|
||||
@@ -91,22 +109,44 @@ class FileTools:
|
||||
try:
|
||||
# Validate path to prevent traversal attacks
|
||||
safe_path = self._validate_path(filepath)
|
||||
with open(safe_path, 'r', encoding=encoding) as f:
|
||||
return f.read()
|
||||
# Serialise with concurrent writers on this path so the read does
|
||||
# not observe a partially-written file.
|
||||
with _file_locks.get_lock(safe_path):
|
||||
with open(safe_path, 'r', encoding=encoding) as f:
|
||||
data = f.read()
|
||||
# Record the read hash so a later write engages the staleness
|
||||
# guard. Hash the on-disk byte form (CRLF preserved, BOM
|
||||
# stripped) so the recorded hash matches what EditTools (binary
|
||||
# mode) records for the same file, keeping the staleness guard
|
||||
# consistent across tools.
|
||||
try:
|
||||
_file_locks.record_read_hash(
|
||||
safe_path, self._content_hash(safe_path, encoding))
|
||||
except Exception:
|
||||
pass
|
||||
return data
|
||||
except Exception as e:
|
||||
error_msg = f"Error reading file {filepath}: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return error_msg
|
||||
|
||||
@require_approval(risk_level="high")
|
||||
def write_file(self, filepath: str, content: str, encoding: str = 'utf-8') -> bool:
|
||||
def write_file(self, filepath: str, content: str, encoding: str = 'utf-8',
|
||||
force: bool = False) -> bool:
|
||||
"""
|
||||
Write content to a file.
|
||||
|
||||
The write runs under a per-file lock shared with the edit tools, so a
|
||||
concurrent edit/write to the same path serialises instead of racing.
|
||||
If the file already exists and was previously read via these tools, the
|
||||
write aborts when the on-disk content changed since that read, unless
|
||||
``force=True`` is passed.
|
||||
|
||||
Args:
|
||||
filepath: Path to the file
|
||||
content: Content to write
|
||||
encoding: File encoding (default: utf-8)
|
||||
force: Bypass the automatic staleness guard for a blind overwrite
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
@@ -116,16 +156,46 @@ class FileTools:
|
||||
self._require_workspace_access(write=True)
|
||||
# Validate path to prevent traversal attacks
|
||||
safe_path = self._validate_path(filepath)
|
||||
# Create directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(safe_path), exist_ok=True)
|
||||
# Auto-coerce non-string content (LLMs sometimes pass dicts/lists)
|
||||
if not isinstance(content, str):
|
||||
if isinstance(content, (dict, list)):
|
||||
content = json.dumps(content, indent=2, ensure_ascii=False)
|
||||
else:
|
||||
content = str(content)
|
||||
with open(safe_path, 'w', encoding=encoding) as f:
|
||||
f.write(content)
|
||||
# Serialise concurrent writes/edits on this canonical path.
|
||||
with _file_locks.get_lock(safe_path):
|
||||
# Automatic staleness guard for existing files.
|
||||
if not force and os.path.exists(safe_path):
|
||||
recorded = _file_locks.get_read_hash(safe_path)
|
||||
if recorded is not None:
|
||||
# Fail closed: if the verification re-read raises (decode,
|
||||
# permission, etc.) refuse the write rather than blindly
|
||||
# clobbering the file.
|
||||
try:
|
||||
current = self._content_hash(safe_path, encoding)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
"Refusing to write %s: could not verify staleness: %s",
|
||||
filepath, e)
|
||||
return False
|
||||
if current != recorded:
|
||||
logging.error(
|
||||
"Refusing to write %s: file changed since it was "
|
||||
"read - re-read before writing (or pass force=True)",
|
||||
filepath)
|
||||
return False
|
||||
# Create directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(safe_path), exist_ok=True)
|
||||
with open(safe_path, 'w', encoding=encoding) as f:
|
||||
f.write(content)
|
||||
# Record the new content hash (in the on-disk byte form) so
|
||||
# follow-up writes are not falsely flagged stale and the hash
|
||||
# matches what a later read by either tool computes.
|
||||
try:
|
||||
_file_locks.record_read_hash(
|
||||
safe_path, self._content_hash(safe_path, encoding))
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
except Exception as e:
|
||||
error_msg = f"Error writing to file {filepath}: {str(e)}"
|
||||
|
||||
@@ -7,6 +7,7 @@ logger = logging.getLogger(__name__)
|
||||
@tool
|
||||
def github_create_branch(branch_name: str) -> str:
|
||||
"""Create and checkout a new git branch.
|
||||
|
||||
|
||||
Args:
|
||||
branch_name: The name of the branch to create and checkout.
|
||||
@@ -14,7 +15,16 @@ def github_create_branch(branch_name: str) -> str:
|
||||
try:
|
||||
# Check if we are in a git repository
|
||||
subprocess.run(["git", "rev-parse", "--is-inside-work-tree"], check=True, capture_output=True)
|
||||
# Validate branch name to prevent misinterpretation as git options or invalid refs
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "check-ref-format", "--branch", branch_name],
|
||||
check=True, capture_output=True, text=True
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
return f"Error: invalid branch name '{branch_name}': {e.stderr.strip() if e.stderr else 'branch name is not a valid git ref'}"
|
||||
subprocess.run(["git", "checkout", "-B", branch_name], check=True, capture_output=True, text=True)
|
||||
logger.debug(f"Branch '{branch_name}' checked out successfully.")
|
||||
logger.debug(f"Successfully checked out branch '{branch_name}'")
|
||||
return f"Successfully created and checked out branch '{branch_name}'"
|
||||
except subprocess.CalledProcessError as e:
|
||||
|
||||
@@ -139,6 +139,66 @@ def _parse_media(message: str) -> tuple[str, Optional[List[str]]]:
|
||||
return text, (media or None)
|
||||
|
||||
|
||||
def _check_send_policy(target: str) -> Optional[str]:
|
||||
"""Evaluate the active send-policy for ``target`` (Issue #2226).
|
||||
|
||||
Returns a model-readable denial string when the send is *not* permitted, or
|
||||
``None`` when allowed (no policy registered, or policy allows). The policy
|
||||
is consulted from the task-local context and is enriched with the current
|
||||
session's agent/session/origin so back-ends can scope decisions.
|
||||
"""
|
||||
try:
|
||||
from ..session.context import get_send_policy, get_session_context
|
||||
from ..gateway.protocols import SendDecision
|
||||
except Exception:
|
||||
# The session-context module is unavailable, so the send-policy feature
|
||||
# cannot have been configured. Preserve today's allow-all behaviour.
|
||||
return None
|
||||
|
||||
policy = get_send_policy()
|
||||
if policy is None:
|
||||
return None
|
||||
|
||||
agent_id = session_id = ""
|
||||
origin = None
|
||||
try:
|
||||
ctx = get_session_context()
|
||||
agent_id = getattr(ctx, "user_id", "") or ""
|
||||
session_id = getattr(ctx, "chat_id", "") or ""
|
||||
ctx_origin = getattr(ctx, "origin", None)
|
||||
if ctx_origin is not None and ctx_origin.platform:
|
||||
origin = ctx_origin.platform
|
||||
except Exception:
|
||||
# Context enrichment is best-effort; the policy still evaluates the
|
||||
# target with empty scope identifiers.
|
||||
logger.debug("send_policy context enrichment failed", exc_info=True)
|
||||
|
||||
try:
|
||||
decision = policy.evaluate(
|
||||
target,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
origin=origin,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("send_policy evaluation failed: %s", e, exc_info=True)
|
||||
return f"Failed to send to {target}: send_policy evaluation error"
|
||||
|
||||
# Fail closed: a policy that returns anything other than a SendDecision is
|
||||
# treated as a denial (consistent with how evaluation exceptions are
|
||||
# handled), so a non-conforming back-end can never implicitly allow a send.
|
||||
if not isinstance(decision, SendDecision):
|
||||
logger.error(
|
||||
"send_policy.evaluate() returned unexpected type %r; blocking send",
|
||||
type(decision),
|
||||
)
|
||||
return f"Failed to send to {target}: send_policy evaluation error"
|
||||
if not decision.allow:
|
||||
reason = decision.reason or "target not permitted by send_policy"
|
||||
return f"Failed to send to {target}: {reason}"
|
||||
return None
|
||||
|
||||
|
||||
def send_message(
|
||||
target: str = "origin",
|
||||
message: str = "",
|
||||
@@ -179,6 +239,15 @@ def send_message(
|
||||
if action != "send":
|
||||
return f"Unknown action '{action}'. Use 'send' or 'list'."
|
||||
|
||||
# Outbound send-policy guard (Issue #2226): the target is
|
||||
# model-controlled, so a steered/prompt-injected agent could misdeliver
|
||||
# to any reachable channel. Enforce the operator's allow/deny policy
|
||||
# *before* dispatch so every messenger implementation is constrained.
|
||||
# Absent a policy, today's behaviour is preserved (allow-all).
|
||||
denied = _check_send_policy(target)
|
||||
if denied is not None:
|
||||
return denied
|
||||
|
||||
text, media = _parse_media(message)
|
||||
result = _run_async(messenger.send(target, text, media=media))
|
||||
return result.summary or (
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ def schedule_add(
|
||||
channel_id: str = "",
|
||||
agent_id: str = "",
|
||||
session_id: str = "",
|
||||
accept_suggestion: str = "",
|
||||
) -> str:
|
||||
"""Add a new scheduled job.
|
||||
|
||||
@@ -85,6 +86,16 @@ def schedule_add(
|
||||
executor uses its default agent.
|
||||
session_id: Optional session ID to preserve conversation context.
|
||||
If set, the agent will have access to prior chat history.
|
||||
accept_suggestion: If set, accept the suggestion with this ID
|
||||
after the job is successfully added.
|
||||
|
||||
Note:
|
||||
The ``pre_run`` shell gate is intentionally NOT exposed through this
|
||||
agent-callable tool. ``pre_run`` runs an arbitrary shell command on the
|
||||
host on every tick, so accepting it from an LLM would let a prompt-
|
||||
injected agent persist server-side command execution. It is configured
|
||||
only by trusted job authors via the ``praisonai schedule add --pre-run``
|
||||
CLI or by constructing a :class:`ScheduleJob` directly in Python.
|
||||
|
||||
Returns:
|
||||
Confirmation string with the job id.
|
||||
@@ -155,7 +166,23 @@ def schedule_add(
|
||||
else:
|
||||
delivery_note = ""
|
||||
agent_note = f" agent={agent_id}" if agent_id else ""
|
||||
return f"Schedule '{name}' added (id: {job.id}, {schedule}{agent_note}{delivery_note})."
|
||||
confirmation = f"Schedule '{name}' added (id: {job.id}, {schedule}{agent_note}{delivery_note})."
|
||||
|
||||
# ── Suggestion acceptance integration ─────────────────────
|
||||
if accept_suggestion:
|
||||
try:
|
||||
from ..scheduler.suggestion_store import SuggestionStore
|
||||
store_obj = SuggestionStore()
|
||||
sug = store_obj.get(accept_suggestion)
|
||||
if sug is not None and not sug.accepted and not sug.dismissed:
|
||||
store_obj.accept(accept_suggestion)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to accept suggestion %s: %s",
|
||||
accept_suggestion, e,
|
||||
)
|
||||
|
||||
return confirmation
|
||||
except ValueError as e:
|
||||
return f"Error adding schedule: {e}"
|
||||
except Exception as e:
|
||||
|
||||
@@ -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}"
|
||||
@@ -236,17 +243,26 @@ class SkillTools:
|
||||
return f"Error listing skill scripts: {str(e)}"
|
||||
|
||||
@require_approval(risk_level="low")
|
||||
def skill_manage(self, action: str, name: str, *,
|
||||
def skill_manage(self, action: str, name: str = "", *,
|
||||
content=None, category=None, file_path=None,
|
||||
file_content=None, old_string=None,
|
||||
new_string=None, replace_all=False) -> str:
|
||||
new_string=None, replace_all=False,
|
||||
propose=None) -> str:
|
||||
"""Hermes-compatible skill management tool.
|
||||
|
||||
Actions: create, edit, patch, delete, write_file, remove_file
|
||||
Mutating actions (create, edit, patch, delete, write_file, remove_file)
|
||||
are *staged for human approval by default* (safe-by-default). The model
|
||||
receives a ``{"status": "pending", "id": ...}`` response and a human
|
||||
approves via the ``approve``/``reject`` actions (or the gateway
|
||||
``/skills`` commands). Pass ``propose=False`` to write directly in
|
||||
trusted/local contexts.
|
||||
|
||||
Actions: create, edit, patch, delete, write_file, remove_file,
|
||||
pending, approve, reject
|
||||
|
||||
Args:
|
||||
action: Action to perform
|
||||
name: Skill name
|
||||
name: Skill name (or pending id for approve/reject)
|
||||
content: Skill content (for create/edit)
|
||||
category: Skill category (for create)
|
||||
file_path: File path within skill (for patch/write_file/remove_file)
|
||||
@@ -254,6 +270,9 @@ class SkillTools:
|
||||
old_string: String to find (for patch)
|
||||
new_string: Replacement string (for patch)
|
||||
replace_all: Replace all occurrences (for patch)
|
||||
propose: Override the staging policy. If None, uses the manager's
|
||||
write-approval default; True stages for approval; False writes
|
||||
directly.
|
||||
|
||||
Returns:
|
||||
JSON string with result
|
||||
@@ -266,30 +285,43 @@ class SkillTools:
|
||||
if action == "create":
|
||||
if not content:
|
||||
return json.dumps({"success": False, "error": "Content required for create action"})
|
||||
result = self.skill_manager.create_skill(name, content, category)
|
||||
result = self.skill_manager.create_skill(name, content, category, propose=propose)
|
||||
|
||||
elif action == "edit":
|
||||
if not content:
|
||||
return json.dumps({"success": False, "error": "Content required for edit action"})
|
||||
result = self.skill_manager.edit_skill(name, content)
|
||||
result = self.skill_manager.edit_skill(name, content, propose=propose)
|
||||
|
||||
elif action == "patch":
|
||||
if not old_string or new_string is None:
|
||||
return json.dumps({"success": False, "error": "old_string and new_string required for patch action"})
|
||||
result = self.skill_manager.patch_skill(name, old_string, new_string, file_path, replace_all)
|
||||
result = self.skill_manager.patch_skill(name, old_string, new_string, file_path, replace_all, propose=propose)
|
||||
|
||||
elif action == "delete":
|
||||
result = self.skill_manager.delete_skill(name)
|
||||
result = self.skill_manager.delete_skill(name, propose=propose)
|
||||
|
||||
elif action == "write_file":
|
||||
if not file_path or not file_content:
|
||||
if not file_path or file_content is None:
|
||||
return json.dumps({"success": False, "error": "file_path and file_content required for write_file action"})
|
||||
result = self.skill_manager.write_skill_file(name, file_path, file_content)
|
||||
result = self.skill_manager.write_skill_file(name, file_path, file_content, propose=propose)
|
||||
|
||||
elif action == "remove_file":
|
||||
if not file_path:
|
||||
return json.dumps({"success": False, "error": "file_path required for remove_file action"})
|
||||
result = self.skill_manager.remove_skill_file(name, file_path)
|
||||
result = self.skill_manager.remove_skill_file(name, file_path, propose=propose)
|
||||
|
||||
elif action == "pending":
|
||||
result = {"success": True, "pending": self.skill_manager.list_pending()}
|
||||
|
||||
elif action == "approve":
|
||||
if not name:
|
||||
return json.dumps({"success": False, "error": "id or name required for approve action"})
|
||||
result = self.skill_manager.approve(name)
|
||||
|
||||
elif action == "reject":
|
||||
if not name:
|
||||
return json.dumps({"success": False, "error": "id or name required for reject action"})
|
||||
result = self.skill_manager.reject(name)
|
||||
|
||||
else:
|
||||
return json.dumps({"success": False, "error": f"Unknown action: {action}"})
|
||||
@@ -412,14 +444,15 @@ def list_skill_scripts(skill_path: str) -> str:
|
||||
|
||||
|
||||
@require_approval(risk_level="low")
|
||||
def skill_manage(action: str, name: str, **kwargs) -> str:
|
||||
def skill_manage(action: str, name: str = "", **kwargs) -> str:
|
||||
"""Skill management tool (hermes-compatible API).
|
||||
|
||||
Actions: create, edit, patch, delete, write_file, remove_file
|
||||
Actions: create, edit, patch, delete, write_file, remove_file,
|
||||
pending, approve, reject
|
||||
|
||||
Args:
|
||||
action: Action to perform
|
||||
name: Skill name
|
||||
name: Skill name (or pending id for approve/reject; omit for pending)
|
||||
**kwargs: Additional parameters for the action
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -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.72"
|
||||
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,247 @@
|
||||
"""Tests for skill provenance, usage telemetry, archival, and rollback."""
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
|
||||
def _make_manager(tmpdir, name="my-skill", body="Original body."):
|
||||
from praisonaiagents.skills.manager import SkillManager
|
||||
|
||||
skill_dir = Path(tmpdir) / name
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
f"""---
|
||||
name: {name}
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
{body}
|
||||
"""
|
||||
)
|
||||
manager = SkillManager()
|
||||
manager.add_skill(str(skill_dir))
|
||||
return manager
|
||||
|
||||
|
||||
class TestProvenanceFields:
|
||||
def test_properties_have_provenance_defaults(self):
|
||||
from praisonaiagents.skills.models import SkillProperties
|
||||
|
||||
props = SkillProperties(name="x", description="y")
|
||||
assert props.agent_created is False
|
||||
assert props.created_at is None
|
||||
assert props.use_count == 0
|
||||
assert props.last_used is None
|
||||
assert props.patch_count == 0
|
||||
|
||||
def test_to_dict_includes_provenance(self):
|
||||
from praisonaiagents.skills.models import SkillProperties
|
||||
|
||||
props = SkillProperties(
|
||||
name="x",
|
||||
description="y",
|
||||
agent_created=True,
|
||||
created_at="2024-01-01T00:00:00+00:00",
|
||||
use_count=3,
|
||||
patch_count=1,
|
||||
)
|
||||
d = props.to_dict()
|
||||
assert d["agent-created"] is True
|
||||
assert d["created-at"] == "2024-01-01T00:00:00+00:00"
|
||||
assert d["use-count"] == 3
|
||||
assert d["patch-count"] == 1
|
||||
|
||||
def test_parser_reads_provenance(self):
|
||||
from praisonaiagents.skills.parser import read_properties
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_dir = Path(tmpdir) / "prov-skill"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"""---
|
||||
name: prov-skill
|
||||
description: provenance test
|
||||
agent-created: true
|
||||
created-at: "2024-01-01T00:00:00+00:00"
|
||||
use-count: 5
|
||||
patch-count: 2
|
||||
---
|
||||
|
||||
Body.
|
||||
"""
|
||||
)
|
||||
props = read_properties(skill_dir)
|
||||
assert props.agent_created is True
|
||||
assert props.created_at == "2024-01-01T00:00:00+00:00"
|
||||
assert props.use_count == 5
|
||||
assert props.patch_count == 2
|
||||
|
||||
def test_idle_days_none_without_timestamps(self):
|
||||
from praisonaiagents.skills.models import SkillProperties
|
||||
|
||||
props = SkillProperties(name="x", description="y")
|
||||
assert props.idle_days is None
|
||||
|
||||
def test_idle_days_positive_for_old_timestamp(self):
|
||||
from praisonaiagents.skills.models import SkillProperties
|
||||
|
||||
props = SkillProperties(
|
||||
name="x", description="y", created_at="2000-01-01T00:00:00+00:00"
|
||||
)
|
||||
assert props.idle_days is not None
|
||||
assert props.idle_days > 1000
|
||||
|
||||
|
||||
class TestCreateProvenance:
|
||||
def test_create_skill_marks_agent_created(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
from praisonaiagents.skills import manager as manager_mod
|
||||
|
||||
orig = manager_mod.get_default_skill_dirs
|
||||
manager_mod.get_default_skill_dirs = lambda: [tmpdir]
|
||||
try:
|
||||
manager = manager_mod.SkillManager()
|
||||
result = manager.create_skill("scrape-x", "Do the scrape.", propose=False)
|
||||
assert result["success"] is True
|
||||
skill = manager.get_skill("scrape-x")
|
||||
assert skill.properties.agent_created is True
|
||||
assert skill.properties.created_at is not None
|
||||
assert skill.properties.use_count == 0
|
||||
finally:
|
||||
manager_mod.get_default_skill_dirs = orig
|
||||
|
||||
|
||||
class TestUsageTelemetry:
|
||||
def test_invoke_increments_use_count(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manager = _make_manager(tmpdir)
|
||||
manager.invoke("my-skill")
|
||||
skill = manager.get_skill("my-skill")
|
||||
assert skill.properties.use_count == 1
|
||||
assert skill.properties.last_used is not None
|
||||
|
||||
def test_get_instructions_increments_use_count(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manager = _make_manager(tmpdir)
|
||||
manager.get_instructions("my-skill")
|
||||
skill = manager.get_skill("my-skill")
|
||||
assert skill.properties.use_count == 1
|
||||
|
||||
def test_use_count_persists_to_frontmatter(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manager = _make_manager(tmpdir)
|
||||
manager.get_instructions("my-skill")
|
||||
content = (Path(tmpdir) / "my-skill" / "SKILL.md").read_text()
|
||||
assert "use-count: 1" in content
|
||||
assert "last-used:" in content
|
||||
|
||||
def test_patch_increments_patch_count(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manager = _make_manager(tmpdir)
|
||||
res = manager.patch_skill("my-skill", "Original body.", "New body.", propose=False)
|
||||
assert res["success"] is True
|
||||
skill = manager.get_skill("my-skill")
|
||||
assert skill.properties.patch_count == 1
|
||||
|
||||
def test_telemetry_preserves_embedded_fence_in_value(self):
|
||||
"""A frontmatter value containing '---' must not corrupt the file
|
||||
when telemetry is persisted line-wise."""
|
||||
from praisonaiagents.skills.manager import SkillManager
|
||||
|
||||
skill_dir = Path(tmpdir := tempfile.mkdtemp()) / "fence-skill"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"""---
|
||||
name: fence-skill
|
||||
description: Parse YAML---based configs
|
||||
---
|
||||
|
||||
The body --- with a fence-like line stays intact.
|
||||
"""
|
||||
)
|
||||
manager = SkillManager()
|
||||
manager.add_skill(str(skill_dir))
|
||||
manager.get_instructions("fence-skill")
|
||||
|
||||
content = (skill_dir / "SKILL.md").read_text()
|
||||
assert "description: Parse YAML---based configs" in content
|
||||
assert "The body --- with a fence-like line stays intact." in content
|
||||
assert "use-count: 1" in content
|
||||
|
||||
|
||||
class TestArchivalAndRestore:
|
||||
def test_archive_then_restore(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
from praisonaiagents.skills import manager as manager_mod
|
||||
|
||||
orig = manager_mod.get_default_skill_dirs
|
||||
manager_mod.get_default_skill_dirs = lambda: [str(Path(tmpdir) / "skills")]
|
||||
try:
|
||||
(Path(tmpdir) / "skills").mkdir()
|
||||
manager = manager_mod.SkillManager()
|
||||
manager.create_skill("temp-skill", "content", propose=False)
|
||||
assert "temp-skill" in manager
|
||||
|
||||
res = manager.archive_skill("temp-skill")
|
||||
assert res["success"] is True
|
||||
assert "temp-skill" not in manager
|
||||
assert "temp-skill" in manager.list_archived_skills()
|
||||
|
||||
restored = manager.restore_skill("temp-skill")
|
||||
assert restored["success"] is True
|
||||
assert "temp-skill" in manager
|
||||
finally:
|
||||
manager_mod.get_default_skill_dirs = orig
|
||||
|
||||
def test_delete_default_is_recoverable(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
from praisonaiagents.skills import manager as manager_mod
|
||||
|
||||
orig = manager_mod.get_default_skill_dirs
|
||||
manager_mod.get_default_skill_dirs = lambda: [str(Path(tmpdir) / "skills")]
|
||||
try:
|
||||
(Path(tmpdir) / "skills").mkdir()
|
||||
manager = manager_mod.SkillManager()
|
||||
manager.create_skill("temp-skill", "content", propose=False)
|
||||
res = manager.delete_skill("temp-skill", propose=False)
|
||||
assert res["success"] is True
|
||||
assert "temp-skill" in manager.list_archived_skills()
|
||||
finally:
|
||||
manager_mod.get_default_skill_dirs = orig
|
||||
|
||||
def test_hard_delete_is_unrecoverable(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manager = _make_manager(tmpdir)
|
||||
res = manager.delete_skill("my-skill", hard=True, propose=False)
|
||||
assert res["success"] is True
|
||||
assert "my-skill" not in manager
|
||||
|
||||
|
||||
class TestRollback:
|
||||
def test_rollback_patch(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manager = _make_manager(tmpdir)
|
||||
manager.patch_skill("my-skill", "Original body.", "Broken body.", propose=False)
|
||||
instructions = manager.get_instructions("my-skill")
|
||||
assert "Broken body." in instructions
|
||||
|
||||
res = manager.rollback_skill("my-skill")
|
||||
assert res["success"] is True
|
||||
assert "Original body." in manager.get_instructions("my-skill")
|
||||
|
||||
def test_rollback_edit(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manager = _make_manager(tmpdir)
|
||||
manager.edit_skill("my-skill", "Replacement body.", propose=False)
|
||||
assert "Replacement body." in manager.get_instructions("my-skill")
|
||||
|
||||
res = manager.rollback_skill("my-skill")
|
||||
assert res["success"] is True
|
||||
assert "Original body." in manager.get_instructions("my-skill")
|
||||
|
||||
def test_rollback_without_snapshot_fails_cleanly(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manager = _make_manager(tmpdir)
|
||||
res = manager.rollback_skill("my-skill")
|
||||
assert res["success"] is False
|
||||
assert "No rollback snapshot" in res["error"]
|
||||
@@ -270,7 +270,7 @@ content
|
||||
manager = SkillManager()
|
||||
manager.add_skill(str(skill_dir))
|
||||
|
||||
result = manager.patch_skill("safe-skill", "content", "updated", "../outside.md")
|
||||
result = manager.patch_skill("safe-skill", "content", "updated", "../outside.md", propose=False)
|
||||
assert result["success"] is False
|
||||
assert "Path traversal detected" in result["error"]
|
||||
|
||||
@@ -291,6 +291,203 @@ content
|
||||
manager = SkillManager()
|
||||
manager.add_skill(str(skill_dir))
|
||||
|
||||
result = manager.write_skill_file("safe-skill", "scripts/../../outside.py", "print('x')")
|
||||
result = manager.write_skill_file("safe-skill", "scripts/../../outside.py", "print('x')", propose=False)
|
||||
assert result["success"] is False
|
||||
assert "Path traversal detected" in result["error"]
|
||||
|
||||
|
||||
class TestSkillApprovalGate:
|
||||
"""Tests for the safe-by-default skill mutation approval gate."""
|
||||
|
||||
def test_create_stages_by_default(self, monkeypatch):
|
||||
"""create_skill defaults to staging, not writing to disk."""
|
||||
from praisonaiagents.skills.manager import SkillManager
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
monkeypatch.chdir(tmpdir)
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
|
||||
from praisonaiagents import paths
|
||||
paths._clear_cache()
|
||||
|
||||
manager = SkillManager()
|
||||
result = manager.create_skill("staged-skill", "# body")
|
||||
|
||||
assert result["status"] == "pending"
|
||||
assert result["id"].startswith("skl-")
|
||||
# Not written to disk / not loaded live.
|
||||
assert "staged-skill" not in manager
|
||||
skill_path = Path(tmpdir) / "home" / "skills" / "staged-skill"
|
||||
assert not skill_path.exists()
|
||||
|
||||
def test_propose_false_writes_directly(self, monkeypatch):
|
||||
"""propose=False bypasses the gate and writes immediately."""
|
||||
from praisonaiagents.skills.manager import SkillManager
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
monkeypatch.chdir(tmpdir)
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
|
||||
from praisonaiagents import paths
|
||||
paths._clear_cache()
|
||||
|
||||
manager = SkillManager()
|
||||
result = manager.create_skill("direct-skill", "# body", propose=False)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result.get("status") != "pending"
|
||||
assert "direct-skill" in manager
|
||||
|
||||
def test_env_disables_approval(self, monkeypatch):
|
||||
"""SKILL_WRITE_APPROVAL=0 makes writes direct by default."""
|
||||
from praisonaiagents.skills.manager import SkillManager
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
monkeypatch.chdir(tmpdir)
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
|
||||
monkeypatch.setenv("SKILL_WRITE_APPROVAL", "0")
|
||||
from praisonaiagents import paths
|
||||
paths._clear_cache()
|
||||
|
||||
manager = SkillManager()
|
||||
result = manager.create_skill("env-skill", "# body")
|
||||
|
||||
assert result.get("status") != "pending"
|
||||
assert "env-skill" in manager
|
||||
|
||||
def test_list_pending_and_approve(self, monkeypatch):
|
||||
"""Staged mutation appears in list_pending and applies on approve."""
|
||||
from praisonaiagents.skills.manager import SkillManager
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
monkeypatch.chdir(tmpdir)
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
|
||||
from praisonaiagents import paths
|
||||
paths._clear_cache()
|
||||
|
||||
manager = SkillManager()
|
||||
staged = manager.create_skill("approve-me", "# body")
|
||||
request_id = staged["id"]
|
||||
|
||||
pending = manager.list_pending()
|
||||
assert any(p["id"] == request_id for p in pending)
|
||||
assert pending[0]["action"] == "create"
|
||||
|
||||
result = manager.approve(request_id)
|
||||
assert result["success"] is True
|
||||
assert "approve-me" in manager
|
||||
# Pending entry consumed.
|
||||
assert manager.list_pending() == []
|
||||
|
||||
def test_reject_discards_mutation(self, monkeypatch):
|
||||
"""Rejecting a staged mutation never writes it."""
|
||||
from praisonaiagents.skills.manager import SkillManager
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
monkeypatch.chdir(tmpdir)
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
|
||||
from praisonaiagents import paths
|
||||
paths._clear_cache()
|
||||
|
||||
manager = SkillManager()
|
||||
staged = manager.create_skill("reject-me", "# body")
|
||||
request_id = staged["id"]
|
||||
|
||||
result = manager.reject(request_id)
|
||||
assert result["success"] is True
|
||||
assert result["status"] == "rejected"
|
||||
assert "reject-me" not in manager
|
||||
assert manager.list_pending() == []
|
||||
|
||||
def test_approve_unknown_id_fails(self, monkeypatch):
|
||||
"""Approving a non-existent id returns an error."""
|
||||
from praisonaiagents.skills.manager import SkillManager
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
monkeypatch.chdir(tmpdir)
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
|
||||
from praisonaiagents import paths
|
||||
paths._clear_cache()
|
||||
|
||||
manager = SkillManager()
|
||||
result = manager.approve("skl-nonexistent")
|
||||
assert result["success"] is False
|
||||
|
||||
def test_stage_rejects_oversized_content(self, monkeypatch):
|
||||
"""Oversized proposals are rejected before reaching the pending store."""
|
||||
from praisonaiagents.skills.manager import SkillManager
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
monkeypatch.chdir(tmpdir)
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
|
||||
from praisonaiagents import paths
|
||||
paths._clear_cache()
|
||||
|
||||
manager = SkillManager()
|
||||
result = manager.create_skill("too-big", "x" * 100_001)
|
||||
|
||||
assert result["success"] is False
|
||||
assert manager.list_pending() == []
|
||||
|
||||
def test_stage_rejects_invalid_name(self, monkeypatch):
|
||||
"""Invalid skill names are rejected at staging time for all actions."""
|
||||
from praisonaiagents.skills.manager import SkillManager
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
monkeypatch.chdir(tmpdir)
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
|
||||
from praisonaiagents import paths
|
||||
paths._clear_cache()
|
||||
|
||||
manager = SkillManager()
|
||||
result = manager.edit_skill("../escape", "# body")
|
||||
|
||||
assert result["success"] is False
|
||||
assert manager.list_pending() == []
|
||||
|
||||
def test_pending_store_size_cap(self, monkeypatch):
|
||||
"""The pending store refuses new proposals past SKILL_MAX_PENDING."""
|
||||
from praisonaiagents.skills.manager import SkillManager
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
monkeypatch.chdir(tmpdir)
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
|
||||
monkeypatch.setenv("SKILL_MAX_PENDING", "2")
|
||||
from praisonaiagents import paths
|
||||
paths._clear_cache()
|
||||
|
||||
manager = SkillManager()
|
||||
assert manager.create_skill("one", "# a")["status"] == "pending"
|
||||
assert manager.create_skill("two", "# b")["status"] == "pending"
|
||||
full = manager.create_skill("three", "# c")
|
||||
assert full["success"] is False
|
||||
assert len(manager.list_pending()) == 2
|
||||
|
||||
def test_approve_failure_keeps_record(self, monkeypatch):
|
||||
"""A failed apply preserves the pending record and does not lie in audit."""
|
||||
from praisonaiagents.skills.manager import SkillManager
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
monkeypatch.chdir(tmpdir)
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
|
||||
from praisonaiagents import paths
|
||||
paths._clear_cache()
|
||||
|
||||
manager = SkillManager()
|
||||
staged = manager.create_skill("flaky", "# body")
|
||||
request_id = staged["id"]
|
||||
|
||||
def _boom(_record):
|
||||
return {"success": False, "error": "boom"}
|
||||
|
||||
monkeypatch.setattr(manager, "_apply_pending", _boom)
|
||||
result = manager.approve(request_id)
|
||||
|
||||
assert result["success"] is False
|
||||
# Record retained for retry.
|
||||
assert any(p["id"] == request_id for p in manager.list_pending())
|
||||
|
||||
def test_protocol_conformance(self):
|
||||
"""SkillManager conforms to SkillMutatorProtocol."""
|
||||
from praisonaiagents.skills.manager import SkillManager
|
||||
from praisonaiagents.skills.protocols import SkillMutatorProtocol
|
||||
|
||||
assert isinstance(SkillManager(), SkillMutatorProtocol)
|
||||
Loaded 100 of 285 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user