mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 01:08:39 -10:00
Compare commits
168
Commits
No files matched your search
@@ -227,9 +227,10 @@ runs:
|
||||
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
|
||||
3. Tools (PraisonAI-Tools): Specific integrations, external tools (e.g. SurrealDB, Slack), and community extensions MUST go to the `MervinPraison/PraisonAI-Tools` repository. If the feature is a tool, clone `https://github.com/MervinPraison/PraisonAI-Tools`, implement it there, and create the PR using `gh pr create -R MervinPraison/PraisonAI-Tools`.
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
|
||||
- Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
|
||||
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead. PRs with >100 lines added to agent.py require manual review (merge gate will not auto-merge).
|
||||
- Is there a name collision with existing exports in __init__.py?
|
||||
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
|
||||
|
||||
@@ -260,16 +261,24 @@ runs:
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
|
||||
GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
INPUT_MODEL: ${{ inputs.model }}
|
||||
INPUT_MAX_TURNS: ${{ inputs.max_turns }}
|
||||
INPUT_ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
|
||||
INPUT_DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
|
||||
INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: inherit
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
|
||||
run: |
|
||||
echo "🚀 Running Claude Code with GitHub context..."
|
||||
|
||||
# Build command arguments
|
||||
CMD_ARGS=("-p" "--verbose" "--output-format" "stream-json")
|
||||
|
||||
if [[ -n "$INPUT_MODEL" ]]; then
|
||||
CMD_ARGS+=("--model" "$INPUT_MODEL")
|
||||
export ANTHROPIC_MODEL="$INPUT_MODEL"
|
||||
fi
|
||||
|
||||
# Add max turns if specified
|
||||
if [[ -n "$INPUT_MAX_TURNS" ]]; then
|
||||
CMD_ARGS+=("--max-turns" "$INPUT_MAX_TURNS")
|
||||
@@ -280,11 +289,36 @@ runs:
|
||||
if [[ -n "$INPUT_ALLOWED_TOOLS" ]]; then
|
||||
TOOLS="$TOOLS,$INPUT_ALLOWED_TOOLS"
|
||||
fi
|
||||
|
||||
# Remove any explicitly disallowed tools from the effective allow-list so
|
||||
# the read-only contract is enforced deterministically (no reliance on CLI
|
||||
# precedence between --allowedTools and --disallowedTools).
|
||||
if [[ -n "$INPUT_DISALLOWED_TOOLS" ]]; then
|
||||
DISALLOWED_CSV=$(echo "$INPUT_DISALLOWED_TOOLS" | tr ',\n' ' ' | xargs)
|
||||
FILTERED=""
|
||||
IFS=',' read -ra TOOL_ARR <<< "$TOOLS"
|
||||
for tool in "${TOOL_ARR[@]}"; do
|
||||
tool=$(echo "$tool" | xargs)
|
||||
[[ -z "$tool" ]] && continue
|
||||
skip="false"
|
||||
for bad in $DISALLOWED_CSV; do
|
||||
if [[ "$tool" == "$bad" ]]; then
|
||||
skip="true"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$skip" == "false" ]]; then
|
||||
FILTERED="${FILTERED:+$FILTERED,}$tool"
|
||||
fi
|
||||
done
|
||||
TOOLS="$FILTERED"
|
||||
fi
|
||||
CMD_ARGS+=("--allowedTools" "$TOOLS")
|
||||
|
||||
# Add disallowed tools
|
||||
if [[ -n "$INPUT_DISALLOWED_TOOLS" ]]; then
|
||||
CMD_ARGS+=("--disallowedTools" "$INPUT_DISALLOWED_TOOLS")
|
||||
DISALLOWED_FLAG=$(echo "$INPUT_DISALLOWED_TOOLS" | tr '\n' ',' | sed 's/,*$//')
|
||||
CMD_ARGS+=("--disallowedTools" "$DISALLOWED_FLAG")
|
||||
fi
|
||||
|
||||
echo "📝 Executing Claude Code with prompt from file..."
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
const chain = require('./bot-pr-review-chain.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
assert('chainKickPosted requires both markers', chain.chainKickPosted([
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
{ user: { login: 'MervinPraison' }, body: '/review' },
|
||||
]));
|
||||
assert('chainKickPosted false with only coderabbit', !chain.chainKickPosted([
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
]));
|
||||
assert('coderabbitKickPosted detects coderabbit', chain.coderabbitKickPosted([
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
]));
|
||||
assert('qodoKickPosted detects qodo', chain.qodoKickPosted([
|
||||
{ user: { login: 'github-actions[bot]' }, body: '/review' },
|
||||
]));
|
||||
assert('ignores coderabbit bot comment', !chain.coderabbitKickPosted([
|
||||
{ user: { login: 'coderabbitai[bot]' }, body: '@coderabbitai review' },
|
||||
]));
|
||||
assert('bot PR author match', chain.isBotOpenedPr({ user: { login: 'praisonai-triage-agent[bot]' } }));
|
||||
assert('generic bot type match', chain.isBotOpenedPr({ user: { login: 'my-bot', type: 'Bot' } }));
|
||||
assert('human author excluded', !chain.isBotOpenedPr({ user: { login: 'MervinPraison', type: 'User' } }));
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Idempotent CodeRabbit/Qodo kick for bot-opened PRs.
|
||||
* @see .github/workflows/auto-pr-comment.yml, claude.yml, bot-pr-recovery.yml
|
||||
*/
|
||||
|
||||
const KICK_AUTHORS = new Set(['MervinPraison', 'github-actions[bot]']);
|
||||
const BOT_PR_AUTHORS = new Set(['praisonai-triage-agent[bot]', 'github-actions[bot]']);
|
||||
|
||||
async function listAllComments(github, owner, repo, issueNumber) {
|
||||
if (typeof github.paginate === 'function') {
|
||||
return github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
const { data } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
function kickAuthored(comment, marker) {
|
||||
return (
|
||||
KICK_AUTHORS.has(comment.user?.login) &&
|
||||
(comment.body || '').includes(marker)
|
||||
);
|
||||
}
|
||||
|
||||
function coderabbitKickPosted(comments) {
|
||||
return comments.some((c) => kickAuthored(c, '@coderabbitai review'));
|
||||
}
|
||||
|
||||
function qodoKickPosted(comments) {
|
||||
return comments.some((c) => kickAuthored(c, '/review'));
|
||||
}
|
||||
|
||||
function chainKickPosted(comments) {
|
||||
return coderabbitKickPosted(comments) && qodoKickPosted(comments);
|
||||
}
|
||||
|
||||
function isBotOpenedPr(pr) {
|
||||
if (BOT_PR_AUTHORS.has(pr.user?.login)) return true;
|
||||
return pr.user?.type === 'Bot';
|
||||
}
|
||||
|
||||
async function kickReviewChain(github, owner, repo, prNumber, core, preFetchedComments = null) {
|
||||
const comments = preFetchedComments || await listAllComments(github, owner, repo, prNumber);
|
||||
const needCoderabbit = !coderabbitKickPosted(comments);
|
||||
const needQodo = !qodoKickPosted(comments);
|
||||
|
||||
if (!needCoderabbit && !needQodo) {
|
||||
core?.info?.(`Review chain already kicked on PR #${prNumber}`);
|
||||
return { kicked: false, reason: 'already_kicked' };
|
||||
}
|
||||
|
||||
if (needCoderabbit) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: '@coderabbitai review',
|
||||
});
|
||||
}
|
||||
if (needQodo) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: '/review',
|
||||
});
|
||||
}
|
||||
core?.info?.(`Kicked review chain for PR #${prNumber} (coderabbit=${needCoderabbit}, qodo=${needQodo})`);
|
||||
return { kicked: true, coderabbit: needCoderabbit, qodo: needQodo };
|
||||
}
|
||||
|
||||
async function findOpenPrForIssue(github, owner, repo, issueNumber) {
|
||||
const prefix = `claude/issue-${issueNumber}-`;
|
||||
const { data: prs } = await github.rest.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
sort: 'created',
|
||||
direction: 'desc',
|
||||
per_page: 30,
|
||||
});
|
||||
return (
|
||||
prs.find(
|
||||
(p) => (p.head?.ref || '').startsWith(prefix) && isBotOpenedPr(p)
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
async function kickReviewChainForIssue(github, owner, repo, issueNumber, core) {
|
||||
const pr = await findOpenPrForIssue(github, owner, repo, issueNumber);
|
||||
if (!pr) {
|
||||
core?.info?.(`No open PR for issue #${issueNumber}, skipping review kick`);
|
||||
return { kicked: false, reason: 'no_pr' };
|
||||
}
|
||||
const result = await kickReviewChain(github, owner, repo, pr.number, core);
|
||||
return { ...result, prNumber: pr.number };
|
||||
}
|
||||
|
||||
async function recoverStalledBotPrs(github, owner, repo, options, core) {
|
||||
const { prNumber = null, minAgeMs = 10 * 60 * 1000, maxRecover = 10 } = options || {};
|
||||
let prs;
|
||||
if (prNumber) {
|
||||
const { data } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
prs = [data];
|
||||
} else if (typeof github.paginate === 'function') {
|
||||
prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
} else {
|
||||
const { data } = await github.rest.pulls.list({ owner, repo, state: 'open', per_page: 100 });
|
||||
prs = data;
|
||||
}
|
||||
|
||||
const cutoff = Date.now() - minAgeMs;
|
||||
let recovered = 0;
|
||||
for (const pr of prs) {
|
||||
if (recovered >= maxRecover) break;
|
||||
if (!isBotOpenedPr(pr)) continue;
|
||||
if (!prNumber && new Date(pr.created_at).getTime() > cutoff) continue;
|
||||
const comments = await listAllComments(github, owner, repo, pr.number);
|
||||
if (chainKickPosted(comments)) continue;
|
||||
await kickReviewChain(github, owner, repo, pr.number, core, comments);
|
||||
recovered += 1;
|
||||
}
|
||||
core?.info?.(`Recovery complete (${recovered} PR(s) kicked)`);
|
||||
return recovered;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KICK_AUTHORS,
|
||||
BOT_PR_AUTHORS,
|
||||
listAllComments,
|
||||
chainKickPosted,
|
||||
coderabbitKickPosted,
|
||||
qodoKickPosted,
|
||||
isBotOpenedPr,
|
||||
kickReviewChain,
|
||||
findOpenPrForIssue,
|
||||
kickReviewChainForIssue,
|
||||
recoverStalledBotPrs,
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Local self-test for merge-gate.js heuristics (no GitHub API).
|
||||
* Run: node .github/scripts/merge-gate-selftest.js
|
||||
*/
|
||||
const mg = require('./merge-gate.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
// Stale FINAL: push after FINAL, no @claude since head
|
||||
const finals = [
|
||||
{ user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T08:00:00Z' },
|
||||
];
|
||||
assert('stale when head after final', mg.isStaleFinalAfterPush(finals, '2026-06-12T09:00:00Z'));
|
||||
|
||||
const withRecovery = [
|
||||
...finals,
|
||||
{ user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T09:30:00Z' },
|
||||
];
|
||||
assert('not stale when @claude after head', !mg.isStaleFinalAfterPush(withRecovery, '2026-06-12T09:00:00Z'));
|
||||
|
||||
// Bot CHANGES_REQUESTED then APPROVE
|
||||
const reviews = [
|
||||
{ user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T08:00:00Z' },
|
||||
{ user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'APPROVED', submitted_at: '2026-06-12T09:00:00Z' },
|
||||
];
|
||||
assert('bot approve clears CR', !mg.hasAnyChangesRequested(reviews));
|
||||
assert('human CR blocks', mg.hasAnyChangesRequested([
|
||||
{ user: { login: 'MervinPraison', type: 'User' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T09:00:00Z' },
|
||||
]));
|
||||
|
||||
// Verdict after HEAD
|
||||
const verdictComments = [
|
||||
{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T08:00:00Z' },
|
||||
];
|
||||
assert('verdict before head rejected', mg.findMergeGateVerdict(verdictComments, null, '2026-06-12T09:00:00Z') === null);
|
||||
assert('verdict after head accepted', mg.findMergeGateVerdict(
|
||||
[{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T10:00:00Z' }],
|
||||
null,
|
||||
'2026-06-12T09:00:00Z'
|
||||
) === 'APPROVE');
|
||||
|
||||
const noise = [{ user: { login: 'MervinPraison' }, body: '**Merge gate scan** — wait for `@claude`', created_at: new Date().toISOString() }];
|
||||
assert('diagnostic comment not a trigger', !mg.hasRecentClaudeTrigger(noise, 35));
|
||||
|
||||
// Sensitive + secrets
|
||||
assert('workflow path sensitive', mg.sensitivePathReasons([{ filename: '.github/workflows/foo.yml' }]).length === 1);
|
||||
assert('ci-only label exempts workflows', mg.sensitivePathReasons(
|
||||
[{ filename: '.github/workflows/foo.yml' }],
|
||||
[mg.WORKFLOW_ONLY_LABEL]
|
||||
).length === 0);
|
||||
assert('ci-only label not exempt mixed changes', mg.sensitivePathReasons(
|
||||
[{ filename: '.github/workflows/foo.yml' }, { filename: 'src/foo.py' }],
|
||||
[mg.WORKFLOW_ONLY_LABEL]
|
||||
).length === 1);
|
||||
assert('secret in patch', mg.secretScanReasons([{ filename: 'x.py', patch: '+key = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"' }]).length === 1);
|
||||
|
||||
// Claude run scoping — other PR branches must not block
|
||||
assert('other branch claude does not block', !mg.hasBlockingClaudeRunForPr(
|
||||
[{ event: 'issue_comment', head_branch: 'other-branch' }],
|
||||
'my-branch'
|
||||
));
|
||||
assert('same branch claude blocks', mg.hasBlockingClaudeRunForPr(
|
||||
[{ event: 'issue_comment', head_branch: 'my-branch' }],
|
||||
'my-branch'
|
||||
));
|
||||
assert('issues event never blocks', !mg.hasBlockingClaudeRunForPr(
|
||||
[{ event: 'issues', head_branch: 'my-branch' }],
|
||||
'my-branch'
|
||||
));
|
||||
|
||||
// Conflict rebase clears after bot completion + FINAL on HEAD
|
||||
const conflictTrigger = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude this PR has merge conflicts with main. Please rebase',
|
||||
created_at: '2026-06-26T10:30:38Z',
|
||||
};
|
||||
const rebaseDone = {
|
||||
user: { login: 'praisonai-triage-agent[bot]' },
|
||||
body: 'Rebase complete — PR #2308 onto latest main',
|
||||
created_at: '2026-06-26T10:30:55Z',
|
||||
};
|
||||
const finalAfterRebase = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude You are the FINAL architecture reviewer.',
|
||||
created_at: '2026-06-26T10:39:43Z',
|
||||
};
|
||||
const headAfterRebase = '2026-06-26T10:39:25Z';
|
||||
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase];
|
||||
assert('conflict blocks before rebase done', mg.hasRecentConflictComment([conflictTrigger], headAfterRebase));
|
||||
assert('conflict clears after rebase + FINAL on HEAD', !mg.hasRecentConflictComment(rebaseComments, headAfterRebase));
|
||||
assert('conflict still blocks without FINAL on HEAD', mg.hasRecentConflictComment(
|
||||
[conflictTrigger, rebaseDone],
|
||||
headAfterRebase
|
||||
));
|
||||
|
||||
// Tests heuristic
|
||||
assert('sdk without tests', mg.missingTestsReason([{ filename: 'src/praisonai-agents/a/b.py', additions: 3 }]) !== null);
|
||||
assert('sdk with tests ok', mg.missingTestsReason([
|
||||
{ filename: 'src/praisonai-agents/a/b.py', additions: 3 },
|
||||
{ filename: 'src/praisonai-agents/tests/test_x.py', additions: 10 },
|
||||
]) === null);
|
||||
|
||||
// PR size
|
||||
assert('large PR blocked', mg.prSizeReasons([{ additions: 900 }]).length > 0);
|
||||
|
||||
assert('internal PR link accepted', mg.isInternalPullRequestLink(
|
||||
{ base: { repo: { full_name: 'MervinPraison/PraisonAI' } } },
|
||||
'MervinPraison',
|
||||
'PraisonAI'
|
||||
));
|
||||
assert('fork sync PR link rejected', !mg.isInternalPullRequestLink(
|
||||
{ number: 21, base: { repo: { full_name: 'Milkmange/PraisonAI' } } },
|
||||
'MervinPraison',
|
||||
'PraisonAI'
|
||||
));
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,747 @@
|
||||
/**
|
||||
* Shared merge-gate helpers for claude-merge-gate.yml
|
||||
* @see .github/workflows/claude-merge-gate.yml
|
||||
*/
|
||||
|
||||
const CLAUDE_TRIGGER_LOGINS = ['MervinPraison', 'github-actions[bot]'];
|
||||
const AUTO_ACTORS = CLAUDE_TRIGGER_LOGINS;
|
||||
const CONFLICT_COOLDOWN_MS = 12 * 60 * 60 * 1000;
|
||||
const CLAUDE_ACTIVE_MS = 35 * 60 * 1000;
|
||||
const POST_PUSH_BUFFER_MS = 5 * 60 * 1000;
|
||||
const ALLOWED_MERGE_STATES = new Set(['CLEAN', 'UNSTABLE']);
|
||||
const AGENT_PY_MAX_AUTO_LINES = 100;
|
||||
const PR_MAX_AUTO_ADDITIONS = 800;
|
||||
const PR_MAX_AUTO_FILES = 30;
|
||||
const MANUAL_ONLY_LABELS = new Set(['security', 'breaking-change', 'needs-manual-review', 'release']);
|
||||
const WORKFLOW_ONLY_LABEL = 'merge-gate-ci-only';
|
||||
const CI_ONLY_PATH_PREFIXES = ['.github/workflows/', '.github/actions/', '.github/scripts/merge-gate'];
|
||||
const SDK_PATH_PREFIXES = ['src/praisonai-agents/', 'src/praisonai/'];
|
||||
const SENSITIVE_PATH_PATTERNS = [
|
||||
/^\.github\/workflows\//,
|
||||
/praisonaiagents\/(auth|approval|policy|sandbox)\//,
|
||||
/^src\/praisonai-agents\/pyproject\.toml$/,
|
||||
/^src\/praisonai\/pyproject\.toml$/,
|
||||
/\.env(\.|$)/,
|
||||
/credentials\.json$/i,
|
||||
];
|
||||
const REQUIRED_SDK_CHECK_PATTERNS = [/test/i, /smoke/i, /core/i, /python package/i, /comprehensive/i, /optimized/i];
|
||||
const SECRET_PATTERNS = [
|
||||
/sk-[a-zA-Z0-9]{20,}/,
|
||||
/AKIA[0-9A-Z]{16}/,
|
||||
/ghp_[a-zA-Z0-9]{36,}/,
|
||||
/github_pat_/,
|
||||
/Bearer eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/,
|
||||
/-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/,
|
||||
];
|
||||
const BLOCK_LABELS = new Set([
|
||||
'claude-conflict-pending',
|
||||
'claude-merge-gate-active',
|
||||
'no-auto-merge',
|
||||
'auto-merged-by-gate',
|
||||
]);
|
||||
const BOT_REVIEWER_PATTERNS = [
|
||||
'coderabbit',
|
||||
'qodo',
|
||||
'gemini',
|
||||
'copilot',
|
||||
'greptile',
|
||||
];
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function isFinalClaudeTriggerComment(c) {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!AUTO_ACTORS.includes(c.user.login)) return false;
|
||||
if (!body.includes('@claude')) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
}
|
||||
|
||||
function isClaudeTriggerNoise(c) {
|
||||
const body = c.body || '';
|
||||
if (body.includes('Merge gate scan')) return true;
|
||||
if (body.includes('MERGE_GATE_VERDICT')) return true;
|
||||
if (body.includes('Merged by **Claude PR merge gate**')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasRecentClaudeTrigger(comments, minutes = 35) {
|
||||
const cutoff = Date.now() - minutes * 60 * 1000;
|
||||
return comments.some((c) => {
|
||||
if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false;
|
||||
if (isClaudeTriggerNoise(c)) return false;
|
||||
if (!(c.body || '').includes('@claude')) return false;
|
||||
return new Date(c.created_at).getTime() > cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
function isConflictRebaseTriggerComment(c) {
|
||||
if (!AUTO_ACTORS.includes(c.user.login)) return false;
|
||||
const body = (c.body || '').toLowerCase();
|
||||
return body.includes('@claude') && body.includes('merge conflict');
|
||||
}
|
||||
|
||||
function isConflictRebaseCompletionComment(c) {
|
||||
const login = (c.user?.login || '').toLowerCase();
|
||||
if (!login.includes('praisonai-triage') && !login.includes('github-actions')) return false;
|
||||
const body = (c.body || '').toLowerCase();
|
||||
return (
|
||||
body.includes('rebase complete') ||
|
||||
body.includes('rebase onto') ||
|
||||
(body.includes('conflict') && body.includes('resolved'))
|
||||
);
|
||||
}
|
||||
|
||||
function conflictRebaseQuiescent(comments, headPushedAt) {
|
||||
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
|
||||
if (conflictTriggers.length === 0) return true;
|
||||
|
||||
const latestConflict = conflictTriggers.reduce((a, b) =>
|
||||
new Date(a.created_at) > new Date(b.created_at) ? a : b
|
||||
);
|
||||
const conflictTime = new Date(latestConflict.created_at).getTime();
|
||||
|
||||
const rebaseDone = comments.some(
|
||||
(c) =>
|
||||
new Date(c.created_at).getTime() > conflictTime && isConflictRebaseCompletionComment(c)
|
||||
);
|
||||
if (!rebaseDone) return false;
|
||||
|
||||
return finalClaudeCompletedOnSha(comments, headPushedAt);
|
||||
}
|
||||
|
||||
function hasRecentConflictComment(comments, headPushedAt = null) {
|
||||
const cutoff = Date.now() - CONFLICT_COOLDOWN_MS;
|
||||
const hasRecentTrigger = comments.some((c) => {
|
||||
if (!isConflictRebaseTriggerComment(c)) return false;
|
||||
return new Date(c.created_at).getTime() > cutoff;
|
||||
});
|
||||
if (!hasRecentTrigger) return false;
|
||||
|
||||
if (headPushedAt && conflictRebaseQuiescent(comments, headPushedAt)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isBotReviewer(login, userType) {
|
||||
const lower = (login || '').toLowerCase();
|
||||
if (userType === 'Bot') return true;
|
||||
if (lower.endsWith('[bot]')) return true;
|
||||
return ['coderabbit', 'qodo', 'gemini', 'copilot', 'greptile'].some((p) => lower.includes(p));
|
||||
}
|
||||
|
||||
function latestReviewsByUser(reviews) {
|
||||
const latestByUser = new Map();
|
||||
for (const r of reviews) {
|
||||
const login = r.user?.login;
|
||||
if (!login) continue;
|
||||
const prev = latestByUser.get(login);
|
||||
if (!prev || new Date(r.submitted_at) > new Date(prev.submitted_at)) {
|
||||
latestByUser.set(login, r);
|
||||
}
|
||||
}
|
||||
return latestByUser;
|
||||
}
|
||||
|
||||
function hasHumanChangesRequested(reviews) {
|
||||
for (const [login, review] of latestReviewsByUser(reviews)) {
|
||||
if (review.state !== 'CHANGES_REQUESTED') continue;
|
||||
if (!isBotReviewer(login, review.user?.type)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasAnyChangesRequested(reviews) {
|
||||
for (const [, review] of latestReviewsByUser(reviews)) {
|
||||
if (review.state === 'CHANGES_REQUESTED') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasFinalClaudeReviewTrigger(comments) {
|
||||
return comments.some(isFinalClaudeTriggerComment);
|
||||
}
|
||||
|
||||
function isStaleFinalAfterPush(comments, headPushedAt) {
|
||||
if (!headPushedAt) return false;
|
||||
const headTime = new Date(headPushedAt).getTime();
|
||||
const finals = comments.filter(isFinalClaudeTriggerComment);
|
||||
if (finals.length === 0) return true;
|
||||
const latestFinal = finals.reduce((a, b) =>
|
||||
new Date(a.created_at) > new Date(b.created_at) ? a : b
|
||||
);
|
||||
const finalTime = new Date(latestFinal.created_at).getTime();
|
||||
if (headTime <= finalTime + 60000) return false;
|
||||
const claudeSinceHead = comments.some((c) => {
|
||||
if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false;
|
||||
if (isClaudeTriggerNoise(c)) return false;
|
||||
if (!(c.body || '').includes('@claude')) return false;
|
||||
return new Date(c.created_at).getTime() >= headTime - 60000;
|
||||
});
|
||||
return !claudeSinceHead;
|
||||
}
|
||||
|
||||
function needsStaleFinalRecovery(comments, headPushedAt) {
|
||||
return (
|
||||
hasFinalClaudeReviewTrigger(comments) &&
|
||||
isStaleFinalAfterPush(comments, headPushedAt)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldSkipFinalRecovery(comments, headPushedAt) {
|
||||
const isStale = isStaleFinalAfterPush(comments, headPushedAt);
|
||||
if (isStale) return false;
|
||||
return hasRecentClaudeTrigger(comments, 35);
|
||||
}
|
||||
|
||||
function finalClaudeCompletedOnSha(comments, headPushedAt) {
|
||||
if (!hasFinalClaudeReviewTrigger(comments)) return false;
|
||||
if (isStaleFinalAfterPush(comments, headPushedAt)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const FINAL_CLAUDE_REVIEW_BODY =
|
||||
'@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items';
|
||||
|
||||
async function getMergeState(github, owner, repo, prNumber) {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
mergeStateStatus
|
||||
maintainerCanModify
|
||||
isDraft
|
||||
headRefOid
|
||||
headRef { repository { nameWithOwner } }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const result = await github.graphql(query, { owner, repo, number: prNumber });
|
||||
const prGql = result.repository.pullRequest;
|
||||
const status = (prGql?.mergeStateStatus || '').toUpperCase();
|
||||
if (status && status !== 'UNKNOWN') {
|
||||
return {
|
||||
status,
|
||||
isDraft: prGql.isDraft,
|
||||
headRepo: prGql.headRef?.repository?.nameWithOwner,
|
||||
headSha: prGql.headRefOid,
|
||||
maintainerCanModify: prGql.maintainerCanModify === true,
|
||||
};
|
||||
}
|
||||
if (attempt < 2) await sleep(10000);
|
||||
}
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
return {
|
||||
status: (pr.mergeable_state || '').toUpperCase(),
|
||||
isDraft: pr.draft,
|
||||
headRepo: pr.head.repo?.full_name,
|
||||
headSha: pr.head.sha,
|
||||
maintainerCanModify: pr.maintainer_can_modify === true,
|
||||
};
|
||||
}
|
||||
|
||||
async function allChecksGreenOnSha(github, owner, repo, sha, core) {
|
||||
const { data } = await github.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
const runs = (data.check_runs || []).filter((r) => r.head_sha === sha);
|
||||
if (runs.length === 0) {
|
||||
core?.info?.(`No check runs on ${sha.slice(0, 7)} — allowing (e.g. docs-only PR)`);
|
||||
return true;
|
||||
}
|
||||
for (const run of runs) {
|
||||
if (run.status !== 'completed') {
|
||||
core?.info?.(`Check pending: ${run.name} (${run.status})`);
|
||||
return false;
|
||||
}
|
||||
const ok = ['success', 'neutral', 'skipped'].includes(run.conclusion);
|
||||
if (!ok) {
|
||||
core?.info?.(`Check failed: ${run.name} (${run.conclusion})`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function claudeRunBlocksPr(run, headRef) {
|
||||
if (!run || run.event === 'issues') return false;
|
||||
if (!headRef) return true;
|
||||
return run.head_branch === headRef;
|
||||
}
|
||||
|
||||
function hasBlockingClaudeRunForPr(runs, headRef) {
|
||||
return (runs || []).some((r) => claudeRunBlocksPr(r, headRef));
|
||||
}
|
||||
|
||||
async function hasInProgressClaudeAssistant(github, owner, repo, prNumber = null) {
|
||||
try {
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: 'claude.yml',
|
||||
status: 'in_progress',
|
||||
per_page: 20,
|
||||
});
|
||||
const runs = (data.workflow_runs || []).filter((r) => r.event !== 'issues');
|
||||
if (runs.length === 0) return false;
|
||||
if (prNumber == null) return runs.length > 0;
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
const headRef = pr.head.ref;
|
||||
return hasBlockingClaudeRunForPr(runs, headRef);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isCiOnlyChange(files) {
|
||||
if (!files.length) return false;
|
||||
return files.every((f) => CI_ONLY_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
|
||||
}
|
||||
|
||||
function isInternalPullRequestLink(link, owner, repo) {
|
||||
if (link?.base?.repo?.full_name === `${owner}/${repo}`) return true;
|
||||
const baseUrl = link?.base?.repo?.url || '';
|
||||
return baseUrl.endsWith(`/repos/${owner}/${repo}`);
|
||||
}
|
||||
|
||||
async function resolvePrNumberFromLinkedPullRequests(github, owner, repo, linked) {
|
||||
const repoFull = `${owner}/${repo}`;
|
||||
const internal = (linked || []).filter((l) => isInternalPullRequestLink(l, owner, repo));
|
||||
for (const link of internal) {
|
||||
if (!link.number) continue;
|
||||
try {
|
||||
const { data } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: link.number,
|
||||
});
|
||||
if (data.state === 'open' && data.base?.repo?.full_name === repoFull) {
|
||||
return data.number;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.status !== 404) throw err;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolvePrNumberFromHeadBranch(github, owner, repo, branch) {
|
||||
if (!branch || branch === 'main' || branch === 'master') return null;
|
||||
const { data } = await github.rest.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
head: `${owner}:${branch}`,
|
||||
per_page: 1,
|
||||
});
|
||||
return data[0]?.number || null;
|
||||
}
|
||||
|
||||
async function resolvePrNumberFromWorkflowRun(github, owner, repo, workflowRun) {
|
||||
const fromLinked = await resolvePrNumberFromLinkedPullRequests(
|
||||
github, owner, repo, workflowRun.pull_requests
|
||||
);
|
||||
if (fromLinked) return fromLinked;
|
||||
|
||||
const fromBranch = await resolvePrNumberFromHeadBranch(
|
||||
github, owner, repo, workflowRun.head_branch
|
||||
);
|
||||
if (fromBranch) return fromBranch;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listPullFiles(github, owner, repo, prNumber) {
|
||||
if (typeof github.paginate === 'function') {
|
||||
return github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
const { data } = await github.rest.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
function countNewAgentParams(patch) {
|
||||
if (!patch) return 0;
|
||||
return patch
|
||||
.split('\n')
|
||||
.filter((l) => l.startsWith('+') && !l.startsWith('+++'))
|
||||
.filter((l) => {
|
||||
const t = l.slice(1).trim();
|
||||
if (!t || t.startsWith('#') || t.startsWith('"""') || t.startsWith("'''")) return false;
|
||||
if (/^(def|class|@|return\b|if\b|elif\b|else\b|for\b|while\b)/.test(t)) return false;
|
||||
if (/^\w+\s*[:=]/.test(t) && !t.startsWith('self.')) return true;
|
||||
return false;
|
||||
}).length;
|
||||
}
|
||||
|
||||
function getAgentPyChangeFromFiles(files) {
|
||||
const agentFile = files.find((f) => f.filename.endsWith('praisonaiagents/agent/agent.py'));
|
||||
if (!agentFile) {
|
||||
return { touched: false, additions: 0, newParams: 0 };
|
||||
}
|
||||
return {
|
||||
touched: true,
|
||||
additions: agentFile.additions || 0,
|
||||
newParams: countNewAgentParams(agentFile.patch || ''),
|
||||
};
|
||||
}
|
||||
|
||||
async function getAgentPyChange(github, owner, repo, prNumber) {
|
||||
const files = await listPullFiles(github, owner, repo, prNumber);
|
||||
return getAgentPyChangeFromFiles(files);
|
||||
}
|
||||
|
||||
function touchesSdk(files) {
|
||||
return files.some((f) => SDK_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
|
||||
}
|
||||
|
||||
function hasManualOnlyLabel(labels) {
|
||||
return labels.some((l) => MANUAL_ONLY_LABELS.has(l));
|
||||
}
|
||||
|
||||
function sensitivePathReasons(files, labels = []) {
|
||||
if (labels.includes(WORKFLOW_ONLY_LABEL) && isCiOnlyChange(files)) {
|
||||
return [];
|
||||
}
|
||||
const reasons = [];
|
||||
for (const f of files) {
|
||||
if (SENSITIVE_PATH_PATTERNS.some((p) => p.test(f.filename))) {
|
||||
reasons.push(`sensitive path: ${f.filename}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function prSizeReasons(files) {
|
||||
const reasons = [];
|
||||
const totalAdditions = files.reduce((sum, f) => sum + (f.additions || 0), 0);
|
||||
if (totalAdditions > PR_MAX_AUTO_ADDITIONS) {
|
||||
reasons.push(`PR +${totalAdditions} lines (>${PR_MAX_AUTO_ADDITIONS}) requires manual review`);
|
||||
}
|
||||
if (files.length > PR_MAX_AUTO_FILES) {
|
||||
reasons.push(`${files.length} files changed (>${PR_MAX_AUTO_FILES}) requires manual review`);
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function missingTestsReason(files) {
|
||||
const sdkAdds = files.filter(
|
||||
(f) =>
|
||||
f.filename.startsWith('src/praisonai-agents/') &&
|
||||
f.filename.endsWith('.py') &&
|
||||
!f.filename.endsWith('__init__.py') &&
|
||||
(f.additions || 0) > 0
|
||||
);
|
||||
if (sdkAdds.length === 0) return null;
|
||||
const hasTestChange = files.some(
|
||||
(f) => /\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename) || /_test\.py$/.test(f.filename)
|
||||
);
|
||||
if (!hasTestChange) return 'SDK code added without test file changes — requires manual review';
|
||||
return null;
|
||||
}
|
||||
|
||||
function secretScanReasons(files) {
|
||||
for (const f of files) {
|
||||
if (/\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename)) continue;
|
||||
const patch = f.patch || '';
|
||||
if (!patch) continue;
|
||||
for (const pattern of SECRET_PATTERNS) {
|
||||
if (pattern.test(patch)) {
|
||||
return [`possible secret in diff (${f.filename}) — requires manual review`];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function sdkTestChecksReason(github, owner, repo, sha, files, core) {
|
||||
if (!touchesSdk(files)) return null;
|
||||
const { data } = await github.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
const runs = (data.check_runs || []).filter((r) => r.head_sha === sha);
|
||||
if (runs.length === 0) {
|
||||
return 'SDK code changed but no CI checks on HEAD';
|
||||
}
|
||||
const testRuns = runs.filter((r) => REQUIRED_SDK_CHECK_PATTERNS.some((p) => p.test(r.name || '')));
|
||||
if (testRuns.length === 0) {
|
||||
return 'SDK code changed but no test check runs on HEAD';
|
||||
}
|
||||
core?.info?.(`SDK test checks on HEAD: ${testRuns.map((r) => r.name).join(', ')}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
function manualReviewReasonForAgentPy(agentChange) {
|
||||
if (!agentChange.touched) return null;
|
||||
if (agentChange.additions > AGENT_PY_MAX_AUTO_LINES) {
|
||||
return `agent.py +${agentChange.additions} lines (>${AGENT_PY_MAX_AUTO_LINES}) requires manual review`;
|
||||
}
|
||||
if (agentChange.newParams > 0) {
|
||||
return `agent.py adds ${agentChange.newParams} new Agent param(s) — requires manual review`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listAllComments(github, owner, repo, issueNumber) {
|
||||
if (typeof github.paginate === 'function') {
|
||||
return github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
const { data } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getHeadCommitDate(github, owner, repo, prNumber) {
|
||||
try {
|
||||
let commits;
|
||||
if (typeof github.paginate === 'function') {
|
||||
commits = await github.paginate(github.rest.pulls.listCommits, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
} else {
|
||||
const { data } = await github.rest.pulls.listCommits({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
commits = data;
|
||||
}
|
||||
const last = commits[commits.length - 1];
|
||||
return last?.commit?.committer?.date || last?.commit?.author?.date || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrContext(github, owner, repo, prNumber) {
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: prNumber });
|
||||
const comments = await listAllComments(github, owner, repo, prNumber);
|
||||
const { data: reviews } = await github.rest.pulls.listReviews({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const mergeState = await getMergeState(github, owner, repo, prNumber);
|
||||
const headSha = mergeState.headSha || pr.head.sha;
|
||||
const headCommitDate = await getHeadCommitDate(github, owner, repo, prNumber);
|
||||
const headPushedAt = headCommitDate || pr.updated_at;
|
||||
|
||||
return {
|
||||
pr,
|
||||
issue,
|
||||
comments,
|
||||
reviews,
|
||||
mergeState,
|
||||
headSha,
|
||||
headPushedAt,
|
||||
labels: issue.labels.map((l) => l.name),
|
||||
baseRepo: `${owner}/${repo}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, options = {}) {
|
||||
const {
|
||||
forMergeStep = false,
|
||||
skipGlobalClaudeRunCheck = false,
|
||||
skipRecentClaudeCooldown = false,
|
||||
} = options;
|
||||
const ctx = await loadPrContext(github, owner, repo, prNumber);
|
||||
const reasons = [];
|
||||
|
||||
if (ctx.pr.draft) reasons.push('draft');
|
||||
if (ctx.pr.state !== 'open') reasons.push('not open');
|
||||
if (ctx.labels.includes('auto-merged-by-gate')) reasons.push('already merged by gate');
|
||||
if (ctx.labels.includes('no-auto-merge')) reasons.push('no-auto-merge label');
|
||||
if (ctx.labels.includes('claude-conflict-pending')) reasons.push('claude-conflict-pending');
|
||||
if (!forMergeStep && ctx.labels.includes('claude-merge-gate-active')) {
|
||||
reasons.push('claude-merge-gate-active');
|
||||
}
|
||||
|
||||
const { status, headRepo, maintainerCanModify } = ctx.mergeState;
|
||||
if (!ALLOWED_MERGE_STATES.has(status)) reasons.push(`mergeState=${status}`);
|
||||
|
||||
if (headRepo && headRepo !== ctx.baseRepo) {
|
||||
reasons.push('fork PR');
|
||||
}
|
||||
|
||||
if (hasRecentConflictComment(ctx.comments, ctx.headPushedAt)) {
|
||||
reasons.push('recent merge-conflict @claude');
|
||||
}
|
||||
if (!skipRecentClaudeCooldown && hasRecentClaudeTrigger(ctx.comments, 35)) {
|
||||
reasons.push('recent @claude within 35min');
|
||||
}
|
||||
|
||||
if (!skipGlobalClaudeRunCheck && (await hasInProgressClaudeAssistant(github, owner, repo, prNumber))) {
|
||||
reasons.push('claude.yml in progress');
|
||||
}
|
||||
|
||||
const headTime = new Date(ctx.headPushedAt).getTime();
|
||||
if (Date.now() - headTime < POST_PUSH_BUFFER_MS) reasons.push('post-push buffer (<5min)');
|
||||
|
||||
if (!finalClaudeCompletedOnSha(ctx.comments, ctx.headPushedAt)) {
|
||||
if (!hasFinalClaudeReviewTrigger(ctx.comments)) {
|
||||
reasons.push('no FINAL Claude review trigger');
|
||||
} else if (isStaleFinalAfterPush(ctx.comments, ctx.headPushedAt)) {
|
||||
reasons.push('stale FINAL after new commits (needs @claude re-review)');
|
||||
} else {
|
||||
reasons.push('FINAL Claude not complete on HEAD');
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAnyChangesRequested(ctx.reviews)) reasons.push('CHANGES_REQUESTED review');
|
||||
|
||||
if (hasManualOnlyLabel(ctx.labels)) {
|
||||
const manualLabel = ctx.labels.find((l) => MANUAL_ONLY_LABELS.has(l));
|
||||
reasons.push(`manual-only label: ${manualLabel}`);
|
||||
}
|
||||
|
||||
const pullFiles = await listPullFiles(github, owner, repo, prNumber);
|
||||
|
||||
const agentChange = getAgentPyChangeFromFiles(pullFiles);
|
||||
const agentManual = manualReviewReasonForAgentPy(agentChange);
|
||||
if (agentManual) reasons.push(agentManual);
|
||||
|
||||
reasons.push(...sensitivePathReasons(pullFiles, ctx.labels));
|
||||
reasons.push(...prSizeReasons(pullFiles));
|
||||
reasons.push(...secretScanReasons(pullFiles));
|
||||
|
||||
const testsReason = missingTestsReason(pullFiles);
|
||||
if (testsReason) reasons.push(testsReason);
|
||||
|
||||
const sdkCheckReason = await sdkTestChecksReason(github, owner, repo, ctx.headSha, pullFiles, core);
|
||||
if (sdkCheckReason) reasons.push(sdkCheckReason);
|
||||
|
||||
const checksOk = await allChecksGreenOnSha(github, owner, repo, ctx.headSha, core);
|
||||
if (!checksOk) reasons.push('CI not green on HEAD');
|
||||
|
||||
if (ctx.pr.mergeable === false) reasons.push('not mergeable');
|
||||
|
||||
const ready = reasons.length === 0;
|
||||
return {
|
||||
ready,
|
||||
reasons,
|
||||
headSha: ctx.headSha,
|
||||
prNumber,
|
||||
};
|
||||
}
|
||||
|
||||
async function selectMergeGateCandidates(github, owner, repo, prNumbers, maxCandidates, core) {
|
||||
const readyList = [];
|
||||
const skipped = [];
|
||||
for (const num of prNumbers) {
|
||||
const result = await evaluatePipelineQuiescent(github, owner, repo, num, core);
|
||||
if (result.ready) {
|
||||
readyList.push({ pr_number: num, head_sha: result.headSha });
|
||||
} else {
|
||||
skipped.push({ pr: num, reasons: result.reasons });
|
||||
}
|
||||
}
|
||||
readyList.sort((a, b) => a.pr_number - b.pr_number);
|
||||
return {
|
||||
candidates: readyList.slice(0, maxCandidates),
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
function findMergeGateVerdict(comments, minCreatedAt = null, headPushedAt = null) {
|
||||
const minTime = minCreatedAt ? new Date(minCreatedAt).getTime() : 0;
|
||||
const headTime = headPushedAt ? new Date(headPushedAt).getTime() - 60000 : 0;
|
||||
const gateComments = comments
|
||||
.filter((c) => {
|
||||
if (!(c.body || '').includes('MERGE_GATE_VERDICT:')) return false;
|
||||
const created = new Date(c.created_at).getTime();
|
||||
if (minTime && created < minTime) return false;
|
||||
if (headTime && created < headTime) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
if (gateComments.length === 0) return null;
|
||||
const body = gateComments[0].body || '';
|
||||
if (body.includes('MERGE_GATE_VERDICT: APPROVE')) return 'APPROVE';
|
||||
if (body.includes('MERGE_GATE_VERDICT: BLOCK')) return 'BLOCK';
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CLAUDE_TRIGGER_LOGINS,
|
||||
AUTO_ACTORS,
|
||||
isFinalClaudeTriggerComment,
|
||||
isClaudeTriggerNoise,
|
||||
hasRecentClaudeTrigger,
|
||||
hasRecentConflictComment,
|
||||
isConflictRebaseTriggerComment,
|
||||
isConflictRebaseCompletionComment,
|
||||
conflictRebaseQuiescent,
|
||||
hasHumanChangesRequested,
|
||||
hasAnyChangesRequested,
|
||||
hasFinalClaudeReviewTrigger,
|
||||
finalClaudeCompletedOnSha,
|
||||
getMergeState,
|
||||
allChecksGreenOnSha,
|
||||
hasInProgressClaudeAssistant,
|
||||
claudeRunBlocksPr,
|
||||
hasBlockingClaudeRunForPr,
|
||||
isCiOnlyChange,
|
||||
isInternalPullRequestLink,
|
||||
resolvePrNumberFromLinkedPullRequests,
|
||||
resolvePrNumberFromHeadBranch,
|
||||
resolvePrNumberFromWorkflowRun,
|
||||
WORKFLOW_ONLY_LABEL,
|
||||
getAgentPyChange,
|
||||
getAgentPyChangeFromFiles,
|
||||
manualReviewReasonForAgentPy,
|
||||
countNewAgentParams,
|
||||
listPullFiles,
|
||||
touchesSdk,
|
||||
hasManualOnlyLabel,
|
||||
sensitivePathReasons,
|
||||
prSizeReasons,
|
||||
missingTestsReason,
|
||||
secretScanReasons,
|
||||
sdkTestChecksReason,
|
||||
listAllComments,
|
||||
getHeadCommitDate,
|
||||
isStaleFinalAfterPush,
|
||||
needsStaleFinalRecovery,
|
||||
shouldSkipFinalRecovery,
|
||||
FINAL_CLAUDE_REVIEW_BODY,
|
||||
loadPrContext,
|
||||
evaluatePipelineQuiescent,
|
||||
selectMergeGateCandidates,
|
||||
findMergeGateVerdict,
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run: node .github/scripts/pipeline-status-selftest.js
|
||||
*/
|
||||
const ps = require('./pipeline-status.js');
|
||||
const mg = require('./merge-gate.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
const kickComments = [
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
{ user: { login: 'MervinPraison' }, body: '/review' },
|
||||
];
|
||||
|
||||
const finalComment = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude You are the FINAL architecture reviewer.',
|
||||
created_at: '2026-06-26T10:00:00Z',
|
||||
};
|
||||
|
||||
assert(
|
||||
'reviews pending when not kicked',
|
||||
ps.deriveStage([], { ready: false, reasons: ['no FINAL Claude review trigger'] })
|
||||
=== 'pipeline/reviews-pending'
|
||||
);
|
||||
assert(
|
||||
'final pending after kick',
|
||||
ps.deriveStage(kickComments, { ready: false, reasons: ['no FINAL Claude review trigger'] })
|
||||
=== 'pipeline/final-claude-pending'
|
||||
);
|
||||
assert(
|
||||
'awaiting merge gate after final',
|
||||
ps.deriveStage(
|
||||
[...kickComments, finalComment],
|
||||
{ ready: false, reasons: ['recent @claude within 35min'] }
|
||||
) === 'pipeline/awaiting-merge-gate'
|
||||
);
|
||||
assert(
|
||||
'merge ready',
|
||||
ps.deriveStage([...kickComments, finalComment], { ready: true, reasons: [] })
|
||||
=== 'pipeline/merge-ready'
|
||||
);
|
||||
|
||||
const blockers = ps.deriveBlockerLabels({
|
||||
ready: false,
|
||||
reasons: ['CI not green on HEAD', 'SDK code added without test file changes — requires manual review'],
|
||||
});
|
||||
assert('maps ci blocker', blockers.includes('pipeline/blocked:ci'));
|
||||
assert('maps manual blocker', blockers.includes('pipeline/blocked:manual-review'));
|
||||
|
||||
assert(
|
||||
'internal link matches upstream base',
|
||||
mg.isInternalPullRequestLink(
|
||||
{ base: { repo: { full_name: 'MervinPraison/PraisonAI' } } },
|
||||
'MervinPraison',
|
||||
'PraisonAI'
|
||||
)
|
||||
);
|
||||
assert(
|
||||
'fork sync link rejected',
|
||||
!mg.isInternalPullRequestLink(
|
||||
{ number: 21, base: { repo: { full_name: 'Milkmange/PraisonAI' } } },
|
||||
'MervinPraison',
|
||||
'PraisonAI'
|
||||
)
|
||||
);
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Sync pipeline stage/blocker labels on open PRs.
|
||||
* @see .github/workflows/pipeline-status-sync.yml
|
||||
*/
|
||||
|
||||
const mergeGate = require('./merge-gate.js');
|
||||
const chain = require('./bot-pr-review-chain.js');
|
||||
|
||||
const PIPELINE_PREFIX = 'pipeline/';
|
||||
const STAGE_LABELS = [
|
||||
'pipeline/reviews-pending',
|
||||
'pipeline/final-claude-pending',
|
||||
'pipeline/awaiting-merge-gate',
|
||||
'pipeline/merge-ready',
|
||||
'pipeline/merged',
|
||||
];
|
||||
const BLOCKER_LABELS = [
|
||||
'pipeline/blocked:ci',
|
||||
'pipeline/blocked:conflict',
|
||||
'pipeline/blocked:manual-review',
|
||||
'pipeline/blocked:cooldown',
|
||||
'pipeline/blocked:stale-final',
|
||||
'pipeline/blocked:no-final',
|
||||
];
|
||||
const ALL_PIPELINE_LABELS = [...STAGE_LABELS, ...BLOCKER_LABELS];
|
||||
|
||||
const LABEL_SPECS = [
|
||||
{ name: 'pipeline/reviews-pending', color: 'fbca04', description: 'Waiting for CodeRabbit/Qodo/Copilot reviews' },
|
||||
{ name: 'pipeline/final-claude-pending', color: 'd4c5f9', description: 'Reviews done; waiting for FINAL @claude' },
|
||||
{ name: 'pipeline/awaiting-merge-gate', color: 'c5def5', description: 'FINAL done; waiting for merge gate / CI' },
|
||||
{ name: 'pipeline/merge-ready', color: '0e8a16', description: 'Eligible for merge gate auto-merge' },
|
||||
{ name: 'pipeline/merged', color: '6f42c1', description: 'Merged via pipeline' },
|
||||
{ name: 'pipeline/blocked:ci', color: 'd93f0b', description: 'Blocked: CI not green on HEAD' },
|
||||
{ name: 'pipeline/blocked:conflict', color: 'b60205', description: 'Blocked: merge conflict or rebase pending' },
|
||||
{ name: 'pipeline/blocked:manual-review', color: 'e99695', description: 'Blocked: requires manual review' },
|
||||
{ name: 'pipeline/blocked:cooldown', color: 'fef2c0', description: 'Blocked: post-push or @claude cooldown' },
|
||||
{ name: 'pipeline/blocked:stale-final', color: 'f9d0c4', description: 'Blocked: FINAL stale after new commits' },
|
||||
{ name: 'pipeline/blocked:no-final', color: 'ededed', description: 'Blocked: no FINAL @claude trigger yet' },
|
||||
];
|
||||
|
||||
function deriveStage(comments, evalResult) {
|
||||
if (evalResult.ready) return 'pipeline/merge-ready';
|
||||
if (!chain.chainKickPosted(comments)) return 'pipeline/reviews-pending';
|
||||
if (!mergeGate.hasFinalClaudeReviewTrigger(comments)) return 'pipeline/final-claude-pending';
|
||||
return 'pipeline/awaiting-merge-gate';
|
||||
}
|
||||
|
||||
function reasonToBlockerLabel(reason) {
|
||||
const r = (reason || '').toLowerCase();
|
||||
if (r.includes('ci not green') || r.includes('sdk code changed but no ci')) return 'pipeline/blocked:ci';
|
||||
if (
|
||||
r.includes('conflict') ||
|
||||
r.includes('mergestate=dirty') ||
|
||||
r.includes('not mergeable')
|
||||
) return 'pipeline/blocked:conflict';
|
||||
if (
|
||||
r.includes('manual') ||
|
||||
r.includes('requires manual') ||
|
||||
r.includes('sensitive path') ||
|
||||
r.includes('no-auto-merge') ||
|
||||
r.includes('manual-only label') ||
|
||||
r.includes('without test') ||
|
||||
r.includes('files changed') ||
|
||||
r.includes('possible secret') ||
|
||||
r.includes('agent.py')
|
||||
) return 'pipeline/blocked:manual-review';
|
||||
if (r.includes('recent @claude') || r.includes('post-push buffer')) return 'pipeline/blocked:cooldown';
|
||||
if (r.includes('stale final')) return 'pipeline/blocked:stale-final';
|
||||
if (r.includes('no final claude')) return 'pipeline/blocked:no-final';
|
||||
if (r.includes('final claude not complete')) return 'pipeline/blocked:stale-final';
|
||||
if (r.includes('claude.yml in progress') || r.includes('claude-merge-gate-active')) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveBlockerLabels(evalResult) {
|
||||
if (evalResult.ready) return [];
|
||||
const labels = new Set();
|
||||
for (const reason of evalResult.reasons || []) {
|
||||
const mapped = reasonToBlockerLabel(reason);
|
||||
if (mapped && mapped.startsWith('pipeline/blocked:')) labels.add(mapped);
|
||||
}
|
||||
return [...labels];
|
||||
}
|
||||
|
||||
function computePipelineLabels(comments, evalResult) {
|
||||
const stage = deriveStage(comments, evalResult);
|
||||
const blockers = stage === 'pipeline/merge-ready' ? [] : deriveBlockerLabels(evalResult);
|
||||
return { stage, blockers, all: [stage, ...blockers] };
|
||||
}
|
||||
|
||||
async function ensurePipelineLabels(github, owner, repo, core) {
|
||||
let existing;
|
||||
if (typeof github.paginate === 'function') {
|
||||
existing = await github.paginate(github.rest.issues.listLabelsForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
} else {
|
||||
({ data: existing } = await github.rest.issues.listLabelsForRepo({
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
}));
|
||||
}
|
||||
const names = new Set(existing.map((l) => l.name));
|
||||
for (const spec of LABEL_SPECS) {
|
||||
if (names.has(spec.name)) continue;
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: spec.name,
|
||||
color: spec.color,
|
||||
description: spec.description,
|
||||
});
|
||||
core?.info?.(`Created label ${spec.name}`);
|
||||
} catch (err) {
|
||||
if (err.status !== 422) throw err;
|
||||
core?.info?.(`Label ${spec.name} already exists`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncPipelineLabels(github, owner, repo, prNumber, core) {
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
if (ctx.pr.state !== 'open') return { synced: false, reason: 'not_open' };
|
||||
|
||||
const evalResult = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core
|
||||
);
|
||||
const { stage, blockers, all } = computePipelineLabels(ctx.comments, evalResult);
|
||||
|
||||
const current = ctx.labels.filter((l) => l.startsWith(PIPELINE_PREFIX));
|
||||
const desired = new Set(all);
|
||||
const toRemove = current.filter((l) => !desired.has(l) && l !== 'pipeline/merged');
|
||||
const toAdd = all.filter((l) => !current.includes(l));
|
||||
|
||||
for (const name of toRemove) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name });
|
||||
} catch (err) {
|
||||
if (err.status !== 404) throw err;
|
||||
}
|
||||
}
|
||||
if (toAdd.length) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: toAdd,
|
||||
});
|
||||
}
|
||||
|
||||
core?.info?.(`PR #${prNumber}: stage=${stage} blockers=[${blockers.join(', ')}]`);
|
||||
return { synced: true, stage, blockers, ready: evalResult.ready, reasons: evalResult.reasons };
|
||||
}
|
||||
|
||||
async function syncOpenPullRequests(github, owner, repo, options, core) {
|
||||
const { maxPrs = 20 } = options || {};
|
||||
await ensurePipelineLabels(github, owner, repo, core);
|
||||
let prs;
|
||||
if (maxPrs <= 100) {
|
||||
({ data: prs } = await github.rest.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
}));
|
||||
} else {
|
||||
prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
let synced = 0;
|
||||
for (const pr of prs) {
|
||||
if (synced >= maxPrs) break;
|
||||
if (pr.draft) continue;
|
||||
if (pr.head?.repo?.full_name && pr.head.repo.full_name !== `${owner}/${repo}`) continue;
|
||||
await syncPipelineLabels(github, owner, repo, pr.number, core);
|
||||
synced += 1;
|
||||
}
|
||||
core?.info?.(`Pipeline label sync complete (${synced} PR(s))`);
|
||||
return synced;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STAGE_LABELS,
|
||||
BLOCKER_LABELS,
|
||||
ALL_PIPELINE_LABELS,
|
||||
deriveStage,
|
||||
deriveBlockerLabels,
|
||||
computePipelineLabels,
|
||||
ensurePipelineLabels,
|
||||
syncPipelineLabels,
|
||||
syncOpenPullRequests,
|
||||
};
|
||||
@@ -16,6 +16,9 @@ on:
|
||||
types: [submitted]
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
schedule:
|
||||
- cron: '30 3,9,15,21 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
MAX_POLL_ATTEMPTS: 20
|
||||
@@ -190,6 +193,7 @@ jobs:
|
||||
'Review Context: ' + comments.data.length + ' total comments, ' +
|
||||
(reviews.coderabbit.length + reviews.qodo.length + reviews.gemini.length + reviews.copilot.length + reviews.greptile.length) + ' bot reviews.\n\n' +
|
||||
'Phase 1: Review per AGENTS.md principles (protocol-driven, backward compatible, no perf impact, SDK value gate — no feature bloat).\n' +
|
||||
'Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n' +
|
||||
'Phase 2: FIX valid issues found by prior reviewers.\n' +
|
||||
'Phase 3: Provide final verdict (approve or request changes).';
|
||||
await github.rest.issues.createComment({issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: claudeBody});
|
||||
@@ -212,6 +216,10 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
@@ -319,10 +327,23 @@ jobs:
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Qodo fallback path (same polling approach)
|
||||
# ================================================================
|
||||
@@ -338,6 +359,10 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
@@ -426,10 +451,23 @@ jobs:
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Gemini — restores the trigger lost from the deleted
|
||||
# chain-claude-after-copilot.yml workflow.
|
||||
@@ -480,7 +518,7 @@ jobs:
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the Lead Engineer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
|
||||
body: '@claude You are the Lead Engineer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
|
||||
});
|
||||
|
||||
# ================================================================
|
||||
@@ -496,30 +534,41 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Post missing FINAL Claude review after rebase
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Re-post FINAL Claude review when stale or missing
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
issue_number: pr, owner, repo, per_page: 100,
|
||||
});
|
||||
const comments = await mergeGate.listAllComments(github, owner, repo, pr);
|
||||
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr))
|
||||
|| context.payload.pull_request.updated_at;
|
||||
|
||||
const hasFinalReview = comments.some(c => {
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!body.includes('@claude') || !triggerLogins.includes(c.user.login)) return false;
|
||||
if (body.includes('merge conflict')) return false;
|
||||
return body.includes('final architecture reviewer') || body.includes('lead engineer');
|
||||
});
|
||||
if (hasFinalReview) {
|
||||
core.info('FINAL Claude review already posted, skipping recovery');
|
||||
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
|
||||
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
|
||||
|
||||
if (mergeGate.shouldSkipFinalRecovery(comments, headPushedAt)) {
|
||||
core.info('Recent @claude within 35min and FINAL not stale — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalBody = mergeGate.FINAL_CLAUDE_REVIEW_BODY;
|
||||
|
||||
if (hasFinal && isStale) {
|
||||
await github.rest.issues.createComment({ issue_number: pr, owner, repo, body: finalBody });
|
||||
core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasFinal) {
|
||||
core.info('FINAL Claude review current on HEAD — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -533,11 +582,62 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
|
||||
await github.rest.issues.createComment({ issue_number: pr, owner, repo, body: finalBody });
|
||||
core.info(`Posted missing FINAL Claude review on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# Scheduled: re-post @claude when FINAL is stale (Claude pushed after last FINAL trigger)
|
||||
stale-final-recovery-scheduled:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Re-post stale FINAL @claude on open internal PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: 'open', per_page: 100,
|
||||
});
|
||||
core.info(`Posted recovery FINAL Claude review on PR #${pr}`);
|
||||
let posted = 0;
|
||||
for (const pr of prs) {
|
||||
if (pr.draft) continue;
|
||||
if (pr.head?.repo?.full_name && pr.head.repo.full_name !== `${owner}/${repo}`) continue;
|
||||
const comments = await mergeGate.listAllComments(github, owner, repo, pr.number);
|
||||
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr.number)) || pr.updated_at;
|
||||
if (!mergeGate.needsStaleFinalRecovery(comments, headPushedAt)) continue;
|
||||
if (mergeGate.hasRecentClaudeTrigger(comments, 10)) {
|
||||
core.info(`PR #${pr.number}: stale FINAL but @claude within 10min — skip`);
|
||||
continue;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr.number, owner, repo,
|
||||
body: mergeGate.FINAL_CLAUDE_REVIEW_BODY,
|
||||
});
|
||||
posted++;
|
||||
core.info(`Posted scheduled stale-FINAL recovery on PR #${pr.number}`);
|
||||
if (posted >= 5) break;
|
||||
}
|
||||
core.info(`Scheduled stale-FINAL recovery complete (${posted} posted)`);
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: CodeRabbit/Qodo skip bot-authored PRs by default.
|
||||
@@ -556,38 +656,29 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Trigger CodeRabbit and Qodo reviews
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
await chain.kickReviewChain(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
// 1. Trigger CodeRabbit review
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '@coderabbitai review'
|
||||
});
|
||||
console.log(`Triggered CodeRabbit for bot PR #${pr}`);
|
||||
|
||||
// 2. Trigger Qodo review
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '/review'
|
||||
});
|
||||
console.log(`Triggered Qodo for bot PR #${pr}`);
|
||||
|
||||
// 3. Trigger Gemini review (commented out)
|
||||
// await github.rest.issues.createComment({
|
||||
// issue_number: pr, owner, repo,
|
||||
// body: '@gemini review this PR'
|
||||
// });
|
||||
// console.log(`Triggered Gemini for bot PR #${pr}`);
|
||||
|
||||
// NOTE: @copilot is NOT triggered here.
|
||||
// It will be triggered by copilot-after-coderabbit or copilot-after-qodo
|
||||
// once those bots finish, ensuring Copilot always reviews LAST.
|
||||
console.log('Copilot will be triggered after CodeRabbit/Qodo complete.');
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Bot PR Recovery
|
||||
|
||||
# Belt-and-braces: kick CodeRabbit/Qodo when pull_request:opened never fired for bot PRs.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Specific PR number to kick (optional)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Find and kick stalled bot PRs
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER_INPUT: ${{ github.event.inputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const manual = (process.env.PR_NUMBER_INPUT || '').trim();
|
||||
const prNumber = manual ? parseInt(manual, 10) : null;
|
||||
if (manual && Number.isNaN(prNumber)) {
|
||||
throw new Error(`Invalid pr_number: ${manual}`);
|
||||
}
|
||||
await chain.recoverStalledBotPrs(github, owner, repo, { prNumber }, core);
|
||||
@@ -0,0 +1,474 @@
|
||||
name: Claude PR Merge Gate
|
||||
|
||||
# Read-only Claude assesses merge readiness; GH_TOKEN merges (no @claude, no gh pr review).
|
||||
# Does not overlap with merge-conflict-claude.yml or auto-pr-comment @claude chains.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2,8,14,20 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Optional PR number (scan all open internal PRs if empty)'
|
||||
required: false
|
||||
type: string
|
||||
merge_method:
|
||||
description: 'Merge method'
|
||||
required: false
|
||||
type: choice
|
||||
default: merge
|
||||
options:
|
||||
- merge
|
||||
- squash
|
||||
- rebase
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Claude Assistant
|
||||
- Core Tests
|
||||
types:
|
||||
- completed
|
||||
repository_dispatch:
|
||||
types:
|
||||
- claude-merge-gate
|
||||
|
||||
concurrency:
|
||||
group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || github.event.workflow_run.id || 'scan' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
MAX_CANDIDATES: 5
|
||||
DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
auto-dispatch:
|
||||
if: github.event_name == 'workflow_run'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Dispatch merge gate when PR pipeline is ready
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PAT: ${{ secrets.GH_TOKEN }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const wr = context.payload.workflow_run;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
if (!process.env.PAT) {
|
||||
core.setFailed('GH_TOKEN secret is required for merge gate auto-dispatch (needs repo + workflow scope).');
|
||||
return;
|
||||
}
|
||||
|
||||
if (wr.conclusion !== 'success') {
|
||||
core.info(`Skip dispatch: ${wr.name} conclusion=${wr.conclusion}`);
|
||||
return;
|
||||
}
|
||||
const allowed = ['Claude Assistant', 'Core Tests'];
|
||||
if (!allowed.includes(wr.name)) {
|
||||
core.info(`Skip dispatch: workflow ${wr.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun(github, owner, repo, wr);
|
||||
if (!prNumber) {
|
||||
core.info('Skip dispatch: could not resolve PR from workflow run');
|
||||
return;
|
||||
}
|
||||
|
||||
const fromClaude = wr.name === 'Claude Assistant';
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, {
|
||||
skipGlobalClaudeRunCheck: fromClaude,
|
||||
skipRecentClaudeCooldown: fromClaude,
|
||||
}
|
||||
);
|
||||
if (!check.ready) {
|
||||
core.info(`PR #${prNumber} not ready: ${check.reasons.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
event_type: 'claude-merge-gate',
|
||||
client_payload: { pr_number: prNumber },
|
||||
});
|
||||
core.info(`Dispatched merge gate for PR #${prNumber} after ${wr.name}`);
|
||||
|
||||
scan-candidates:
|
||||
if: github.event_name != 'workflow_run'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
actions: read
|
||||
outputs:
|
||||
matrix: ${{ steps.scan.outputs.matrix }}
|
||||
has_candidates: ${{ steps.scan.outputs.has_candidates }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Scan open PRs for merge gate eligibility
|
||||
id: scan
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const maxCandidates = parseInt(process.env.MAX_CANDIDATES || '5', 10);
|
||||
const inputPr = String(process.env.DISPATCH_PR_NUMBER || '').trim();
|
||||
|
||||
const candidates = [];
|
||||
const skipped = [];
|
||||
|
||||
if (inputPr) {
|
||||
const n = parseInt(inputPr, 10);
|
||||
if (Number.isNaN(n)) {
|
||||
throw new Error(`Invalid pr_number input: ${inputPr}`);
|
||||
}
|
||||
const result = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, n, core);
|
||||
if (result.ready) {
|
||||
candidates.push({ pr_number: n, head_sha: result.headSha });
|
||||
} else {
|
||||
skipped.push({ pr: n, reasons: result.reasons });
|
||||
}
|
||||
} else {
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: 'open', per_page: 100, sort: 'created', direction: 'asc',
|
||||
});
|
||||
const prNumbers = prs.map((p) => p.number);
|
||||
const selected = await mergeGate.selectMergeGateCandidates(
|
||||
github, owner, repo, prNumbers, maxCandidates, core
|
||||
);
|
||||
candidates.push(...selected.candidates);
|
||||
skipped.push(...selected.skipped);
|
||||
}
|
||||
|
||||
core.info('Skipped: ' + JSON.stringify(skipped));
|
||||
core.setOutput('matrix', JSON.stringify({ include: candidates }));
|
||||
core.setOutput('has_candidates', candidates.length > 0 ? 'true' : 'false');
|
||||
|
||||
core.summary.addRaw('## Merge gate scan\n');
|
||||
core.summary.addRaw(`Candidates: ${candidates.length}\n`);
|
||||
core.summary.addRaw('```json\n' + JSON.stringify({ candidates, skipped }, null, 2) + '\n```');
|
||||
await core.summary.write();
|
||||
|
||||
if (inputPr && skipped.length === 1 && skipped[0].pr === parseInt(inputPr, 10)) {
|
||||
const reasons = skipped[0].reasons;
|
||||
const body = [
|
||||
'**Merge gate scan** — not eligible for auto-merge.',
|
||||
'',
|
||||
...reasons.map((r) => `- \`${r}\``),
|
||||
'',
|
||||
'**Actions:** wait for CI and the Claude review chain, or add label `needs-manual-review` and merge manually.',
|
||||
'**Opt out:** label `no-auto-merge`.',
|
||||
].join('\n');
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: skipped[0].pr, body,
|
||||
});
|
||||
} else if (inputPr && candidates.length === 1) {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: candidates[0].pr_number,
|
||||
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
|
||||
});
|
||||
}
|
||||
|
||||
await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 20 }, core);
|
||||
|
||||
claude-assess:
|
||||
needs: scan-candidates
|
||||
if: needs.scan-candidates.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Mark merge gate active
|
||||
id: mark_active
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
core.setOutput('started_at', new Date().toISOString());
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: ${{ matrix.pr_number }},
|
||||
labels: ['claude-merge-gate-active'],
|
||||
});
|
||||
|
||||
- name: Stash merge gate helpers
|
||||
run: cp .github/scripts/merge-gate.js /tmp/merge-gate.js
|
||||
|
||||
- name: Fetch PR branch
|
||||
env:
|
||||
PR_NUMBER: ${{ matrix.pr_number }}
|
||||
run: |
|
||||
git fetch origin "pull/${PR_NUMBER}/head:pr-${PR_NUMBER}"
|
||||
git checkout "pr-${PR_NUMBER}"
|
||||
|
||||
- name: Claude merge gate assessment (read-only)
|
||||
id: assess
|
||||
continue-on-error: true
|
||||
uses: ./.github/actions/claude-code-action
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: inherit
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
model: claude-opus-4-8
|
||||
trigger_phrase: '@claude-merge-gate-internal'
|
||||
direct_prompt: |
|
||||
You are the MERGE GATE reviewer for PraisonAI PR #${{ matrix.pr_number }}.
|
||||
READ-ONLY: do not modify code, commit, push, rebase, or create branches.
|
||||
|
||||
CRITICAL OUTPUT RULE:
|
||||
Post exactly one PR comment via GitHub MCP containing either:
|
||||
MERGE_GATE_VERDICT: APPROVE
|
||||
or
|
||||
MERGE_GATE_VERDICT: BLOCK
|
||||
followed by a short rationale on the next lines.
|
||||
|
||||
NEVER include @claude in any comment. NEVER mention merge conflict resolution.
|
||||
|
||||
Review checklist:
|
||||
1. Read AGENTS.md and the PR diff on the current branch.
|
||||
2. Read all PR comments and reviews (FINAL architecture reviewer / Lead Engineer must have run).
|
||||
3. Confirm SDK value gate: change strengthens praisonaiagents (no scope creep).
|
||||
4. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
5. If agent.py adds >100 lines or new Agent __init__ params, respond MERGE_GATE_VERDICT: BLOCK (manual review required).
|
||||
6. BLOCK if labels security/breaking-change/needs-manual-review/release, sensitive paths (.github/workflows, auth, pyproject.toml), secrets in diff, PR >800 lines or >30 files, or SDK changes without tests. Label `merge-gate-ci-only` exempts CI-only changes under `.github/workflows/`, `.github/actions/`, or merge-gate scripts.
|
||||
7. Confirm no CHANGES_REQUESTED reviews and CI green on HEAD ${{ matrix.head_sha }}.
|
||||
8. If ready to merge: MERGE_GATE_VERDICT: APPROVE. Otherwise: MERGE_GATE_VERDICT: BLOCK with reasons.
|
||||
|
||||
Do not approve or merge via GitHub UI/API — only post the verdict comment.
|
||||
disallowed_tools: |
|
||||
Bash
|
||||
Edit
|
||||
Write
|
||||
Replace
|
||||
allowed_tools: |
|
||||
View
|
||||
GlobTool
|
||||
GrepTool
|
||||
Read
|
||||
timeout_minutes: 15
|
||||
|
||||
- name: Post fallback verdict if Claude did not comment
|
||||
id: fallback_verdict
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate.js');
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const existing = mergeGate.findMergeGateVerdict(ctx.comments, minCreatedAt, ctx.headPushedAt);
|
||||
if (existing) {
|
||||
core.info(`Verdict already posted: ${existing}`);
|
||||
core.setOutput('verdict', existing);
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
const verdict = check.ready ? 'APPROVE' : 'BLOCK';
|
||||
const lines = [
|
||||
`MERGE_GATE_VERDICT: ${verdict}`,
|
||||
'',
|
||||
'Automated fallback — Claude assess did not post a verdict comment (e.g. GitHub MCP unavailable).',
|
||||
];
|
||||
if (check.ready) {
|
||||
lines.push('All deterministic merge gates passed.');
|
||||
} else {
|
||||
lines.push('Blockers:', ...check.reasons.map((r) => `- ${r}`));
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber,
|
||||
body: lines.join('\n'),
|
||||
});
|
||||
core.info(`Posted fallback verdict: ${verdict}`);
|
||||
core.setOutput('verdict', verdict);
|
||||
|
||||
- name: Verify verdict posted
|
||||
id: verdict
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate.js');
|
||||
const comments = await mergeGate.listAllComments(
|
||||
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
|
||||
);
|
||||
const ctx = await mergeGate.loadPrContext(
|
||||
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
|
||||
);
|
||||
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const verdict = mergeGate.findMergeGateVerdict(comments, minCreatedAt, ctx.headPushedAt);
|
||||
core.setOutput('verdict', verdict || 'NONE');
|
||||
if (!verdict) core.warning('No recent MERGE_GATE_VERDICT comment found after assessment');
|
||||
|
||||
- name: Remove merge gate active label
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
name: 'claude-merge-gate-active',
|
||||
});
|
||||
core.info(`Removed claude-merge-gate-active from PR #${prNumber}`);
|
||||
} catch (e) {
|
||||
core.info(`Label claude-merge-gate-active already removed or missing on PR #${prNumber}`);
|
||||
}
|
||||
|
||||
- name: Fail job if assessment failed
|
||||
if: steps.assess.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
merge-only:
|
||||
needs: [scan-candidates, claude-assess]
|
||||
if: |
|
||||
always() &&
|
||||
needs.scan-candidates.outputs.has_candidates == 'true' &&
|
||||
needs.claude-assess.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Re-check gates and merge
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
MERGE_METHOD: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
SCAN_HEAD_SHA: ${{ matrix.head_sha }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
const scanHeadSha = process.env.SCAN_HEAD_SHA;
|
||||
|
||||
const removeLabels = async () => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: prNumber, name: 'claude-merge-gate-active',
|
||||
});
|
||||
} catch (e) {
|
||||
core.info('Label claude-merge-gate-active already removed or missing');
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const comments = ctx.comments;
|
||||
const verdictMin = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const verdict = mergeGate.findMergeGateVerdict(comments, verdictMin, ctx.headPushedAt);
|
||||
if (verdict !== 'APPROVE') {
|
||||
core.info(`Skip merge: verdict=${verdict || 'NONE'}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
if (!check.ready) {
|
||||
core.info(`Skip merge: ${check.reasons.join(', ')}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
if (check.headSha !== scanHeadSha) {
|
||||
core.info(`Skip merge: HEAD moved ${scanHeadSha.slice(0, 7)} → ${check.headSha.slice(0, 7)}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
const method = process.env.MERGE_METHOD || 'merge';
|
||||
await github.rest.pulls.merge({
|
||||
owner, repo, pull_number: prNumber, merge_method: method,
|
||||
});
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: prNumber, labels: ['auto-merged-by-gate'],
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber,
|
||||
body: [
|
||||
'Merged by **Claude PR merge gate** (`claude-merge-gate.yml`).',
|
||||
`Verdict: MERGE_GATE_VERDICT: APPROVE`,
|
||||
`SHA: \`${check.headSha.slice(0, 7)}\``,
|
||||
`Method: ${method}`,
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
core.info(`Merged PR #${prNumber}`);
|
||||
} finally {
|
||||
await removeLabels();
|
||||
}
|
||||
@@ -123,6 +123,8 @@ jobs:
|
||||
github.event.action != 'opened' &&
|
||||
(github.event.action != 'labeled' || github.event.label.name == 'claude') &&
|
||||
(github.event_name != 'issue_comment' || contains(github.event.comment.body, '@claude')) &&
|
||||
(github.event_name != 'pull_request_review' || contains(github.event.review.body, '@claude')) &&
|
||||
(github.event_name != 'pull_request_review_comment' || contains(github.event.comment.body, '@claude')) &&
|
||||
(
|
||||
!contains(github.actor, '[bot]') ||
|
||||
github.actor == 'github-actions[bot]' ||
|
||||
@@ -277,9 +279,10 @@ jobs:
|
||||
5. Plugins (PraisonAI-Plugins): Framework lifecycle extensions replacing systemic behavior (e.g. tracing, logging, metrics, hooks, guardrails).
|
||||
For items routed to an EXTERNAL repository (Tools, Docs, Plugins), follow STEP 3-ALT below.
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
|
||||
- Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
|
||||
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead. PRs with >100 lines added to agent.py require manual review (merge gate will not auto-merge).
|
||||
- Is there a name collision with existing exports in __init__.py?
|
||||
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
|
||||
|
||||
@@ -333,3 +336,60 @@ jobs:
|
||||
mcp__github__get_issue_comments
|
||||
mcp__github__update_issue
|
||||
timeout_minutes: 30
|
||||
|
||||
- name: Kick review chain for bot-created PR
|
||||
if: |
|
||||
steps.check_fork.outputs.is_pr != 'true' &&
|
||||
(github.event_name == 'issues' || github.event_name == 'issue_comment') &&
|
||||
github.event.issue != null
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
|
||||
const issueNumber = context.payload.issue.number;
|
||||
await chain.kickReviewChainForIssue(
|
||||
github, context.repo.owner, context.repo.repo, issueNumber, core
|
||||
);
|
||||
|
||||
- name: Sync pipeline status labels for PR
|
||||
if: steps.check_fork.outputs.is_pr == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const prNumber = parseInt('${{ steps.check_fork.outputs.pr_number }}', 10);
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, prNumber, core);
|
||||
|
||||
- name: Dispatch merge gate when PR pipeline ready
|
||||
if: steps.check_fork.outputs.is_pr == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = parseInt('${{ steps.check_fork.outputs.pr_number }}', 10);
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, {
|
||||
skipGlobalClaudeRunCheck: true,
|
||||
skipRecentClaudeCooldown: true,
|
||||
}
|
||||
);
|
||||
if (!check.ready) {
|
||||
core.info(`PR #${prNumber} not ready for merge gate: ${check.reasons.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
event_type: 'claude-merge-gate',
|
||||
client_payload: { pr_number: prNumber },
|
||||
});
|
||||
core.info(`Dispatched merge gate for PR #${prNumber}`);
|
||||
@@ -42,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));
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -40,6 +40,14 @@ on:
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
source:
|
||||
description: 'Trigger source (audit trail)'
|
||||
required: false
|
||||
default: manual
|
||||
type: choice
|
||||
options:
|
||||
- manual
|
||||
- nightly
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -78,6 +86,12 @@ jobs:
|
||||
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:
|
||||
@@ -184,16 +198,19 @@ jobs:
|
||||
- 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
|
||||
NEW_VERSION="${{ steps.versions.outputs.agents_version }}"
|
||||
CURRENT="${{ steps.versions.outputs.current_agents }}"
|
||||
|
||||
python <<PY
|
||||
python <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
path = Path("pyproject.toml")
|
||||
content = path.read_text()
|
||||
path.write_text(content.replace(f'version = "{CURRENT}"', f'version = "{NEW_VERSION}"', 1))
|
||||
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
|
||||
@@ -212,17 +229,16 @@ jobs:
|
||||
START=$(date +%s)
|
||||
|
||||
while true; do
|
||||
CURRENT=$(curl -fsSL "https://pypi.org/pypi/${PACKAGE}/json" | python3 -c "import json,sys; print(json.load(sys.stdin)['info']['version'])")
|
||||
if [ "$CURRENT" = "$VERSION" ]; then
|
||||
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} (current: ${CURRENT})"
|
||||
echo "❌ Timeout waiting for ${PACKAGE}==${VERSION} on PyPI"
|
||||
exit 1
|
||||
fi
|
||||
echo "⏳ Current: ${CURRENT}, waiting for: ${VERSION} ($(( MAX_WAIT - ELAPSED ))s remaining...)"
|
||||
echo "⏳ Waiting for ${PACKAGE}==${VERSION} ($(( MAX_WAIT - ELAPSED ))s remaining...)"
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
|
||||
@@ -249,6 +265,30 @@ jobs:
|
||||
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: |
|
||||
@@ -257,19 +297,9 @@ jobs:
|
||||
WRAPPER="${{ steps.versions.outputs.wrapper_version }}"
|
||||
|
||||
if [ "${{ inputs.skip_agents }}" != "true" ]; then
|
||||
LIVE=$(curl -fsSL "https://pypi.org/pypi/praisonaiagents/json" | python3 -c "import json,sys; print(json.load(sys.stdin)['info']['version'])")
|
||||
if [ "$LIVE" != "$AGENTS" ]; then
|
||||
echo "❌ praisonaiagents expected ${AGENTS}, got ${LIVE}"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ praisonaiagents==${AGENTS}"
|
||||
fi
|
||||
|
||||
if [ "${{ inputs.skip_wrapper }}" != "true" ]; then
|
||||
LIVE=$(curl -fsSL "https://pypi.org/pypi/praisonai/json" | python3 -c "import json,sys; print(json.load(sys.stdin)['info']['version'])")
|
||||
if [ "$LIVE" != "$WRAPPER" ]; then
|
||||
echo "❌ praisonai expected ${WRAPPER}, got ${LIVE}"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ praisonai==${WRAPPER}"
|
||||
fi
|
||||
+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`
|
||||
@@ -819,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>
|
||||
@@ -861,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>
|
||||
@@ -890,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>
|
||||
@@ -1367,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.76" \
|
||||
"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.76" \
|
||||
"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.76" \
|
||||
"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)")
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Example: Using MuAPI with PraisonAI for image generation.
|
||||
|
||||
MuAPI (https://muapi.ai) provides unified access to 400+ generative media
|
||||
models — Flux, Midjourney, GPT-4o Image, Google Imagen, Seedream, Veo3,
|
||||
Kling, Wan, and more — through a single API.
|
||||
|
||||
This example creates a PraisonAI agent that generates images on demand
|
||||
using the MuAPI image generation API as a custom tool.
|
||||
|
||||
Setup:
|
||||
pip install praisonaiagents requests
|
||||
export MUAPI_API_KEY=your_key_here
|
||||
# Get an API key at https://muapi.ai/dashboard/api-keys
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import requests
|
||||
from praisonaiagents import Agent, Tool
|
||||
|
||||
|
||||
MUAPI_BASE_URL = "https://api.muapi.ai/api/v1"
|
||||
|
||||
ALLOWED_MODELS = {
|
||||
"flux-schnell", "flux-dev", "flux-kontext-dev", "flux-kontext-pro",
|
||||
"midjourney", "gpt4o", "imagen4", "imagen4-fast", "seedream",
|
||||
"hidream-fast", "hidream-dev", "reve", "ideogram", "hunyuan",
|
||||
}
|
||||
|
||||
|
||||
def generate_image(prompt: str, model: str = "flux-schnell") -> str:
|
||||
"""Generate an image using MuAPI and return the output URL.
|
||||
|
||||
Args:
|
||||
prompt: Text description of the image to generate.
|
||||
model: MuAPI model ID. Options include flux-schnell, flux-dev,
|
||||
midjourney, gpt4o, imagen4, seedream, hidream-fast, reve.
|
||||
|
||||
Returns:
|
||||
URL of the generated image.
|
||||
"""
|
||||
if model not in ALLOWED_MODELS:
|
||||
raise ValueError(
|
||||
f"Unsupported model '{model}'. Choose from: {sorted(ALLOWED_MODELS)}"
|
||||
)
|
||||
|
||||
api_key = os.environ.get("MUAPI_API_KEY", "")
|
||||
if not api_key:
|
||||
raise ValueError("MUAPI_API_KEY environment variable not set")
|
||||
|
||||
headers = {"x-api-key": api_key, "Content-Type": "application/json"}
|
||||
|
||||
# Submit the generation request
|
||||
submit_resp = requests.post(
|
||||
f"{MUAPI_BASE_URL}/{model}",
|
||||
headers=headers,
|
||||
json={"prompt": prompt},
|
||||
timeout=30,
|
||||
)
|
||||
submit_resp.raise_for_status()
|
||||
submit_data = submit_resp.json()
|
||||
request_id = submit_data.get("request_id")
|
||||
if not request_id:
|
||||
raise RuntimeError(f"MuAPI did not return a request_id: {submit_data}")
|
||||
|
||||
# Poll until completion
|
||||
for _ in range(120):
|
||||
time.sleep(3)
|
||||
poll_resp = requests.get(
|
||||
f"{MUAPI_BASE_URL}/predictions/{request_id}/result",
|
||||
headers={"x-api-key": api_key},
|
||||
timeout=15,
|
||||
)
|
||||
poll_resp.raise_for_status()
|
||||
data = poll_resp.json()
|
||||
status = data.get("status")
|
||||
if status == "completed":
|
||||
outputs = data.get("outputs") or []
|
||||
if not outputs:
|
||||
raise RuntimeError(f"Generation completed but returned no outputs: {data}")
|
||||
return outputs[0]
|
||||
if status in ("failed", "cancelled"):
|
||||
raise RuntimeError(f"Image generation {status}: {data.get('error', '')}")
|
||||
|
||||
raise TimeoutError("Image generation timed out after 6 minutes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
image_tool = Tool(
|
||||
name="generate_image",
|
||||
description=(
|
||||
"Generate an image from a text description using MuAPI's 400+ model library. "
|
||||
"Supported models: flux-schnell (fast), flux-dev, midjourney, gpt4o, imagen4, "
|
||||
"seedream, hidream-fast, reve. Returns a URL to the generated image."
|
||||
),
|
||||
function=generate_image,
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name="Image Generator",
|
||||
instructions=(
|
||||
"You are a creative image generation assistant. "
|
||||
"When asked to create an image, use the generate_image tool. "
|
||||
"Pick the best model for the task: flux-schnell for speed, "
|
||||
"midjourney for artistic quality, gpt4o for photorealism."
|
||||
),
|
||||
tools=[image_tool],
|
||||
)
|
||||
|
||||
# Example: generate a product photo
|
||||
response = agent.start(
|
||||
"Generate a photorealistic product photo of a sleek black coffee mug "
|
||||
"on a white marble table with soft morning light. Use the best model for photorealism."
|
||||
)
|
||||
print(response)
|
||||
|
||||
# Example: generate concept art
|
||||
response = agent.start(
|
||||
"Create a cyberpunk cityscape at night with neon reflections on wet streets. "
|
||||
"Use midjourney for artistic quality."
|
||||
)
|
||||
print(response)
|
||||
@@ -571,6 +571,17 @@ gh pr diff $PR --repo $REPO | less
|
||||
|
||||
### 15b. Merge
|
||||
|
||||
**Primary:** Trigger or wait for **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`):
|
||||
|
||||
```bash
|
||||
gh workflow run claude-merge-gate.yml --repo $REPO -f pr_number=$PR
|
||||
gh pr view $PR --repo $REPO --json state,mergedAt,labels
|
||||
```
|
||||
|
||||
The gate merges when `MERGE_GATE_VERDICT: APPROVE`, CI is green, and the PR lacks `no-auto-merge`. Do not merge while `claude-conflict-pending` or during active `@claude` review/fix.
|
||||
|
||||
**Manual fallback** (opt-out or gate unavailable):
|
||||
|
||||
```bash
|
||||
gh pr merge $PR --repo $REPO --squash --delete-branch
|
||||
# --squash preserves linear main; --delete-branch keeps the repo clean
|
||||
|
||||
@@ -137,6 +137,15 @@ Final rule: conclude only when evidence shows missing = 0.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
PUBLISH WORKFLOW (reference)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
GitHub Actions (recommended):
|
||||
Manual: Actions → PyPI Release → Run workflow on main (bump defaults to patch)
|
||||
Nightly: `.github/workflows/nightly-release-gate.yml` runs at 00:00 UTC (midnight) when:
|
||||
- changes exist in src/praisonai or src/praisonai-agents since last v* tag
|
||||
- Core Tests succeeded on current main HEAD
|
||||
→ dispatches pypi-release.yml with bump=patch, source=nightly
|
||||
→ still requires pypi environment approval before publish
|
||||
Ad-hoc minor/major: manual PyPI Release only (explicit bump selection)
|
||||
|
||||
Step 1 — Publish praisonaiagents (Core SDK):
|
||||
cd /Users/praison/praisonai-package/src/praisonai-agents
|
||||
praisonai publish pypi
|
||||
@@ -161,4 +170,4 @@ Step 3 — Publish praisonai-tools (External, if needed):
|
||||
# Bump version in pyproject.toml, then:
|
||||
python3.13 -m build && uv run twine upload dist/*
|
||||
|
||||
PraisonAI PRs: NEVER merge automatically — user merges manually.
|
||||
PraisonAI PRs: default merge via **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`) when validation passes. Add label `no-auto-merge` to opt out. Manual `gh pr merge` only as fallback when the gate cannot run.
|
||||
@@ -187,7 +187,7 @@ gh pr create \
|
||||
|
||||
**Commit prefixes:** `fix:`, `feat:`, `security:`, `refactor:`, `docs:`, `test:`
|
||||
|
||||
> **Do NOT merge automatically** — user merges manually unless explicitly told otherwise.
|
||||
> **Merge:** Default is the **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`). Do not merge manually unless the PR has `no-auto-merge` or you are explicitly told to use `gh pr merge`.
|
||||
|
||||
### 5b. Verify PR state
|
||||
|
||||
|
||||
@@ -129,7 +129,9 @@ python -m pytest tests/ -q \
|
||||
|
||||
## Step 6 — Approve or Request Changes
|
||||
|
||||
**If the PR is good:**
|
||||
**Primary (automated):** The **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`) assesses readiness and merges when `MERGE_GATE_VERDICT: APPROVE`, CI is green, and the PR lacks `no-auto-merge`. It never posts `@claude` and does not use `gh pr review` (avoids re-triggering `claude.yml`).
|
||||
|
||||
**Manual fallback — if the PR is good and gate is skipped or opted out:**
|
||||
|
||||
```bash
|
||||
cd /Users/praison/praisonai-package && \
|
||||
@@ -147,6 +149,14 @@ gh pr review <NUMBER> --request-changes --body "Requesting changes: <specific fe
|
||||
|
||||
## Step 7 — Merge
|
||||
|
||||
**Primary:** Wait for merge gate or verify `mergedAt` after a gate run:
|
||||
|
||||
```bash
|
||||
cd /Users/praison/praisonai-package && gh pr view <NUMBER> --json state,mergedAt,labels
|
||||
```
|
||||
|
||||
**Manual fallback** (label `no-auto-merge` or gate disabled):
|
||||
|
||||
```bash
|
||||
cd /Users/praison/praisonai-package && \
|
||||
gh pr merge <NUMBER> --merge --subject "<PR title>"
|
||||
|
||||
@@ -50,7 +50,7 @@ The workflow uses the agent configuration at `.github/praisonai-reviewer.yaml` a
|
||||
- Copilot NEVER triggers before CodeRabbit/Qodo complete.
|
||||
- Claude NEVER triggers before Copilot completes.
|
||||
- Duplicate `@copilot` comments are prevented by checking existing comments.
|
||||
- PraisonAI PRs: NEVER merge automatically — user merges manually.
|
||||
- Default merge: **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`) after review chain + CI green. Opt out with label `no-auto-merge`. Manual merge only as fallback.
|
||||
|
||||
## Security & Credentials
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> --jq '{ghsa:
|
||||
3. For each advisory, validate the vulnerability in source code:
|
||||
- Read the file(s) mentioned in the advisory description
|
||||
- Confirm the vulnerability exists (or is already fixed)
|
||||
- Classify: `real` | `already-fixed` | `not-reproducible`
|
||||
- Classify using triage labels (see **Managing ongoing audits** below)
|
||||
- Reference the official repository for latest source: https://github.com/MervinPraison/PraisonAI
|
||||
|
||||
## PHASE 2 — VALIDATE & FIX
|
||||
@@ -99,9 +99,29 @@ pip index versions praisonaiagents | head -1
|
||||
pip index versions praisonai | head -1
|
||||
```
|
||||
|
||||
## PHASE 4 — UPDATE ADVISORIES (only after packages are on PyPI)
|
||||
13. Publish **praisonai-platform** (only when the advisory targets platform code):
|
||||
```bash
|
||||
cd /Users/praison/praisonai-package/src/praisonai-platform
|
||||
praisonai publish pypi
|
||||
# or: uv lock && uv build && uv publish --token $PYPI_TOKEN
|
||||
```
|
||||
Platform releases are **independent** of `praisonai` / `praisonaiagents` version numbers. A platform-only fix (e.g. `0.1.8`) does **not** require bumping the main SDK.
|
||||
|
||||
13. For each advisory, reopen to draft state and set patched versions + credits:
|
||||
14. Publish **praisonai (npm / TypeScript)** (only when the advisory targets `src/praisonai-ts`):
|
||||
```bash
|
||||
cd /Users/praison/praisonai-package/src/praisonai-ts
|
||||
npm version patch && npm publish
|
||||
```
|
||||
|
||||
15. Verify platform / npm when applicable:
|
||||
```bash
|
||||
pip index versions praisonai-platform | head -1
|
||||
npm view praisonai version
|
||||
```
|
||||
|
||||
## PHASE 4 — UPDATE ADVISORIES (only after packages are on PyPI/npm)
|
||||
|
||||
16. For each advisory, reopen to draft state and set patched versions + credits:
|
||||
```bash
|
||||
gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> \
|
||||
--method PATCH \
|
||||
@@ -119,34 +139,86 @@ gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> \
|
||||
}
|
||||
EOF
|
||||
```
|
||||
- `<PACKAGE_NAME>`: `praisonaiagents` for core SDK, `praisonai` for wrapper
|
||||
- `<PACKAGE_NAME>`: `praisonaiagents` (core SDK), `praisonai` (Python wrapper), `praisonai-platform` (platform layer), or `praisonai` (npm ecosystem for TypeScript)
|
||||
- `<LAST_VULNERABLE_VERSION>`: version before the fix
|
||||
- `<FIRST_PATCHED_VERSION>`: the version just published with the fix
|
||||
- Always include credits for the reporter
|
||||
|
||||
14. Publish each advisory:
|
||||
17. Publish each advisory:
|
||||
```bash
|
||||
gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> \
|
||||
--method PATCH --field state=published
|
||||
```
|
||||
|
||||
15. Request CVE for each published advisory:
|
||||
18. Request CVE for each published advisory:
|
||||
```bash
|
||||
gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID>/cve --method POST
|
||||
```
|
||||
GitHub reviews and assigns CVE IDs within 1-3 business days.
|
||||
|
||||
16. Verify final state:
|
||||
19. Verify final state:
|
||||
```bash
|
||||
gh api repos/MervinPraison/PraisonAI/security-advisories --jq '.[] | "\(.ghsa_id) \(.state) \(.cve_id // "pending") \(.severity) \([.credits[].login] | join(",") ) \(.summary | .[0:50])"'
|
||||
```
|
||||
|
||||
## MANAGING ONGOING AUDITS
|
||||
|
||||
Continuous security reports are **expected** for an agent framework. Treat them as a product signal, not a crisis. Use a consistent process so triage stays manageable.
|
||||
|
||||
### Triage labels
|
||||
|
||||
After validating each advisory in source code, assign **one** label:
|
||||
|
||||
| Label | Meaning | Action |
|
||||
|---|---|---|
|
||||
| `real` | Reproducible, valid security issue | Fix → publish → advisory |
|
||||
| `already-fixed` | Fixed in `main` but advisory still open | Set patched version to current release; publish advisory |
|
||||
| `duplicate` | Same root cause as another GHSA | Close or merge; link to primary advisory |
|
||||
| `not-reproducible` | Cannot reproduce with steps provided | Request more detail; hold in triage |
|
||||
| `theoretical` | Requires unlikely preconditions or no practical impact | Document reasoning; close as wontfix or low priority |
|
||||
| `documented-opt-out` | Behaviour is intentional with documented escape hatch | Close as wontfix; point to docs/env var |
|
||||
| `out-of-scope` | Wrong package, fork, or deployment the project does not ship | Close with explanation |
|
||||
|
||||
### Triage load
|
||||
|
||||
- Not every report is valid — filter with the labels above before writing code.
|
||||
- Batch related GHSAs (same file, same pattern) into one fix where possible.
|
||||
- Prefer regression tests per GHSA so fixes are not re-opened by the next audit round.
|
||||
|
||||
### Version churn and release boundaries
|
||||
|
||||
| Package | Version line | Notes |
|
||||
|---|---|---|
|
||||
| `praisonaiagents` + `praisonai` | Aligned patch (e.g. `1.6.62` / `4.6.62`) | Main SDK + wrapper; bump together via `bump_and_release.py` |
|
||||
| `praisonai-platform` | Independent semver (e.g. `0.1.8`) | Platform-only fixes; users need **not** upgrade the main SDK |
|
||||
| `praisonai` (npm) | Independent semver (e.g. `1.7.2`) | TypeScript SDK; publish from `src/praisonai-ts` |
|
||||
|
||||
Always set the advisory `patched_versions` to the **package that actually contains the fix**. Do not imply users must upgrade all packages when only one changed.
|
||||
|
||||
### Researcher incentives
|
||||
|
||||
- Some reports are bounty-driven or low-quality — still acknowledge and triage promptly.
|
||||
- Credit valid reporters in the advisory even for small fixes.
|
||||
- Close invalid reports with a clear label and short rationale (no exploit discussion in public issues).
|
||||
- Never fix “for the sake of it” — see Phase 2 validate checks.
|
||||
|
||||
### When to close as wontfix
|
||||
|
||||
Close (or publish advisory with no code change only if already mitigated) when:
|
||||
|
||||
- Documented opt-out exists and risk is accepted (e.g. localhost-only dev modes with explicit env vars).
|
||||
- Issue is theoretical with no realistic attack path in supported deployments.
|
||||
- Fix would remove existing features or materially bloat the SDK — escalate instead.
|
||||
- Duplicate of an existing published advisory.
|
||||
|
||||
## REFERENCE — Package-to-file mapping
|
||||
|
||||
| Package | Canonical Path | Key files |
|
||||
|---|---|---|
|
||||
| praisonaiagents | src/praisonai-agents/praisonaiagents/ | tools/python_tools.py, mcp/, memory/, agent/ |
|
||||
| praisonai | src/praisonai/praisonai/ | ui/sql_alchemy.py, capabilities/, mcp_server/, cli/ |
|
||||
| Package | Ecosystem | Canonical Path | Key files |
|
||||
|---|---|---|---|
|
||||
| praisonaiagents | pip | src/praisonai-agents/praisonaiagents/ | tools/, mcp/, memory/, agent/, sandbox/ |
|
||||
| praisonai | pip | src/praisonai/praisonai/ | cli/, capabilities/, mcp_server/, sandbox/, recipe/ |
|
||||
| praisonai-platform | pip | src/praisonai-platform/praisonai_platform/ | api/routes/, services/, auth |
|
||||
| praisonai | npm | src/praisonai-ts/src/ | agent/, mcp/, tools/, code-mode/ |
|
||||
|
||||
## RESOURCES & DOCUMENTATION
|
||||
|
||||
@@ -160,8 +232,10 @@ gh api repos/MervinPraison/PraisonAI/security-advisories --jq '.[] | "\(.ghsa_id
|
||||
- NEVER expose exploit details in public issues or PRs
|
||||
- ALWAYS credit the reporter in the advisory
|
||||
- ALWAYS set patched_versions before publishing advisory
|
||||
- ALWAYS publish to PyPI BEFORE publishing advisories (so Dependabot can point users to a fix) - to publish `praisonai publish pypi` from praisonai-agents folder, after that
|
||||
`cd /Users/praison/praisonai-package/src/praisonai && python scripts/bump_and_release.py 4.5.125 --agents 1.5.125 --wait`. 4.5.125 is praisonai version number and 1.5.125 is praisonaiagents version number. Last 2 numbers will be same 4/1.xx.xxx
|
||||
- ALWAYS publish to PyPI/npm BEFORE publishing advisories (so Dependabot can point users to a fix)
|
||||
- Python SDK: `praisonai publish pypi` from `src/praisonai-agents`, then `bump_and_release.py` for the wrapper
|
||||
- Platform-only: `praisonai publish pypi` from `src/praisonai-platform` — no SDK bump required
|
||||
- npm: `npm publish` from `src/praisonai-ts` when the TypeScript SDK is affected
|
||||
- ALWAYS request CVE AFTER publishing advisory
|
||||
- Use generic commit messages: "refactor: harden X" not "fix: SQL injection in Y"
|
||||
- Fix on main, push directly — no feature branches for security patches
|
||||
@@ -172,7 +246,6 @@ gh api repos/MervinPraison/PraisonAI/security-advisories --jq '.[] | "\(.ghsa_id
|
||||
|
||||
## REPORTING SECURITY ISSUES
|
||||
|
||||
If you discover a security vulnerability, please report it responsibly:
|
||||
1. Open a draft security advisory at https://github.com/MervinPraison/PraisonAI/security/advisories/new
|
||||
2. Include detailed reproduction steps and impact assessment
|
||||
3. Allow time for the maintainers to address the issue before public disclosure
|
||||
If you discover a security vulnerability, please report it responsibly. See [SECURITY.md](../../../../SECURITY.md) at the monorepo root for scope, supported packages, and the private advisory link.
|
||||
|
||||
Maintainers and agents: use [.cursor/skills/security-audit/SKILL.md](../../.cursor/skills/security-audit/SKILL.md) for Cursor skill entry point.
|
||||
@@ -18,19 +18,19 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 20 | 41 | 11111 |
|
||||
| ✅ Agent | 21 | 42 | 11340 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2880 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 575 |
|
||||
| ✅ Approval | 1 | 3 | 1344 |
|
||||
| ✅ Audio | 2 | 10 | 649 |
|
||||
| ✅ Auto Generation | 5 | 6 | 1637 |
|
||||
| ✅ Autonomy | 3 | 5 | 1919 |
|
||||
| ✅ Bots | 7 | 23 | 7357 |
|
||||
| ✅ Bots | 7 | 23 | 7714 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 107 | 26699 |
|
||||
| ✅ 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 | 2160 |
|
||||
| ✅ Context Management | 16 | 33 | 10280 |
|
||||
@@ -44,12 +44,12 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| ✅ Failover | 2 | 1 | 372 |
|
||||
| ✅ Files | 2 | 6 | 1632 |
|
||||
| ✅ Flow | 1 | 2 | 435 |
|
||||
| ✅ Gateway | 7 | 19 | 6767 |
|
||||
| ✅ Gateway | 7 | 21 | 7477 |
|
||||
| ✅ Guardrails | 4 | 4 | 1619 |
|
||||
| ✅ Handoffs | 11 | 6 | 2499 |
|
||||
| ✅ Hooks | 2 | 8 | 3146 |
|
||||
| ✅ 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 | 10939 |
|
||||
@@ -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 | 3445 |
|
||||
| ✅ Skills | 6 | 10 | 3641 |
|
||||
| ✅ 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 | 25896 |
|
||||
| ✅ Tools | 12 | 118 | 25895 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Video | 2 | 6 | 510 |
|
||||
| ✅ Vision | 2 | 1 | 283 |
|
||||
| ✅ Web | 3 | 6 | 1352 |
|
||||
| ✅ Workflows | 4 | 14 | 5810 |
|
||||
| ✅ Web | 3 | 7 | 1537 |
|
||||
| ✅ Workflows | 4 | 14 | 5893 |
|
||||
|
||||
## Undocumented Categories (Need Documentation)
|
||||
|
||||
|
||||
@@ -235,6 +235,12 @@ _LAZY_IMPORTS = {
|
||||
'CliBackendResult': ('praisonaiagents.cli_backend.protocols', 'CliBackendResult'),
|
||||
'CliBackendDelta': ('praisonaiagents.cli_backend.protocols', 'CliBackendDelta'),
|
||||
|
||||
# Runtime Selection (Auto harness selection and registry)
|
||||
'AgentRuntimeProtocol': ('praisonaiagents.runtime.protocols', 'AgentRuntimeProtocol'),
|
||||
'resolve_runtime': ('praisonaiagents.runtime.registry', 'resolve_runtime'),
|
||||
'register_runtime': ('praisonaiagents.runtime.registry', 'register_runtime'),
|
||||
'list_runtimes': ('praisonaiagents.runtime.registry', 'list_runtimes'),
|
||||
|
||||
# Agent classes
|
||||
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
|
||||
'RetryBackoffConfig': ('praisonaiagents.agent.retry_utils', 'RetryBackoffConfig'),
|
||||
|
||||
@@ -4063,37 +4063,6 @@ Summary:"""
|
||||
)
|
||||
return await handoff_obj.execute_async(self, prompt, context)
|
||||
|
||||
def _create_subagent(
|
||||
self,
|
||||
profile: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
) -> 'Agent':
|
||||
"""Create a subagent with the specified profile.
|
||||
|
||||
Args:
|
||||
profile: Agent profile name
|
||||
context: Additional context
|
||||
|
||||
Returns:
|
||||
Configured Agent instance
|
||||
"""
|
||||
from ..agents.profiles import get_profile, BUILTIN_PROFILES
|
||||
|
||||
# Get profile config
|
||||
profile_config = get_profile(profile) if profile in BUILTIN_PROFILES else None
|
||||
|
||||
if profile_config:
|
||||
return Agent(
|
||||
name=f"subagent_{profile}",
|
||||
instructions=profile_config.system_prompt,
|
||||
)
|
||||
else:
|
||||
# Default subagent
|
||||
return Agent(
|
||||
name=f"subagent_{profile}",
|
||||
instructions=f"You are a {profile} assistant.",
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Runtime Capability Management
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -831,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -107,12 +107,26 @@ def is_path_like(value: str) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
|
||||
# Check for path indicators
|
||||
if value.startswith(("./", "../", "/", "~/")):
|
||||
# Check for path indicators (POSIX and Windows-style prefixes)
|
||||
if value.startswith(("./", "../", "/", "~/", ".\\", "..\\", "\\\\", "\\", "~\\")):
|
||||
return True
|
||||
|
||||
|
||||
# Check for Windows drive paths like C:\data or C:/data
|
||||
if (
|
||||
len(value) >= 3
|
||||
and value[1] == ":"
|
||||
and value[0].isalpha()
|
||||
and value[2] in ("\\", "/")
|
||||
):
|
||||
return True
|
||||
|
||||
# Check for directory indicator
|
||||
if value.endswith("/"):
|
||||
if value.endswith(("/", "\\")):
|
||||
return True
|
||||
|
||||
# Check for embedded backslash separators (relative Windows path like "knowledge\docs").
|
||||
# Backslash is not valid in normal config string values, so it is a strong path signal.
|
||||
if "\\" in value:
|
||||
return True
|
||||
|
||||
# Check for file extension (common ones)
|
||||
|
||||
@@ -314,7 +314,8 @@ def read_file(
|
||||
start_line: Optional[int] = None,
|
||||
end_line: Optional[int] = None,
|
||||
context_lines: int = 0,
|
||||
max_lines: int = 500
|
||||
max_lines: int = 500,
|
||||
workspace_root: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Read file contents with optional line range.
|
||||
|
||||
@@ -328,8 +329,17 @@ def read_file(
|
||||
Returns:
|
||||
Dictionary with content, line info, and metadata
|
||||
"""
|
||||
filepath = os.path.abspath(filepath)
|
||||
|
||||
from praisonaiagents.tools.path_safety import resolve_within_root
|
||||
|
||||
safe_path = resolve_within_root(filepath, workspace_root or os.getcwd())
|
||||
if safe_path is None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Path outside workspace: {filepath}",
|
||||
"path": filepath,
|
||||
}
|
||||
filepath = safe_path
|
||||
|
||||
if not os.path.exists(filepath):
|
||||
return {
|
||||
"success": False,
|
||||
|
||||
@@ -19,6 +19,7 @@ from .protocols import (
|
||||
GatewayMessage,
|
||||
EventType,
|
||||
OperatorScope,
|
||||
GatewayCloseCode,
|
||||
# Push protocols and dataclasses
|
||||
PushChannelProtocol,
|
||||
PresenceProtocol,
|
||||
@@ -38,10 +39,19 @@ from .protocols import (
|
||||
RouteFacts,
|
||||
RouteMatch,
|
||||
resolve_route,
|
||||
# Per-route, trust-tiered toolset scoping (Issue #2298)
|
||||
ToolPolicy,
|
||||
UNTRUSTED_DENY_SUBSTRINGS,
|
||||
TRUST_TIERS,
|
||||
# Outbound send-policy guard (Issue #2226)
|
||||
SendDecision,
|
||||
SendPolicyProtocol,
|
||||
SendPolicy,
|
||||
# Gateway idle-dormancy / scale-to-zero (Issue #2332)
|
||||
IdleDecision,
|
||||
GatewayIdlePolicyProtocol,
|
||||
GatewayIdlePolicy, # backward-compat alias
|
||||
ScaleToZeroPolicy,
|
||||
# Protocol version negotiation
|
||||
PROTOCOL_VERSION,
|
||||
MIN_PROTOCOL_VERSION,
|
||||
@@ -51,6 +61,13 @@ from .protocols import (
|
||||
GapInfo,
|
||||
ResumeSnapshot,
|
||||
)
|
||||
from .hooks import (
|
||||
HookAction,
|
||||
HookConfig,
|
||||
InboundTriggerProtocol,
|
||||
render_template,
|
||||
compute_idempotency_key,
|
||||
)
|
||||
from .config import (
|
||||
GatewayConfig,
|
||||
SessionConfig,
|
||||
@@ -112,6 +129,7 @@ __all__ = [
|
||||
"GatewayMessage",
|
||||
"EventType",
|
||||
"OperatorScope",
|
||||
"GatewayCloseCode",
|
||||
# Push protocols (always available)
|
||||
"PushChannelProtocol",
|
||||
"PresenceProtocol",
|
||||
@@ -131,10 +149,19 @@ __all__ = [
|
||||
"RouteFacts",
|
||||
"RouteMatch",
|
||||
"resolve_route",
|
||||
# Per-route, trust-tiered toolset scoping (Issue #2298)
|
||||
"ToolPolicy",
|
||||
"UNTRUSTED_DENY_SUBSTRINGS",
|
||||
"TRUST_TIERS",
|
||||
# Outbound send-policy guard (Issue #2226)
|
||||
"SendDecision",
|
||||
"SendPolicyProtocol",
|
||||
"SendPolicy",
|
||||
# Gateway idle-dormancy / scale-to-zero (Issue #2332)
|
||||
"IdleDecision",
|
||||
"GatewayIdlePolicyProtocol",
|
||||
"GatewayIdlePolicy",
|
||||
"ScaleToZeroPolicy",
|
||||
# Protocol version negotiation
|
||||
"PROTOCOL_VERSION",
|
||||
"MIN_PROTOCOL_VERSION",
|
||||
@@ -143,6 +170,12 @@ __all__ = [
|
||||
"ProtocolHelloOk",
|
||||
"GapInfo",
|
||||
"ResumeSnapshot",
|
||||
# Inbound trigger / webhook contract (Issue #2281)
|
||||
"HookAction",
|
||||
"HookConfig",
|
||||
"InboundTriggerProtocol",
|
||||
"render_template",
|
||||
"compute_idempotency_key",
|
||||
# Config (always available)
|
||||
"GatewayConfig",
|
||||
"SessionConfig",
|
||||
|
||||
@@ -229,6 +229,9 @@ class GatewayConfig:
|
||||
ssl_cert: Path to SSL certificate (for HTTPS/WSS)
|
||||
ssl_key: Path to SSL key
|
||||
max_buffered_bytes: Maximum buffered bytes before slow consumer disconnect (default 1MB)
|
||||
max_queued_frames: Maximum queued outbound frames per client before slow
|
||||
consumer disconnect (0 = unlimited frame count; byte ceiling still
|
||||
applies). Default 1000.
|
||||
push: Push notification service configuration
|
||||
auth_scopes: Optional operator scope policy mapping token -> list of
|
||||
scope names (see OperatorScope). When None/empty (default), any
|
||||
@@ -251,6 +254,7 @@ class GatewayConfig:
|
||||
ssl_cert: Optional[str] = None
|
||||
ssl_key: Optional[str] = None
|
||||
max_buffered_bytes: int = 1024 * 1024 # 1MB default
|
||||
max_queued_frames: int = 1000 # Per-client outbound frame ceiling
|
||||
push: PushConfig = field(default_factory=PushConfig)
|
||||
auth_scopes: Optional[Dict[str, List[str]]] = None
|
||||
|
||||
@@ -262,6 +266,10 @@ class GatewayConfig:
|
||||
raise ValueError(
|
||||
"max_buffered_bytes must be >= 0 (use 0 to disable slow-consumer checks)"
|
||||
)
|
||||
if self.max_queued_frames < 0:
|
||||
raise ValueError(
|
||||
"max_queued_frames must be >= 0 (use 0 to disable the frame-count ceiling)"
|
||||
)
|
||||
if self.max_connections < 0:
|
||||
raise ValueError("max_connections must be >= 0")
|
||||
if self.heartbeat_interval < 0:
|
||||
@@ -335,6 +343,7 @@ class GatewayConfig:
|
||||
"reconnect_timeout": self.reconnect_timeout,
|
||||
"ssl_enabled": bool(self.ssl_cert and self.ssl_key),
|
||||
"max_buffered_bytes": self.max_buffered_bytes,
|
||||
"max_queued_frames": self.max_queued_frames,
|
||||
"push": self.push.to_dict(),
|
||||
"scope_policy_enabled": self.has_scope_policy,
|
||||
}
|
||||
@@ -429,6 +438,7 @@ class MultiChannelGatewayConfig:
|
||||
gateway: GatewayConfig = field(default_factory=GatewayConfig)
|
||||
agents: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
channels: Dict[str, ChannelRouteConfig] = field(default_factory=dict)
|
||||
hooks: List[Any] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "MultiChannelGatewayConfig":
|
||||
@@ -510,7 +520,8 @@ class MultiChannelGatewayConfig:
|
||||
reconnect_timeout=gw_data.get("reconnect_timeout", 60),
|
||||
ssl_cert=gw_data.get("ssl_cert"),
|
||||
ssl_key=gw_data.get("ssl_key"),
|
||||
max_buffered_bytes=gw_data.get("max_buffered_bytes", 1024 * 1024),
|
||||
max_buffered_bytes=int(gw_data.get("max_buffered_bytes", 1024 * 1024)),
|
||||
max_queued_frames=int(gw_data.get("max_queued_frames", 1000)),
|
||||
auth_scopes=auth_scopes,
|
||||
)
|
||||
|
||||
@@ -533,10 +544,29 @@ class MultiChannelGatewayConfig:
|
||||
},
|
||||
)
|
||||
|
||||
# Parse inbound trigger hooks (Issue #2281). The hooks live either at
|
||||
# the top level (``hooks:``) or nested under ``gateway:`` for grouping.
|
||||
from .hooks import HookConfig
|
||||
|
||||
raw_hooks = data.get("hooks")
|
||||
if raw_hooks is None:
|
||||
raw_hooks = gw_data.get("hooks")
|
||||
hooks: List[HookConfig] = []
|
||||
for entry in raw_hooks or []:
|
||||
if isinstance(entry, dict) and entry.get("path"):
|
||||
try:
|
||||
hooks.append(HookConfig.from_dict(entry))
|
||||
except (ValueError, TypeError):
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
"Skipping invalid gateway hook entry: %s", entry
|
||||
)
|
||||
|
||||
return cls(
|
||||
gateway=gateway_config,
|
||||
agents=agents,
|
||||
channels=channels,
|
||||
hooks=hooks,
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@@ -547,4 +577,5 @@ class MultiChannelGatewayConfig:
|
||||
"channels": {
|
||||
name: ch.to_dict() for name, ch in self.channels.items()
|
||||
},
|
||||
"hooks": [h.to_dict() for h in self.hooks],
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Inbound trigger (webhook) contract for the PraisonAI gateway.
|
||||
|
||||
This module is **protocol-only** and carries no heavy dependencies. It defines
|
||||
the declarative shape of an inbound event trigger — an authenticated HTTP
|
||||
surface that, on receiving an external payload, runs an agent (or wakes a
|
||||
session) and delivers the result through a channel.
|
||||
|
||||
The HTTP route registration, request authentication, idempotency store, rate
|
||||
limiting and channel delivery all live in the ``praisonai`` wrapper gateway
|
||||
(``praisonai.gateway.server``). Here we only describe:
|
||||
|
||||
- ``HookConfig``: the declarative trigger definition (path/auth/action/agent/
|
||||
session/idempotency/delivery/message).
|
||||
- ``render_template``: a tiny, dependency-free ``{{ payload.x }}`` / ``{x}``
|
||||
templating helper used to build session keys, idempotency keys and messages
|
||||
from a JSON payload.
|
||||
- ``compute_idempotency_key``: derive a stable dedup key from a payload.
|
||||
- ``InboundTriggerProtocol``: the structural contract a gateway implements to
|
||||
register hooks.
|
||||
|
||||
It sits alongside the existing internal lifecycle hooks (``SCHEDULE_TRIGGER``,
|
||||
``GATEWAY_START``) as the *inbound* counterpart: those fire from inside the
|
||||
process; this fires from an external HTTP request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional, Protocol, runtime_checkable
|
||||
|
||||
__all__ = [
|
||||
"HookAction",
|
||||
"HookConfig",
|
||||
"InboundTriggerProtocol",
|
||||
"compute_idempotency_key",
|
||||
"render_template",
|
||||
]
|
||||
|
||||
|
||||
class HookAction:
|
||||
"""Allowed hook actions (string constants, kept dependency-free)."""
|
||||
|
||||
AGENT = "agent" # Run an agent turn on the templated message.
|
||||
WAKE = "wake" # Nudge an existing session without a new user message.
|
||||
|
||||
@classmethod
|
||||
def all(cls) -> "list[str]":
|
||||
return [cls.AGENT, cls.WAKE]
|
||||
|
||||
|
||||
# Matches both ``{{ payload.a.b }}`` (Jinja-ish) and ``{a.b}`` (str.format-ish)
|
||||
# placeholders so the same template works across the YAML and Python surfaces.
|
||||
# A single combined pattern is used (double-brace alternative first so it wins)
|
||||
# so each placeholder is substituted exactly once in one pass — a payload value
|
||||
# that itself contains ``{...}`` is therefore never re-expanded.
|
||||
_PLACEHOLDER = re.compile(r"\{\{\s*([^}]+?)\s*\}\}|\{\s*([^{}]+?)\s*\}")
|
||||
|
||||
|
||||
def _lookup(path: str, payload: Dict[str, Any]) -> Any:
|
||||
"""Resolve a dotted ``path`` against ``payload``.
|
||||
|
||||
A leading ``payload.`` prefix is optional, so ``{{ payload.from }}`` and
|
||||
``{from}`` resolve identically. Missing keys resolve to an empty string so
|
||||
a template never raises on a partial payload.
|
||||
"""
|
||||
key = path.strip()
|
||||
if key.startswith("payload."):
|
||||
key = key[len("payload."):]
|
||||
elif key == "payload":
|
||||
return payload
|
||||
|
||||
current: Any = payload
|
||||
for part in key.split("."):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
else:
|
||||
return ""
|
||||
return current
|
||||
|
||||
|
||||
def render_template(template: Optional[str], payload: Dict[str, Any]) -> str:
|
||||
"""Render ``template`` against ``payload`` with no external dependencies.
|
||||
|
||||
Supports ``{{ payload.x }}`` and ``{x}`` placeholders resolving dotted
|
||||
paths into the JSON ``payload``. Returns an empty string when ``template``
|
||||
is falsy. Unknown placeholders render as empty strings rather than raising.
|
||||
|
||||
Args:
|
||||
template: The template string (or None).
|
||||
payload: The decoded JSON request body.
|
||||
|
||||
Returns:
|
||||
The rendered string.
|
||||
"""
|
||||
if not template:
|
||||
return ""
|
||||
|
||||
def _resolve(match: "re.Match[str]") -> str:
|
||||
# group(1) is the ``{{ ... }}`` body, group(2) the ``{ ... }`` body.
|
||||
expr = match.group(1) if match.group(1) is not None else match.group(2)
|
||||
value = _lookup(expr, payload)
|
||||
return "" if value is None else str(value)
|
||||
|
||||
return _PLACEHOLDER.sub(_resolve, template)
|
||||
|
||||
|
||||
def compute_idempotency_key(
|
||||
template: Optional[str],
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
path: str = "",
|
||||
) -> str:
|
||||
"""Derive a stable idempotency key for a hook delivery.
|
||||
|
||||
When ``template`` is provided it is rendered against ``payload`` and the
|
||||
result (scoped by ``path``) is hashed. When it is not, the whole payload is
|
||||
hashed deterministically so identical retried deliveries still dedup.
|
||||
|
||||
Args:
|
||||
template: Optional idempotency-key template (e.g. ``"{message_id}"``).
|
||||
payload: The decoded JSON request body.
|
||||
path: The hook path, mixed in so the same id on different hooks does
|
||||
not collide.
|
||||
|
||||
Returns:
|
||||
A hex digest usable as a dedup key.
|
||||
"""
|
||||
if template:
|
||||
rendered = render_template(template, payload).strip()
|
||||
if rendered:
|
||||
basis = f"{path}\x00{rendered}"
|
||||
return hashlib.sha256(basis.encode("utf-8")).hexdigest()
|
||||
|
||||
# Fall back to a deterministic hash of the full payload.
|
||||
import json
|
||||
|
||||
try:
|
||||
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
except (TypeError, ValueError):
|
||||
canonical = repr(payload)
|
||||
basis = f"{path}\x00{canonical}"
|
||||
return hashlib.sha256(basis.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass
|
||||
class HookConfig:
|
||||
"""Declarative definition of an inbound event trigger.
|
||||
|
||||
Exposes ``POST /hooks/<path>`` on the gateway. On a request the gateway
|
||||
authenticates, deduplicates, resolves a session, runs the agent (or wakes a
|
||||
session), and delivers the reply through the delivery router.
|
||||
|
||||
Attributes:
|
||||
path: URL path segment, e.g. ``"gmail"`` exposes ``POST /hooks/gmail``.
|
||||
agent: Agent id to run (``action="agent"``). Defaults to the gateway's
|
||||
first registered agent when omitted.
|
||||
action: ``"agent"`` runs a turn, ``"wake"`` nudges a session.
|
||||
auth: Optional bearer token / shared secret required on the request.
|
||||
When omitted the gateway's own ``auth_token`` is used.
|
||||
session_key: Template for the session id, so related events share
|
||||
context, e.g. ``"hook:gmail:{message_id}"``.
|
||||
idempotency_key: Template for the dedup key, e.g. ``"{message_id}"``.
|
||||
When omitted the whole payload is hashed.
|
||||
deliver_to: ``channel:target`` delivery spec for the agent reply, e.g.
|
||||
``"telegram:123456789"``. Omit to skip outbound delivery.
|
||||
message: Template for the message built from the payload.
|
||||
enabled: Whether the hook is active.
|
||||
metadata: Free-form extra settings.
|
||||
"""
|
||||
|
||||
path: str
|
||||
agent: Optional[str] = None
|
||||
action: str = HookAction.AGENT
|
||||
auth: Optional[str] = None
|
||||
session_key: Optional[str] = None
|
||||
idempotency_key: Optional[str] = None
|
||||
deliver_to: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
enabled: bool = True
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.path = (self.path or "").strip().strip("/")
|
||||
if not self.path:
|
||||
raise ValueError("HookConfig.path must be a non-empty path segment")
|
||||
if self.action not in HookAction.all():
|
||||
raise ValueError(
|
||||
f"HookConfig.action must be one of {HookAction.all()}, "
|
||||
f"got {self.action!r}"
|
||||
)
|
||||
|
||||
@property
|
||||
def route(self) -> str:
|
||||
"""The full gateway route this hook is served on."""
|
||||
return f"/hooks/{self.path}"
|
||||
|
||||
def resolve_session_key(self, payload: Dict[str, Any]) -> str:
|
||||
"""Resolve the session id for ``payload``.
|
||||
|
||||
Falls back to a stable per-hook key when no ``session_key`` template is
|
||||
configured, so a hook without an explicit key still groups its events.
|
||||
"""
|
||||
if self.session_key:
|
||||
rendered = render_template(self.session_key, payload).strip()
|
||||
if rendered:
|
||||
return rendered
|
||||
return f"hook:{self.path}"
|
||||
|
||||
def resolve_idempotency_key(self, payload: Dict[str, Any]) -> str:
|
||||
"""Resolve the dedup key for ``payload``."""
|
||||
return compute_idempotency_key(
|
||||
self.idempotency_key, payload, path=self.path
|
||||
)
|
||||
|
||||
def resolve_message(self, payload: Dict[str, Any]) -> str:
|
||||
"""Render the agent message for ``payload``."""
|
||||
return render_template(self.message, payload)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a dictionary (hides the auth secret)."""
|
||||
return {
|
||||
"path": self.path,
|
||||
"agent": self.agent,
|
||||
"action": self.action,
|
||||
"auth": "***" if self.auth else None,
|
||||
"session_key": self.session_key,
|
||||
"idempotency_key": self.idempotency_key,
|
||||
"deliver_to": self.deliver_to,
|
||||
"message": self.message,
|
||||
"enabled": self.enabled,
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "HookConfig":
|
||||
"""Create a ``HookConfig`` from a parsed YAML/JSON mapping."""
|
||||
known = {
|
||||
"path",
|
||||
"agent",
|
||||
"action",
|
||||
"auth",
|
||||
"session_key",
|
||||
"idempotency_key",
|
||||
"deliver_to",
|
||||
"message",
|
||||
"enabled",
|
||||
"metadata",
|
||||
}
|
||||
return cls(
|
||||
path=data.get("path", ""),
|
||||
agent=data.get("agent"),
|
||||
action=data.get("action", HookAction.AGENT),
|
||||
auth=data.get("auth"),
|
||||
session_key=data.get("session_key"),
|
||||
idempotency_key=data.get("idempotency_key"),
|
||||
deliver_to=data.get("deliver_to"),
|
||||
message=data.get("message"),
|
||||
enabled=data.get("enabled", True),
|
||||
metadata={
|
||||
**(data.get("metadata") or {}),
|
||||
**{k: v for k, v in data.items() if k not in known},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class InboundTriggerProtocol(Protocol):
|
||||
"""Structural contract a gateway implements to host inbound triggers."""
|
||||
|
||||
def register_hook(self, hook: "HookConfig | Dict[str, Any]", **kwargs: Any) -> str:
|
||||
"""Register an inbound trigger, returning its path."""
|
||||
...
|
||||
|
||||
def unregister_hook(self, path: str) -> bool:
|
||||
"""Remove a registered hook by path."""
|
||||
...
|
||||
|
||||
def list_hooks(self) -> "list[str]":
|
||||
"""List registered hook paths."""
|
||||
...
|
||||
@@ -24,6 +24,7 @@ from typing import (
|
||||
Literal,
|
||||
Optional,
|
||||
Protocol,
|
||||
Set,
|
||||
TypedDict,
|
||||
Union,
|
||||
runtime_checkable,
|
||||
@@ -52,6 +53,23 @@ class ConnectErrorCode(str, Enum):
|
||||
CONFIGURATION_ERROR = "configuration_error"
|
||||
|
||||
|
||||
class GatewayCloseCode(str, Enum):
|
||||
"""Structured, machine-readable reasons for a server-initiated close.
|
||||
|
||||
Distinct from :class:`ConnectErrorCode` (which describes why a *connection*
|
||||
was rejected at/handshake time), these describe why an already-established
|
||||
connection is being torn down by the server mid-session.
|
||||
|
||||
Codes:
|
||||
SLOW_CONSUMER: The client's outbound buffer exceeded the gateway's
|
||||
advertised ``max_buffered_bytes`` policy (a genuinely slow/stalled
|
||||
consumer). The server evicts it so its backlog cannot grow without
|
||||
bound or stall delivery to healthy clients.
|
||||
"""
|
||||
|
||||
SLOW_CONSUMER = "slow_consumer"
|
||||
|
||||
|
||||
class ConnectRecoveryStep(str, Enum):
|
||||
"""Machine-readable recovery hint for a connection rejection.
|
||||
|
||||
@@ -1010,6 +1028,105 @@ class OutboundDeliveryProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-route, trust-tiered toolset scoping (Issue #2298)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Conservative deny-list applied to ``trust: "untrusted"`` routes. Inbound
|
||||
# content from strangers / generic webhooks is the framework's largest
|
||||
# prompt-injection surface, so dangerous tool *families* are never advertised
|
||||
# to the model on these routes (shell, file mutation, delegation,
|
||||
# self-scheduling). Names are matched case-insensitively against substrings of
|
||||
# the tool name so deployments do not have to enumerate every concrete tool.
|
||||
UNTRUSTED_DENY_SUBSTRINGS: List[str] = [
|
||||
"shell",
|
||||
"exec",
|
||||
"command",
|
||||
"subprocess",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"delete_file",
|
||||
"rm_file",
|
||||
"delegate",
|
||||
"handoff",
|
||||
"cronjob",
|
||||
"schedule",
|
||||
]
|
||||
|
||||
# Trust tiers, ordered from least to most privileged.
|
||||
TRUST_TIERS: List[str] = ["untrusted", "standard", "trusted"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolPolicy:
|
||||
"""Declarative, per-route scope applied to an agent's tool surface.
|
||||
|
||||
Mirrors the scheduler's ``RunPolicy.filter_tools`` contract (wrapper layer)
|
||||
but lives in core so :class:`RouteBinding` can *declare* the scope without
|
||||
importing any heavy wrapper code. The wrapper inbound path applies it via a
|
||||
small apply/restore helper, exactly as the scheduler already does for
|
||||
unattended runs.
|
||||
|
||||
Attributes:
|
||||
allow_tools: If set, only tools whose name is in this set are kept;
|
||||
everything else is removed. ``None`` means "allow all except
|
||||
``deny_tools`` / the trust deny-list".
|
||||
deny_tools: Exact tool names removed before the run.
|
||||
deny_substrings: Case-insensitive substrings; a tool whose name
|
||||
contains any of them is removed (used by the ``untrusted`` tier).
|
||||
"""
|
||||
|
||||
allow_tools: Optional[Set[str]] = None
|
||||
deny_tools: Set[str] = field(default_factory=set)
|
||||
deny_substrings: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_noop(self) -> bool:
|
||||
"""``True`` when the policy would never remove any tool."""
|
||||
return (
|
||||
self.allow_tools is None
|
||||
and not self.deny_tools
|
||||
and not self.deny_substrings
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _tool_name(tool: Any) -> str:
|
||||
"""Best-effort name for a tool (matches Agent's own resolution)."""
|
||||
name = getattr(tool, "name", None)
|
||||
if isinstance(name, str) and name:
|
||||
return name
|
||||
dunder = getattr(tool, "__name__", None)
|
||||
if isinstance(dunder, str) and dunder:
|
||||
return dunder
|
||||
if isinstance(tool, dict):
|
||||
fn = tool.get("function")
|
||||
if isinstance(fn, dict) and isinstance(fn.get("name"), str):
|
||||
return fn["name"]
|
||||
top = tool.get("name")
|
||||
if isinstance(top, str) and top:
|
||||
return top
|
||||
return str(tool)
|
||||
|
||||
def is_tool_allowed(self, tool: Any) -> bool:
|
||||
"""Return ``True`` if ``tool`` may be exposed on this route."""
|
||||
name = self._tool_name(tool)
|
||||
if name in self.deny_tools:
|
||||
return False
|
||||
lowered = name.lower()
|
||||
for needle in self.deny_substrings:
|
||||
if needle and needle.lower() in lowered:
|
||||
return False
|
||||
if self.allow_tools is not None and name not in self.allow_tools:
|
||||
return False
|
||||
return True
|
||||
|
||||
def filter_tools(self, tools: Optional[List[Any]]) -> List[Any]:
|
||||
"""Return a copy of ``tools`` with denied/disallowed tools removed."""
|
||||
if not tools:
|
||||
return []
|
||||
return [tool for tool in tools if self.is_tool_allowed(tool)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inbound route binding (Issue #2225)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1034,6 +1151,13 @@ class RouteBinding:
|
||||
channel_id: Specific chat/channel id.
|
||||
account: Receiving bot account (for multi-account channels).
|
||||
priority: Higher wins; ties are broken by specificity then order.
|
||||
trust: Optional trust tier ("untrusted" | "standard" | "trusted").
|
||||
``untrusted`` advertises a conservative, read-only-leaning toolset
|
||||
to the model so dangerous tools are never offered on third-party /
|
||||
stranger / generic-webhook routes (Issue #2298). ``None`` /
|
||||
``standard`` / ``trusted`` apply no tier deny-list.
|
||||
allow_tools: If set, only these tool names are exposed on this route.
|
||||
deny_tools: Tool names removed before the run on this route.
|
||||
"""
|
||||
|
||||
agent: str
|
||||
@@ -1043,6 +1167,9 @@ class RouteBinding:
|
||||
channel_id: Optional[str] = None
|
||||
account: Optional[str] = None
|
||||
priority: int = 0
|
||||
trust: Optional[str] = None
|
||||
allow_tools: Optional[List[str]] = None
|
||||
deny_tools: Optional[List[str]] = None
|
||||
|
||||
# Specificity weights — exact peer beats role/channel beats account
|
||||
# beats chat-type. Higher means more specific.
|
||||
@@ -1054,6 +1181,24 @@ class RouteBinding:
|
||||
"chat_type": 2,
|
||||
}
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Normalise ``trust`` so config typos cannot silently fail open.
|
||||
|
||||
Whitespace/case variants of a known tier (e.g. ``" Untrusted "``) are
|
||||
canonicalised. Any *unknown* non-empty value is treated as the most
|
||||
restrictive tier (``untrusted``) rather than as "no policy", so a
|
||||
misconfigured route can never accidentally expose the full toolset.
|
||||
"""
|
||||
if self.trust is None:
|
||||
return
|
||||
normalized = str(self.trust).strip().lower()
|
||||
if not normalized:
|
||||
self.trust = None
|
||||
elif normalized in TRUST_TIERS:
|
||||
self.trust = normalized
|
||||
else:
|
||||
self.trust = "untrusted"
|
||||
|
||||
@property
|
||||
def specificity(self) -> int:
|
||||
"""Sum of weights for the conditions this binding constrains on."""
|
||||
@@ -1079,6 +1224,29 @@ class RouteBinding:
|
||||
return False
|
||||
return True
|
||||
|
||||
def tool_policy(self) -> Optional["ToolPolicy"]:
|
||||
"""Build the :class:`ToolPolicy` this binding declares, if any.
|
||||
|
||||
Returns ``None`` when the binding does not constrain the toolset, so
|
||||
callers can cheaply skip the apply/restore dance for trusted routes.
|
||||
The ``untrusted`` trust tier seeds a conservative substring deny-list;
|
||||
explicit ``allow_tools`` / ``deny_tools`` layer on top of it.
|
||||
"""
|
||||
deny_substrings: List[str] = []
|
||||
if self.trust == "untrusted":
|
||||
deny_substrings = list(UNTRUSTED_DENY_SUBSTRINGS)
|
||||
|
||||
allow = set(self.allow_tools) if self.allow_tools else None
|
||||
deny = set(self.deny_tools) if self.deny_tools else set()
|
||||
|
||||
if allow is None and not deny and not deny_substrings:
|
||||
return None
|
||||
return ToolPolicy(
|
||||
allow_tools=allow,
|
||||
deny_tools=deny,
|
||||
deny_substrings=deny_substrings,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "RouteBinding":
|
||||
"""Create a binding from a YAML/dict mapping.
|
||||
@@ -1094,6 +1262,9 @@ class RouteBinding:
|
||||
channel_id=_as_opt_str(data.get("channel_id")),
|
||||
account=_as_opt_str(data.get("account")),
|
||||
priority=int(data.get("priority", 0) or 0),
|
||||
trust=_as_opt_str(data.get("trust")),
|
||||
allow_tools=_as_opt_str_list(data.get("allow_tools")),
|
||||
deny_tools=_as_opt_str_list(data.get("deny_tools")),
|
||||
)
|
||||
|
||||
|
||||
@@ -1138,6 +1309,24 @@ def _as_opt_str(value: Any) -> Optional[str]:
|
||||
return str(value)
|
||||
|
||||
|
||||
def _as_opt_str_list(value: Any) -> Optional[List[str]]:
|
||||
"""Coerce a YAML scalar or sequence into ``Optional[List[str]]``.
|
||||
|
||||
Accepts a single string (wrapped into a one-element list) or any iterable
|
||||
of values; returns ``None`` for ``None``/empty so an absent key stays
|
||||
unconstrained.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return [value] if value else None
|
||||
try:
|
||||
items = [str(v) for v in value]
|
||||
except TypeError:
|
||||
return [str(value)]
|
||||
return items or None
|
||||
|
||||
|
||||
def resolve_route(
|
||||
bindings: List[RouteBinding],
|
||||
facts: RouteFacts,
|
||||
@@ -1692,6 +1881,156 @@ class SendPolicy:
|
||||
return SendDecision(allow=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway idle-dormancy / scale-to-zero (Issue #2332)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class IdleDecision:
|
||||
"""Result of an idle/quiesce evaluation.
|
||||
|
||||
Attributes:
|
||||
idle: Whether the gateway is currently quiescent.
|
||||
reason: Optional human-readable explanation (logged on quiesce).
|
||||
"""
|
||||
|
||||
idle: bool
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class GatewayIdlePolicyProtocol(Protocol):
|
||||
"""Protocol for gateway-process idle/scale-to-zero decisions.
|
||||
|
||||
Pure, import-free decision contract consumed by the wrapper's
|
||||
``BotOS`` run-loop. The wrapper supplies live facts (running turns,
|
||||
last inbound timestamp, background-work flag) and the policy decides
|
||||
whether the whole gateway may safely stand down. Concrete drivers
|
||||
(suspend the compute host, stand transports down, register a wake
|
||||
URL) live in the wrapper; this contract keeps the *decision* testable
|
||||
in isolation without a live gateway.
|
||||
|
||||
A config-driven default (:class:`ScaleToZeroPolicy`) is provided for
|
||||
the common "idle for N minutes with nothing in flight" case.
|
||||
"""
|
||||
|
||||
def should_arm(
|
||||
self,
|
||||
*,
|
||||
transports_quiescable: bool,
|
||||
wake_registered: bool,
|
||||
) -> bool:
|
||||
"""Return whether dormancy may be armed at all.
|
||||
|
||||
Implementations gate on whether transports can be cleanly stood
|
||||
down and a wake path exists, so a gateway never quiesces into a
|
||||
state it cannot resume from.
|
||||
"""
|
||||
...
|
||||
|
||||
def is_idle(
|
||||
self,
|
||||
*,
|
||||
running_turns: int,
|
||||
last_inbound_ts: float,
|
||||
has_background_work: bool,
|
||||
now: float,
|
||||
) -> IdleDecision:
|
||||
"""Return an :class:`IdleDecision` for the supplied facts."""
|
||||
...
|
||||
|
||||
|
||||
class ScaleToZeroPolicy:
|
||||
"""Config-driven idle policy for safe scale-to-zero.
|
||||
|
||||
The default referenced by ``scale_to_zero:`` blocks in ``gateway.yaml``
|
||||
and the ``BotOS(..., idle_policy=...)`` Python surface. It is
|
||||
intentionally minimal and dependency-free so the decision lives in
|
||||
core and is provable in isolation; the wrapper owns the side effects
|
||||
(suspend host, stand transports down, wake endpoint).
|
||||
|
||||
The gateway is considered idle only when *all* guards pass:
|
||||
|
||||
* no in-flight agent turn (``running_turns == 0``),
|
||||
* no live background work (scheduled jobs, delegated subagents,
|
||||
durable outbox drains — ``has_background_work`` is ``False``),
|
||||
* no inbound message for at least ``idle_timeout_minutes``.
|
||||
|
||||
Example::
|
||||
|
||||
ScaleToZeroPolicy(idle_timeout_minutes=5,
|
||||
wake_url="https://my-bot.fly.dev/_wake")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
idle_timeout_minutes: float = 5.0,
|
||||
wake_url: Optional[str] = None,
|
||||
enabled: bool = True,
|
||||
):
|
||||
if idle_timeout_minutes <= 0:
|
||||
raise ValueError(
|
||||
f"idle_timeout_minutes must be > 0, got {idle_timeout_minutes!r}"
|
||||
)
|
||||
self.idle_timeout_minutes = float(idle_timeout_minutes)
|
||||
self.wake_url = wake_url
|
||||
self.enabled = bool(enabled)
|
||||
|
||||
@property
|
||||
def idle_timeout_seconds(self) -> float:
|
||||
return self.idle_timeout_minutes * 60.0
|
||||
|
||||
def should_arm(
|
||||
self,
|
||||
*,
|
||||
transports_quiescable: bool,
|
||||
wake_registered: bool,
|
||||
) -> bool:
|
||||
if not self.enabled:
|
||||
return False
|
||||
# Never arm into a state we cannot resume from.
|
||||
return bool(transports_quiescable and wake_registered)
|
||||
|
||||
def is_idle(
|
||||
self,
|
||||
*,
|
||||
running_turns: int,
|
||||
last_inbound_ts: float,
|
||||
has_background_work: bool,
|
||||
now: float,
|
||||
) -> IdleDecision:
|
||||
if not self.enabled:
|
||||
return IdleDecision(idle=False, reason="scale_to_zero disabled")
|
||||
if running_turns > 0:
|
||||
return IdleDecision(
|
||||
idle=False,
|
||||
reason=f"{running_turns} agent turn(s) in flight",
|
||||
)
|
||||
if has_background_work:
|
||||
return IdleDecision(
|
||||
idle=False,
|
||||
reason="background work in progress",
|
||||
)
|
||||
elapsed = now - last_inbound_ts
|
||||
if elapsed < self.idle_timeout_seconds:
|
||||
remaining = self.idle_timeout_seconds - elapsed
|
||||
return IdleDecision(
|
||||
idle=False,
|
||||
reason=f"last inbound {elapsed:.0f}s ago; {remaining:.0f}s to idle",
|
||||
)
|
||||
return IdleDecision(
|
||||
idle=True,
|
||||
reason=f"idle for {elapsed:.0f}s with nothing in flight",
|
||||
)
|
||||
|
||||
|
||||
# Backward-compatible alias. The canonical name follows the repo's
|
||||
# ``*Protocol`` suffix convention (e.g. ``SendPolicyProtocol``); the old
|
||||
# name is retained so existing imports keep working.
|
||||
GatewayIdlePolicy = GatewayIdlePolicyProtocol
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol Version Negotiation (Issue #2130)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1516,46 +1516,6 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
return self._is_ollama_provider() and iteration_count == 0
|
||||
return False
|
||||
|
||||
def _try_parse_tool_call_json(self, response_text: str, iteration_count: int) -> tuple:
|
||||
"""
|
||||
Try to parse tool call from JSON response text.
|
||||
|
||||
Returns:
|
||||
tuple: (tool_calls list or None, parse_error string or None)
|
||||
"""
|
||||
if not response_text or not response_text.strip():
|
||||
return None, None
|
||||
|
||||
try:
|
||||
response_json = json.loads(response_text.strip())
|
||||
if isinstance(response_json, dict) and "name" in response_json:
|
||||
tool_calls = [{
|
||||
"id": f"tool_{iteration_count}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": response_json["name"],
|
||||
"arguments": json.dumps(response_json.get("arguments", {}))
|
||||
}
|
||||
}]
|
||||
return tool_calls, None
|
||||
elif isinstance(response_json, list):
|
||||
tool_calls = []
|
||||
for idx, tool_json in enumerate(response_json):
|
||||
if isinstance(tool_json, dict) and "name" in tool_json:
|
||||
tool_calls.append({
|
||||
"id": f"tool_{iteration_count}_{idx}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_json["name"],
|
||||
"arguments": json.dumps(tool_json.get("arguments", {}))
|
||||
}
|
||||
})
|
||||
return tool_calls if tool_calls else None, None
|
||||
else:
|
||||
return None, "Response is not a valid tool call format (missing 'name' field)"
|
||||
except json.JSONDecodeError as e:
|
||||
return None, f"JSON parse error: {str(e)}"
|
||||
|
||||
def _validate_tool_call(self, tool_call: Dict, formatted_tools: Optional[List]) -> Optional[str]:
|
||||
"""
|
||||
Validate a tool call against available tools.
|
||||
@@ -5574,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:
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
)
|
||||
@@ -25,6 +25,22 @@ _DIAGNOSTICS_MAX_CHARS = 2000
|
||||
# Seconds before a diagnostics subprocess is abandoned.
|
||||
_DIAGNOSTICS_TIMEOUT = 10
|
||||
|
||||
# File extension -> LSP language id, used to pick a language server for the
|
||||
# edited file. Mirrors the languages with a default server in ``lsp/config.py``
|
||||
# (DEFAULT_SERVERS); anything not listed falls back to the per-language checker.
|
||||
_LSP_LANGUAGE_BY_EXT = {
|
||||
".py": "python",
|
||||
".pyi": "python",
|
||||
".js": "javascript",
|
||||
".jsx": "javascript",
|
||||
".mjs": "javascript",
|
||||
".cjs": "javascript",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescript",
|
||||
".rs": "rust",
|
||||
".go": "go",
|
||||
}
|
||||
|
||||
|
||||
class EditTools:
|
||||
"""Tools for file editing and patching operations."""
|
||||
@@ -213,15 +229,161 @@ class EditTools:
|
||||
return tool_name, argv
|
||||
return None
|
||||
|
||||
def _run_diagnostics(self, safe_path: str, display_path: str) -> str:
|
||||
"""Run a checker on ``safe_path`` and return a bounded diagnostics block.
|
||||
@staticmethod
|
||||
def _bound_output(output: str) -> str:
|
||||
"""Truncate ``output`` to the shared diagnostics character budget."""
|
||||
if len(output) > _DIAGNOSTICS_MAX_CHARS:
|
||||
return output[:_DIAGNOSTICS_MAX_CHARS] + "\n... (diagnostics truncated)"
|
||||
return output
|
||||
|
||||
Returns an empty string when diagnostics are disabled, no checker is
|
||||
available, or (in ``auto`` mode) the checker reports no problems. Any
|
||||
exception is logged and swallowed so the edit result is never lost.
|
||||
def _try_lsp_diagnostics(self, safe_path: str, display_path: str) -> Optional[str]:
|
||||
"""Query the existing LSP client for diagnostics on ``safe_path``.
|
||||
|
||||
Returns a formatted, bounded diagnostics block (matching the shape used
|
||||
by the per-language checker) when a language server is available for the
|
||||
file's language, ``None`` when no server is configured/installed so the
|
||||
caller can fall back to the legacy checker. Everything is lazy: the LSP
|
||||
client is only imported and spawned when a server actually exists, so
|
||||
Python-only/silent runs pay no startup cost. Any failure returns
|
||||
``None`` so a missing/slow server never blocks a successful edit.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
ext = os.path.splitext(safe_path)[1].lower()
|
||||
language = _LSP_LANGUAGE_BY_EXT.get(ext)
|
||||
if language is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
from ..lsp.config import DEFAULT_SERVERS
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
server = DEFAULT_SERVERS.get(language)
|
||||
if not server:
|
||||
return None
|
||||
# Only spawn a server whose command is actually installed; otherwise
|
||||
# fall back to the zero-config checker without importing the client.
|
||||
if not shutil.which(server["command"]):
|
||||
return None
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
from ..lsp.client import LSPClient
|
||||
from ..lsp.types import DiagnosticSeverity
|
||||
except Exception as e:
|
||||
logger.debug("LSP diagnostics unavailable for %s: %s", display_path, e)
|
||||
return None
|
||||
|
||||
async def _collect() -> Optional[list]:
|
||||
client = LSPClient(language=language)
|
||||
# Keep the whole exchange inside the shared diagnostics budget so a
|
||||
# slow server cannot stall the edit result.
|
||||
client.config.timeout = min(client.config.timeout, _DIAGNOSTICS_TIMEOUT)
|
||||
try:
|
||||
if not await client.start():
|
||||
return None
|
||||
if not await client.open_document(safe_path):
|
||||
return None
|
||||
# Diagnostics arrive asynchronously via publishDiagnostics; poll
|
||||
# briefly within the timeout budget for the server to report.
|
||||
# ``get_diagnostics`` returns ``[]`` both before any publish and
|
||||
# after a clean publish, so also watch the client's diagnostics
|
||||
# store keyed by URI to tell "not reported yet" from "reported,
|
||||
# no problems" — that lets a clean file return promptly instead
|
||||
# of sleeping the full timeout, and distinguishes a real
|
||||
# all-clear (``[]``) from "never published" (``None``) so the
|
||||
# caller can fall back to the legacy checker only in the latter.
|
||||
loop = asyncio.get_event_loop()
|
||||
uri = f"file://{os.path.abspath(safe_path)}"
|
||||
published = getattr(client, "_diagnostics", None)
|
||||
deadline = loop.time() + _DIAGNOSTICS_TIMEOUT
|
||||
diags: Optional[list] = None
|
||||
while loop.time() < deadline:
|
||||
diags = await client.get_diagnostics(safe_path)
|
||||
if diags or (isinstance(published, dict) and uri in published):
|
||||
break
|
||||
diags = None
|
||||
await asyncio.sleep(0.2)
|
||||
return diags
|
||||
finally:
|
||||
try:
|
||||
await client.close_document(safe_path)
|
||||
except Exception:
|
||||
pass
|
||||
await client.stop()
|
||||
|
||||
try:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
running = False
|
||||
else:
|
||||
running = True
|
||||
if running:
|
||||
# An event loop is already running (e.g. async agent context);
|
||||
# don't risk re-entrancy — defer to the checker fallback.
|
||||
return None
|
||||
diagnostics = asyncio.run(
|
||||
asyncio.wait_for(_collect(), timeout=_DIAGNOSTICS_TIMEOUT + 5)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("LSP diagnostics error for %s: %s", display_path, e)
|
||||
return None
|
||||
|
||||
if diagnostics is None:
|
||||
return None
|
||||
|
||||
if not diagnostics:
|
||||
if self._post_edit_diagnostics == "on":
|
||||
return f"\n\nDiagnostics (lsp:{language}): no problems found"
|
||||
return ""
|
||||
|
||||
_severity_label = {
|
||||
DiagnosticSeverity.ERROR: "error",
|
||||
DiagnosticSeverity.WARNING: "warning",
|
||||
DiagnosticSeverity.INFORMATION: "info",
|
||||
DiagnosticSeverity.HINT: "hint",
|
||||
}
|
||||
lines = []
|
||||
for d in diagnostics:
|
||||
try:
|
||||
line = d.range.start.line + 1
|
||||
char = d.range.start.character + 1
|
||||
label = _severity_label.get(d.severity, "error")
|
||||
message = " ".join((d.message or "").split())
|
||||
lines.append(f"{display_path}:{line}:{char}: {label}: {message}")
|
||||
except Exception:
|
||||
continue
|
||||
if not lines:
|
||||
if self._post_edit_diagnostics == "on":
|
||||
return f"\n\nDiagnostics (lsp:{language}): no problems found"
|
||||
return ""
|
||||
|
||||
output = self._bound_output("\n".join(lines))
|
||||
return f"\n\nDiagnostics (lsp:{language}):\n{output}"
|
||||
|
||||
def _run_diagnostics(self, safe_path: str, display_path: str) -> str:
|
||||
"""Run diagnostics on ``safe_path`` and return a bounded diagnostics block.
|
||||
|
||||
Tries the wired-in LSP client first (for any language the user has a
|
||||
language server installed for) and falls back to the zero-config
|
||||
per-language checker when no server is available. Returns an empty
|
||||
string when diagnostics are disabled, no checker is available, or (in
|
||||
``auto`` mode) no problems are found. Any exception is logged and
|
||||
swallowed so the edit result is never lost.
|
||||
"""
|
||||
if self._post_edit_diagnostics == "off":
|
||||
return ""
|
||||
# Prefer the existing LSP client when a language server is available so
|
||||
# multi-language edits get language-aware diagnostics; ``None`` means no
|
||||
# server, so we fall through to the legacy checker below.
|
||||
try:
|
||||
lsp_block = self._try_lsp_diagnostics(safe_path, display_path)
|
||||
if lsp_block is not None:
|
||||
return lsp_block
|
||||
except Exception as e: # never let LSP wiring break a successful edit
|
||||
logger.debug("LSP diagnostics skipped for %s: %s", display_path, e)
|
||||
try:
|
||||
resolved = self._diagnostics_command(safe_path)
|
||||
if resolved is None:
|
||||
@@ -255,8 +417,7 @@ class EditTools:
|
||||
|
||||
if not output:
|
||||
output = f"check failed (exit code {proc.returncode})"
|
||||
if len(output) > _DIAGNOSTICS_MAX_CHARS:
|
||||
output = output[:_DIAGNOSTICS_MAX_CHARS] + "\n... (diagnostics truncated)"
|
||||
output = self._bound_output(output)
|
||||
return f"\n\nDiagnostics ({tool_name}):\n{output}"
|
||||
except Exception as e: # never let diagnostics break a successful edit
|
||||
logger.debug("Diagnostics error for %s: %s", display_path, e)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Path containment helpers for tool and context safety."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def resolve_within_root(path: str, root: Optional[str] = None) -> Optional[str]:
|
||||
"""Resolve *path* under *root* and reject traversal outside the root."""
|
||||
base = os.path.realpath(root or os.getcwd())
|
||||
expanded = os.path.expanduser(path)
|
||||
candidate = (
|
||||
os.path.join(base, expanded)
|
||||
if not os.path.isabs(expanded)
|
||||
else expanded
|
||||
)
|
||||
resolved = os.path.realpath(os.path.normpath(candidate))
|
||||
try:
|
||||
if os.path.commonpath([resolved, base]) != base:
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
return resolved
|
||||
@@ -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}"
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -888,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)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.76"
|
||||
version = "1.6.80"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -334,6 +334,29 @@ class TestKnowledgePresets:
|
||||
)
|
||||
assert isinstance(result, MockKnowledgeConfig)
|
||||
assert "docs/" in result.sources
|
||||
|
||||
def test_knowledge_windows_path_string(self):
|
||||
"""Windows-style directory paths should be treated as sources."""
|
||||
from praisonaiagents.config.param_resolver import resolve
|
||||
|
||||
result = resolve(
|
||||
value="C:\\knowledge",
|
||||
param_name="knowledge",
|
||||
config_class=MockKnowledgeConfig,
|
||||
string_mode="path_as_source",
|
||||
)
|
||||
assert isinstance(result, MockKnowledgeConfig)
|
||||
assert result.sources == ["C:\\knowledge"]
|
||||
|
||||
for windows_path in ("\\knowledge", "~\\knowledge", "knowledge\\docs"):
|
||||
result = resolve(
|
||||
value=windows_path,
|
||||
param_name="knowledge",
|
||||
config_class=MockKnowledgeConfig,
|
||||
string_mode="path_as_source",
|
||||
)
|
||||
assert isinstance(result, MockKnowledgeConfig)
|
||||
assert result.sources == [windows_path]
|
||||
|
||||
def test_knowledge_array_sources(self):
|
||||
"""Array of paths should be sources list."""
|
||||
|
||||
@@ -478,3 +478,176 @@ class TestNoCortexParam:
|
||||
|
||||
spec = importlib.util.find_spec("praisonaiagents.cortex")
|
||||
assert spec is None, "praisonaiagents.cortex module should not exist"
|
||||
|
||||
|
||||
class TestUsageTelemetry:
|
||||
"""Test use_count/last_used usage telemetry on retrieval."""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.store_path = os.path.join(self.temp_dir, "persona.json")
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_new_entry_defaults(self):
|
||||
"""New entries start with use_count 0 and no last_used."""
|
||||
store = PersonaStore(store_path=self.store_path, user_id="u")
|
||||
entry = store.add("User likes Python")
|
||||
assert entry.use_count == 0
|
||||
assert entry.last_used is None
|
||||
|
||||
def test_search_bumps_use_count(self):
|
||||
"""search() records access on returned entries."""
|
||||
store = PersonaStore(store_path=self.store_path, user_id="u")
|
||||
store.add("User likes Python")
|
||||
results = store.search("Python")
|
||||
assert len(results) == 1
|
||||
assert results[0].use_count == 1
|
||||
assert results[0].last_used is not None
|
||||
|
||||
def test_list_all_bumps_use_count(self):
|
||||
"""list_all() records access on returned entries."""
|
||||
store = PersonaStore(store_path=self.store_path, user_id="u")
|
||||
store.add("User likes Python")
|
||||
store.list_all()
|
||||
store.list_all()
|
||||
entry = list(store._entries.values())[0]
|
||||
assert entry.use_count == 2
|
||||
|
||||
def test_telemetry_persisted(self):
|
||||
"""Usage telemetry survives reload."""
|
||||
store = PersonaStore(store_path=self.store_path, user_id="u")
|
||||
store.add("User likes Python")
|
||||
store.search("Python")
|
||||
reloaded = PersonaStore(store_path=self.store_path, user_id="u")
|
||||
entry = list(reloaded._entries.values())[0]
|
||||
assert entry.use_count == 1
|
||||
assert entry.last_used is not None
|
||||
|
||||
def test_entry_roundtrip_preserves_telemetry(self):
|
||||
"""LearnEntry to_dict/from_dict preserves telemetry fields."""
|
||||
entry = LearnEntry(id="x", content="c", use_count=3, last_used="2025-01-01T00:00:00")
|
||||
restored = LearnEntry.from_dict(entry.to_dict())
|
||||
assert restored.use_count == 3
|
||||
assert restored.last_used == "2025-01-01T00:00:00"
|
||||
|
||||
|
||||
class TestBoundedRetention:
|
||||
"""Test max_entries / retention_days pruning and archival."""
|
||||
|
||||
def setup_method(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.store_path = os.path.join(self.temp_dir, "insights.json")
|
||||
|
||||
def teardown_method(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_unbounded_by_default(self):
|
||||
"""No limits configured -> behaviour unchanged, prune is a no-op."""
|
||||
store = InsightStore(store_path=self.store_path, user_id="u")
|
||||
for i in range(20):
|
||||
store.add(f"insight {i}")
|
||||
assert len(store._entries) == 20
|
||||
assert store.prune() == 0
|
||||
|
||||
def test_max_entries_evicts_on_add(self):
|
||||
"""Exceeding max_entries archives least-used/oldest on write."""
|
||||
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=5)
|
||||
for i in range(10):
|
||||
store.add(f"insight {i}")
|
||||
assert len(store._entries) == 5
|
||||
|
||||
def test_max_entries_keeps_most_used(self):
|
||||
"""Least-used entries are evicted first."""
|
||||
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=3)
|
||||
store.add("keep me")
|
||||
for i in range(2):
|
||||
store.add(f"filler {i}")
|
||||
# Bump usage on the entry we want to keep via the public retrieval path
|
||||
for _ in range(5):
|
||||
assert store.search("keep me")
|
||||
for i in range(5):
|
||||
store.add(f"new {i}")
|
||||
contents = [e.content for e in store._entries.values()]
|
||||
assert "keep me" in contents
|
||||
assert len(store._entries) == 3
|
||||
|
||||
def test_new_entry_not_evicted_when_full(self):
|
||||
"""A freshly added entry is never the one pruned away on write."""
|
||||
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=3)
|
||||
for i in range(3):
|
||||
store.add(f"old {i}")
|
||||
# Give existing entries usage so the newcomer is the least-used
|
||||
for _ in range(3):
|
||||
store.search("old")
|
||||
new = store.add("brand new")
|
||||
assert store.get(new.id) is not None
|
||||
contents = [e.content for e in store._entries.values()]
|
||||
assert "brand new" in contents
|
||||
assert len(store._entries) == 3
|
||||
|
||||
def test_archive_failure_restores_entries(self):
|
||||
"""If archival cannot persist, pruned entries stay in the active store."""
|
||||
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=2)
|
||||
for i in range(5):
|
||||
store.add(f"insight {i}")
|
||||
# Force the archive write to fail; entries must not be lost
|
||||
store._archive = lambda entries: False
|
||||
before = dict(store._entries)
|
||||
store.add("trigger prune")
|
||||
# No entries dropped beyond the new add (archival failed -> restore)
|
||||
assert set(before).issubset(set(store._entries))
|
||||
|
||||
def test_archive_is_recoverable(self):
|
||||
"""Evicted entries are written to a recoverable archive file."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=2)
|
||||
for i in range(6):
|
||||
store.add(f"insight {i}")
|
||||
archive = Path(self.store_path).with_suffix(".archive.json")
|
||||
assert archive.exists()
|
||||
with open(archive) as f:
|
||||
archived = json.load(f)
|
||||
assert len(archived) == 4
|
||||
|
||||
def test_retention_days_archives_stale(self):
|
||||
"""Entries unused beyond retention_days are archived by prune()."""
|
||||
from datetime import datetime, timedelta
|
||||
store = InsightStore(store_path=self.store_path, user_id="u", retention_days=30)
|
||||
store.add("fresh")
|
||||
stale = store.add("stale")
|
||||
old = (datetime.utcnow() - timedelta(days=60)).isoformat()
|
||||
stale.last_used = old
|
||||
stale.updated_at = old
|
||||
stale.created_at = old
|
||||
store._save()
|
||||
pruned = store.prune()
|
||||
assert pruned == 1
|
||||
contents = [e.content for e in store._entries.values()]
|
||||
assert "fresh" in contents
|
||||
assert "stale" not in contents
|
||||
|
||||
def test_manager_prune_propagates_config(self):
|
||||
"""LearnManager.prune() runs across stores with config-driven limits."""
|
||||
from datetime import datetime, timedelta
|
||||
config = LearnConfig(persona=True, insights=False, thread=False, retention_days=30)
|
||||
manager = LearnManager(config=config, user_id="u", store_path=self.temp_dir)
|
||||
manager.capture_persona("fresh")
|
||||
stale = manager.capture_persona("stale")
|
||||
old = (datetime.utcnow() - timedelta(days=60)).isoformat()
|
||||
stale.last_used = old
|
||||
stale.updated_at = old
|
||||
stale.created_at = old
|
||||
manager._stores["persona"]._save()
|
||||
result = manager.prune()
|
||||
assert result["persona"] == 1
|
||||
assert manager.get_stats()["persona"] == 1
|
||||
|
||||
def test_config_to_dict_has_retention(self):
|
||||
"""LearnConfig exposes retention fields via to_dict."""
|
||||
config = LearnConfig(max_entries=100, retention_days=90)
|
||||
d = config.to_dict()
|
||||
assert d["max_entries"] == 100
|
||||
assert d["retention_days"] == 90
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Tests for agent-authored interactive replies (presentation emit + reply route).
|
||||
|
||||
Covers the core seam added so an agent can emit a portable MessagePresentation
|
||||
as a normal reply and have a button/select click routed back into the next turn.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from praisonaiagents.bots import (
|
||||
MessagePresentation,
|
||||
PresentationBlock,
|
||||
PresentationButton,
|
||||
PresentationAction,
|
||||
PresentationLimits,
|
||||
ActionType,
|
||||
BlockType,
|
||||
AgentReply,
|
||||
extract_presentation,
|
||||
adapt_presentation,
|
||||
encode_action,
|
||||
decode_callback,
|
||||
create_registry,
|
||||
make_reply_handler,
|
||||
InteractiveContext,
|
||||
REPLY_NAMESPACE,
|
||||
)
|
||||
|
||||
|
||||
def test_reply_action_factory():
|
||||
action = PresentationAction.reply("env=staging")
|
||||
assert action.type == ActionType.REPLY
|
||||
assert action.value == "env=staging"
|
||||
|
||||
|
||||
def test_block_make_helpers():
|
||||
assert PresentationBlock.make_text("hi").type == BlockType.TEXT
|
||||
assert PresentationBlock.make_context("note").type == BlockType.CONTEXT
|
||||
assert PresentationBlock.make_buttons([]).type == BlockType.BUTTONS
|
||||
|
||||
|
||||
def test_quick_replies_builds_reply_buttons():
|
||||
block = PresentationBlock.quick_replies([("Staging", "env=staging"), "Prod"])
|
||||
assert block.type == BlockType.BUTTONS
|
||||
assert block.buttons[0].action.type == ActionType.REPLY
|
||||
assert block.buttons[0].action.value == "env=staging"
|
||||
# Plain string choice uses itself as both label and value.
|
||||
assert block.buttons[1].label == "Prod"
|
||||
assert block.buttons[1].action.value == "Prod"
|
||||
|
||||
|
||||
def test_encode_reply_action():
|
||||
enc = encode_action("ignored", PresentationAction.reply("pick=a"))
|
||||
assert enc == "reply:pick=a"
|
||||
namespace, payload = decode_callback(enc)
|
||||
assert namespace == REPLY_NAMESPACE
|
||||
assert payload["value"] == "pick=a"
|
||||
|
||||
|
||||
def test_adapt_degrades_reply_to_callback():
|
||||
p = MessagePresentation([
|
||||
PresentationBlock.make_buttons([
|
||||
PresentationButton(label="Staging", action=PresentationAction.reply("env=staging")),
|
||||
])
|
||||
])
|
||||
adapted = adapt_presentation(p, PresentationLimits.telegram())
|
||||
btn = adapted.blocks[0].buttons[0]
|
||||
# Renderers only know command/callback/url/web_app; reply degrades to callback.
|
||||
assert btn.action.type == ActionType.CALLBACK
|
||||
assert btn.action.value == "reply:env=staging"
|
||||
|
||||
|
||||
def test_adapt_reply_callback_respects_length_cap():
|
||||
long_value = "x" * 200
|
||||
p = MessagePresentation([
|
||||
PresentationBlock.make_buttons([
|
||||
PresentationButton(label="L", action=PresentationAction.reply(long_value)),
|
||||
])
|
||||
])
|
||||
adapted = adapt_presentation(p, PresentationLimits.telegram())
|
||||
val = adapted.blocks[0].buttons[0].action.value
|
||||
# Cap is a byte limit; an over-cap value becomes a collision-resistant hash
|
||||
# marked with ``#`` (not a lossy prefix) so distinct choices stay distinct.
|
||||
assert len(val.encode("utf-8")) <= 64
|
||||
assert val.startswith("reply:#")
|
||||
|
||||
|
||||
def test_adapt_long_replies_do_not_collide():
|
||||
# Two distinct long values that share a prefix must NOT collapse to the same
|
||||
# callback payload (the prefix-truncation bug). Hashing keeps them distinct.
|
||||
a = "shared-prefix-" + "a" * 200
|
||||
b = "shared-prefix-" + "b" * 200
|
||||
p = MessagePresentation([
|
||||
PresentationBlock.make_buttons([
|
||||
PresentationButton(label="A", action=PresentationAction.reply(a)),
|
||||
PresentationButton(label="B", action=PresentationAction.reply(b)),
|
||||
])
|
||||
])
|
||||
adapted = adapt_presentation(p, PresentationLimits.telegram())
|
||||
va = adapted.blocks[0].buttons[0].action.value
|
||||
vb = adapted.blocks[0].buttons[1].action.value
|
||||
assert va != vb
|
||||
|
||||
|
||||
def test_adapt_reply_callback_byte_cap_with_non_ascii():
|
||||
# Non-ASCII values can stay under 64 *chars* yet exceed 64 *bytes*; the
|
||||
# cap must be enforced in UTF-8 bytes.
|
||||
value = "é" * 40 # 40 chars, 80 bytes
|
||||
p = MessagePresentation([
|
||||
PresentationBlock.make_buttons([
|
||||
PresentationButton(label="L", action=PresentationAction.reply(value)),
|
||||
])
|
||||
])
|
||||
adapted = adapt_presentation(p, PresentationLimits.telegram())
|
||||
val = adapted.blocks[0].buttons[0].action.value
|
||||
assert len(val.encode("utf-8")) <= 64
|
||||
# Over-cap -> hashed marker form, which is pure ASCII (no split codepoint).
|
||||
assert val.startswith("reply:#")
|
||||
|
||||
|
||||
def test_reply_handler_declines_hashed_value():
|
||||
# A hashed (non-routable) reply value must NOT be fed into the turn — the
|
||||
# agent never authored the digest. The handler declines instead.
|
||||
called = {"hit": False}
|
||||
|
||||
async def continue_turn(value, context):
|
||||
called["hit"] = True
|
||||
return "should not run"
|
||||
|
||||
registry = create_registry()
|
||||
registry.register(REPLY_NAMESPACE, make_reply_handler(continue_turn))
|
||||
ctx = InteractiveContext(callback_data="reply:#deadbeefdeadbeef", user_id="u1")
|
||||
handled = asyncio.run(registry.dispatch(ctx))
|
||||
assert handled is False
|
||||
assert called["hit"] is False
|
||||
|
||||
|
||||
def test_encode_empty_string_reply_preserved():
|
||||
# PresentationAction.reply("") is a valid choice; the encoder must keep the
|
||||
# reserved reply: prefix instead of falling back to the caller namespace.
|
||||
enc = encode_action("ns", PresentationAction.reply(""))
|
||||
assert enc == "reply:"
|
||||
namespace, payload = decode_callback(enc)
|
||||
assert namespace == REPLY_NAMESPACE
|
||||
assert payload["value"] == ""
|
||||
|
||||
|
||||
def test_extract_presentation_plain_str():
|
||||
text, pres = extract_presentation("hello")
|
||||
assert text == "hello"
|
||||
assert pres is None
|
||||
|
||||
|
||||
def test_extract_presentation_from_presentation():
|
||||
p = MessagePresentation([
|
||||
PresentationBlock.make_text("Which environment?"),
|
||||
PresentationBlock.quick_replies([("Staging", "staging")]),
|
||||
])
|
||||
text, pres = extract_presentation(p)
|
||||
assert pres is p
|
||||
assert "Which environment?" in text
|
||||
|
||||
|
||||
def test_extract_presentation_from_agent_reply():
|
||||
p = MessagePresentation([PresentationBlock.quick_replies([("A", "a")])])
|
||||
reply = AgentReply(text="Pick one", presentation=p)
|
||||
text, pres = extract_presentation(reply)
|
||||
assert text == "Pick one"
|
||||
assert pres is p
|
||||
|
||||
|
||||
def test_extract_presentation_from_dict():
|
||||
p = MessagePresentation([PresentationBlock.make_text("hi")])
|
||||
data = AgentReply(text="t", presentation=p).to_dict()
|
||||
text, pres = extract_presentation(data)
|
||||
assert text == "t"
|
||||
assert isinstance(pres, MessagePresentation)
|
||||
assert pres.blocks[0].text == "hi"
|
||||
|
||||
|
||||
def test_agent_reply_roundtrip():
|
||||
p = MessagePresentation([PresentationBlock.quick_replies([("A", "a")])])
|
||||
reply = AgentReply(text="hi", presentation=p)
|
||||
restored = AgentReply.from_dict(reply.to_dict())
|
||||
assert restored.text == "hi"
|
||||
assert restored.presentation.blocks[0].buttons[0].action.value == "a"
|
||||
|
||||
|
||||
def test_reply_handler_routes_value_back_into_turn():
|
||||
seen = {}
|
||||
|
||||
async def continue_turn(value, context):
|
||||
seen["value"] = value
|
||||
seen["user"] = context.user_id
|
||||
return f"ran with {value}"
|
||||
|
||||
registry = create_registry()
|
||||
registry.register(REPLY_NAMESPACE, make_reply_handler(continue_turn))
|
||||
|
||||
ctx = InteractiveContext(callback_data="reply:env=prod", user_id="u1")
|
||||
handled = asyncio.run(registry.dispatch(ctx))
|
||||
|
||||
assert handled is True
|
||||
assert seen["value"] == "env=prod"
|
||||
assert seen["user"] == "u1"
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Unit tests for gateway idle-dormancy / scale-to-zero (Issue #2332).
|
||||
|
||||
Covers the pure, core-side decision predicates of ScaleToZeroPolicy and
|
||||
conformance with the GatewayIdlePolicy protocol.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.gateway import (
|
||||
GatewayIdlePolicy,
|
||||
GatewayIdlePolicyProtocol,
|
||||
IdleDecision,
|
||||
ScaleToZeroPolicy,
|
||||
)
|
||||
|
||||
|
||||
def test_protocol_conformance():
|
||||
policy = ScaleToZeroPolicy(idle_timeout_minutes=5)
|
||||
assert isinstance(policy, GatewayIdlePolicyProtocol)
|
||||
|
||||
|
||||
def test_backward_compat_alias():
|
||||
# The old name remains importable and points at the canonical protocol.
|
||||
assert GatewayIdlePolicy is GatewayIdlePolicyProtocol
|
||||
assert isinstance(ScaleToZeroPolicy(idle_timeout_minutes=5), GatewayIdlePolicy)
|
||||
|
||||
|
||||
def test_invalid_timeout_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ScaleToZeroPolicy(idle_timeout_minutes=0)
|
||||
with pytest.raises(ValueError):
|
||||
ScaleToZeroPolicy(idle_timeout_minutes=-1)
|
||||
|
||||
|
||||
def test_idle_timeout_seconds():
|
||||
assert ScaleToZeroPolicy(idle_timeout_minutes=5).idle_timeout_seconds == 300.0
|
||||
|
||||
|
||||
def test_idle_when_quiescent_past_timeout():
|
||||
policy = ScaleToZeroPolicy(idle_timeout_minutes=5)
|
||||
decision = policy.is_idle(
|
||||
running_turns=0,
|
||||
last_inbound_ts=0.0,
|
||||
has_background_work=False,
|
||||
now=301.0,
|
||||
)
|
||||
assert isinstance(decision, IdleDecision)
|
||||
assert decision.idle is True
|
||||
|
||||
|
||||
def test_not_idle_before_timeout():
|
||||
policy = ScaleToZeroPolicy(idle_timeout_minutes=5)
|
||||
decision = policy.is_idle(
|
||||
running_turns=0,
|
||||
last_inbound_ts=0.0,
|
||||
has_background_work=False,
|
||||
now=120.0,
|
||||
)
|
||||
assert decision.idle is False
|
||||
assert "to idle" in decision.reason
|
||||
|
||||
|
||||
def test_in_flight_turn_blocks_dormancy():
|
||||
policy = ScaleToZeroPolicy(idle_timeout_minutes=5)
|
||||
decision = policy.is_idle(
|
||||
running_turns=1,
|
||||
last_inbound_ts=0.0,
|
||||
has_background_work=False,
|
||||
now=10_000.0,
|
||||
)
|
||||
assert decision.idle is False
|
||||
assert "in flight" in decision.reason
|
||||
|
||||
|
||||
def test_background_work_blocks_dormancy():
|
||||
policy = ScaleToZeroPolicy(idle_timeout_minutes=5)
|
||||
decision = policy.is_idle(
|
||||
running_turns=0,
|
||||
last_inbound_ts=0.0,
|
||||
has_background_work=True,
|
||||
now=10_000.0,
|
||||
)
|
||||
assert decision.idle is False
|
||||
assert "background" in decision.reason
|
||||
|
||||
|
||||
def test_disabled_policy_never_idle():
|
||||
policy = ScaleToZeroPolicy(idle_timeout_minutes=5, enabled=False)
|
||||
decision = policy.is_idle(
|
||||
running_turns=0,
|
||||
last_inbound_ts=0.0,
|
||||
has_background_work=False,
|
||||
now=10_000.0,
|
||||
)
|
||||
assert decision.idle is False
|
||||
|
||||
|
||||
def test_should_arm_requires_wake_and_quiescable():
|
||||
policy = ScaleToZeroPolicy(idle_timeout_minutes=5, wake_url="https://x/_wake")
|
||||
assert policy.should_arm(transports_quiescable=True, wake_registered=True) is True
|
||||
assert policy.should_arm(transports_quiescable=False, wake_registered=True) is False
|
||||
assert policy.should_arm(transports_quiescable=True, wake_registered=False) is False
|
||||
|
||||
|
||||
def test_disabled_policy_never_arms():
|
||||
policy = ScaleToZeroPolicy(idle_timeout_minutes=5, enabled=False)
|
||||
assert policy.should_arm(transports_quiescable=True, wake_registered=True) is False
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for actor-authorization enforcement in the interactive registry."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.bots.interactive import (
|
||||
InteractiveContext,
|
||||
InteractiveRegistry,
|
||||
)
|
||||
|
||||
|
||||
def _ctx(user_id: str, callback_data: str = "approval:1a2b3c:allow") -> InteractiveContext:
|
||||
return InteractiveContext(callback_data=callback_data, user_id=user_id)
|
||||
|
||||
|
||||
def test_dispatch_without_authorizer_allows_any_actor():
|
||||
registry = InteractiveRegistry()
|
||||
seen = []
|
||||
|
||||
async def handler(ctx):
|
||||
seen.append(ctx.user_id)
|
||||
return "ok"
|
||||
|
||||
registry.register("approval", handler)
|
||||
handled = asyncio.run(registry.dispatch(_ctx("999")))
|
||||
|
||||
assert handled is True
|
||||
assert seen == ["999"]
|
||||
|
||||
|
||||
def test_dispatch_rejects_unauthorized_actor():
|
||||
registry = InteractiveRegistry()
|
||||
allowed = {"requester", "admin"}
|
||||
seen = []
|
||||
|
||||
async def handler(ctx):
|
||||
seen.append(ctx.user_id)
|
||||
return "ok"
|
||||
|
||||
registry.register(
|
||||
"approval", handler, authorize=lambda ctx: ctx.user_id in allowed
|
||||
)
|
||||
|
||||
handled = asyncio.run(registry.dispatch(_ctx("999")))
|
||||
|
||||
assert handled is False
|
||||
assert seen == [] # handler must never run for unauthorized actor
|
||||
|
||||
|
||||
def test_dispatch_allows_authorized_actor():
|
||||
registry = InteractiveRegistry()
|
||||
allowed = {"requester", "admin"}
|
||||
seen = []
|
||||
|
||||
async def handler(ctx):
|
||||
seen.append(ctx.user_id)
|
||||
return "ok"
|
||||
|
||||
registry.register(
|
||||
"approval", handler, authorize=lambda ctx: ctx.user_id in allowed
|
||||
)
|
||||
|
||||
handled = asyncio.run(registry.dispatch(_ctx("admin")))
|
||||
|
||||
assert handled is True
|
||||
assert seen == ["admin"]
|
||||
|
||||
|
||||
def test_authorizer_exception_denies():
|
||||
registry = InteractiveRegistry()
|
||||
seen = []
|
||||
|
||||
async def handler(ctx):
|
||||
seen.append(ctx.user_id)
|
||||
return "ok"
|
||||
|
||||
def boom(ctx):
|
||||
raise RuntimeError("authorizer failure")
|
||||
|
||||
registry.register("approval", handler, authorize=boom)
|
||||
|
||||
handled = asyncio.run(registry.dispatch(_ctx("admin")))
|
||||
|
||||
assert handled is False
|
||||
assert seen == []
|
||||
|
||||
|
||||
def test_unregister_clears_authorizer():
|
||||
registry = InteractiveRegistry()
|
||||
|
||||
async def handler(ctx):
|
||||
return "ok"
|
||||
|
||||
registry.register("approval", handler, authorize=lambda ctx: False)
|
||||
registry.unregister("approval")
|
||||
# Re-register with no authorizer -> should allow again
|
||||
registry.register("approval", handler)
|
||||
|
||||
handled = asyncio.run(registry.dispatch(_ctx("999")))
|
||||
assert handled is True
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Unit tests for per-route, trust-tiered toolset scoping (Issue #2298).
|
||||
|
||||
Covers the core ``ToolPolicy`` filter contract and ``RouteBinding`` declaring a
|
||||
policy via ``trust`` / ``allow_tools`` / ``deny_tools`` (plus YAML ``from_dict``
|
||||
parsing), in praisonaiagents.gateway.protocols.
|
||||
"""
|
||||
|
||||
from praisonaiagents.gateway import (
|
||||
RouteBinding,
|
||||
ToolPolicy,
|
||||
UNTRUSTED_DENY_SUBSTRINGS,
|
||||
TRUST_TIERS,
|
||||
)
|
||||
|
||||
|
||||
def _named(name):
|
||||
"""A simple callable tool with a resolvable name."""
|
||||
def _tool(): # pragma: no cover - never invoked
|
||||
return None
|
||||
_tool.__name__ = name
|
||||
return _tool
|
||||
|
||||
|
||||
class TestToolPolicyFilter:
|
||||
def test_noop_policy_keeps_everything(self):
|
||||
p = ToolPolicy()
|
||||
assert p.is_noop
|
||||
tools = [_named("shell"), _named("read_file")]
|
||||
assert p.filter_tools(tools) == tools
|
||||
|
||||
def test_deny_tools_exact(self):
|
||||
p = ToolPolicy(deny_tools={"shell"})
|
||||
kept = p.filter_tools([_named("shell"), _named("read_file")])
|
||||
assert [t.__name__ for t in kept] == ["read_file"]
|
||||
|
||||
def test_deny_substrings_case_insensitive(self):
|
||||
p = ToolPolicy(deny_substrings=["shell"])
|
||||
kept = p.filter_tools([_named("RunShellCommand"), _named("search")])
|
||||
assert [t.__name__ for t in kept] == ["search"]
|
||||
|
||||
def test_allow_tools_is_a_whitelist(self):
|
||||
p = ToolPolicy(allow_tools={"search"})
|
||||
kept = p.filter_tools(
|
||||
[_named("search"), _named("write_file"), _named("delete_file")]
|
||||
)
|
||||
assert [t.__name__ for t in kept] == ["search"]
|
||||
|
||||
def test_empty_input_returns_empty(self):
|
||||
assert ToolPolicy(deny_tools={"x"}).filter_tools(None) == []
|
||||
assert ToolPolicy(deny_tools={"x"}).filter_tools([]) == []
|
||||
|
||||
def test_dict_style_tool_name_resolution(self):
|
||||
p = ToolPolicy(deny_tools={"shell"})
|
||||
tool = {"type": "function", "function": {"name": "shell"}}
|
||||
keep = {"type": "function", "function": {"name": "search"}}
|
||||
kept = p.filter_tools([tool, keep])
|
||||
assert kept == [keep]
|
||||
|
||||
def test_top_level_name_dict_is_resolved(self):
|
||||
# Plain schema dicts (no OpenAI ``function`` wrapper) must still resolve
|
||||
# by name so exact denies / untrusted substrings remove them.
|
||||
p = ToolPolicy(deny_tools={"shell"})
|
||||
tool = {"name": "shell", "description": "run a command"}
|
||||
keep = {"name": "search"}
|
||||
assert p.filter_tools([tool, keep]) == [keep]
|
||||
|
||||
def test_top_level_name_dict_untrusted_substring(self):
|
||||
p = ToolPolicy(deny_substrings=["shell"])
|
||||
tool = {"name": "RunShellCommand"}
|
||||
keep = {"name": "search"}
|
||||
assert p.filter_tools([tool, keep]) == [keep]
|
||||
|
||||
|
||||
class TestRouteBindingToolPolicy:
|
||||
def test_no_constraints_returns_none(self):
|
||||
assert RouteBinding(agent="a").tool_policy() is None
|
||||
|
||||
def test_trusted_tier_is_noop(self):
|
||||
assert RouteBinding(agent="a", trust="trusted").tool_policy() is None
|
||||
|
||||
def test_standard_tier_is_noop(self):
|
||||
assert RouteBinding(agent="a", trust="standard").tool_policy() is None
|
||||
|
||||
def test_untrusted_tier_denies_dangerous_families(self):
|
||||
pol = RouteBinding(agent="a", trust="untrusted").tool_policy()
|
||||
assert pol is not None
|
||||
kept = pol.filter_tools(
|
||||
[
|
||||
_named("run_shell"),
|
||||
_named("write_file"),
|
||||
_named("delete_file"),
|
||||
_named("delegate_task"),
|
||||
_named("schedule_cronjob"),
|
||||
_named("web_search"),
|
||||
_named("read_file"),
|
||||
]
|
||||
)
|
||||
names = [t.__name__ for t in kept]
|
||||
assert "web_search" in names
|
||||
assert "read_file" in names
|
||||
assert "run_shell" not in names
|
||||
assert "write_file" not in names
|
||||
assert "delete_file" not in names
|
||||
assert "delegate_task" not in names
|
||||
assert "schedule_cronjob" not in names
|
||||
|
||||
def test_explicit_allow_tools(self):
|
||||
pol = RouteBinding(agent="a", allow_tools=["search"]).tool_policy()
|
||||
assert pol is not None
|
||||
kept = pol.filter_tools([_named("search"), _named("shell")])
|
||||
assert [t.__name__ for t in kept] == ["search"]
|
||||
|
||||
def test_explicit_deny_tools(self):
|
||||
pol = RouteBinding(agent="a", deny_tools=["shell"]).tool_policy()
|
||||
assert pol is not None
|
||||
kept = pol.filter_tools([_named("search"), _named("shell")])
|
||||
assert [t.__name__ for t in kept] == ["search"]
|
||||
|
||||
def test_untrusted_plus_explicit_deny_layer(self):
|
||||
pol = RouteBinding(
|
||||
agent="a", trust="untrusted", deny_tools=["web_search"]
|
||||
).tool_policy()
|
||||
kept = pol.filter_tools(
|
||||
[_named("web_search"), _named("run_shell"), _named("read_file")]
|
||||
)
|
||||
assert [t.__name__ for t in kept] == ["read_file"]
|
||||
|
||||
|
||||
class TestRouteBindingTrustNormalization:
|
||||
def test_case_and_whitespace_variants_canonicalise(self):
|
||||
assert RouteBinding(agent="a", trust=" Untrusted ").trust == "untrusted"
|
||||
assert RouteBinding(agent="a", trust="TRUSTED").trust == "trusted"
|
||||
|
||||
def test_unknown_trust_fails_closed_to_untrusted(self):
|
||||
# A typo must NOT silently expose the full toolset — it down-tiers to
|
||||
# the most restrictive policy instead.
|
||||
b = RouteBinding(agent="a", trust="untrustd")
|
||||
assert b.trust == "untrusted"
|
||||
pol = b.tool_policy()
|
||||
assert pol is not None
|
||||
kept = pol.filter_tools([_named("run_shell"), _named("read_file")])
|
||||
assert [t.__name__ for t in kept] == ["read_file"]
|
||||
|
||||
def test_empty_trust_string_is_none(self):
|
||||
assert RouteBinding(agent="a", trust=" ").trust is None
|
||||
assert RouteBinding(agent="a", trust=" ").tool_policy() is None
|
||||
|
||||
def test_from_dict_unknown_trust_fails_closed(self):
|
||||
b = RouteBinding.from_dict({"agent": "a", "trust": "Untrusted!"})
|
||||
assert b.trust == "untrusted"
|
||||
assert b.tool_policy() is not None
|
||||
|
||||
|
||||
class TestRouteBindingFromDict:
|
||||
def test_from_dict_parses_trust_and_lists(self):
|
||||
b = RouteBinding.from_dict(
|
||||
{
|
||||
"agent": "assistant",
|
||||
"chat_type": "dm",
|
||||
"trust": "untrusted",
|
||||
"allow_tools": ["search"],
|
||||
"deny_tools": ["shell", "delete_file"],
|
||||
}
|
||||
)
|
||||
assert b.trust == "untrusted"
|
||||
assert b.allow_tools == ["search"]
|
||||
assert b.deny_tools == ["shell", "delete_file"]
|
||||
|
||||
def test_from_dict_scalar_tool_is_wrapped(self):
|
||||
b = RouteBinding.from_dict({"agent": "a", "deny_tools": "shell"})
|
||||
assert b.deny_tools == ["shell"]
|
||||
|
||||
def test_from_dict_absent_keys_stay_none(self):
|
||||
b = RouteBinding.from_dict({"agent": "a", "chat_type": "dm"})
|
||||
assert b.trust is None
|
||||
assert b.allow_tools is None
|
||||
assert b.deny_tools is None
|
||||
assert b.tool_policy() is None
|
||||
|
||||
def test_from_dict_backward_compatible(self):
|
||||
b = RouteBinding.from_dict({"agent": "a", "peer": "123", "priority": 5})
|
||||
assert b.agent == "a"
|
||||
assert b.peer == "123"
|
||||
assert b.priority == 5
|
||||
|
||||
|
||||
class TestModuleConstants:
|
||||
def test_trust_tiers_order(self):
|
||||
assert TRUST_TIERS == ["untrusted", "standard", "trusted"]
|
||||
|
||||
def test_untrusted_deny_substrings_nonempty(self):
|
||||
assert "shell" in UNTRUSTED_DENY_SUBSTRINGS
|
||||
assert len(UNTRUSTED_DENY_SUBSTRINGS) > 0
|
||||
@@ -18,6 +18,112 @@ def test_webhook_insecure_override(monkeypatch):
|
||||
assert webhooks_require_verification() is False
|
||||
|
||||
|
||||
def test_verify_hmac_valid_and_invalid():
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from praisonai.bots.webhook_security import verify_hmac
|
||||
|
||||
secret = "s3cret"
|
||||
body = b"payload"
|
||||
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
|
||||
assert verify_hmac(secret, body, sig) is True
|
||||
assert verify_hmac(secret, body, "deadbeef") is False
|
||||
assert verify_hmac(secret, body, None) is False
|
||||
assert verify_hmac("", body, sig) is False
|
||||
|
||||
|
||||
def test_verify_hmac_prefix_and_algo():
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from praisonai.bots.webhook_security import verify_hmac
|
||||
|
||||
secret = "s3cret"
|
||||
body = b"payload"
|
||||
digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
|
||||
assert verify_hmac(secret, body, f"sha256={digest}", prefix="sha256=") is True
|
||||
assert verify_hmac(secret, body, f"sha256={digest}") is True
|
||||
|
||||
|
||||
def test_verify_hmac_unknown_digest_fails_closed():
|
||||
from praisonai.bots.webhook_security import verify_hmac
|
||||
|
||||
assert verify_hmac("s3cret", b"payload", "deadbeef", digest="not-a-real-algo") is False
|
||||
|
||||
|
||||
def test_hmac_webhook_verifier_case_insensitive_header():
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from praisonai.bots.webhook_security import HmacWebhookVerifier
|
||||
|
||||
secret = "s3cret"
|
||||
body = b"payload"
|
||||
sig = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
|
||||
verifier = HmacWebhookVerifier(
|
||||
secret, signature_headers=["X-Hub-Signature-256"], prefix="sha256="
|
||||
)
|
||||
assert verifier.verify(
|
||||
headers={"x-hub-signature-256": sig}, raw_body=body
|
||||
) is True
|
||||
assert verifier.verify(headers={"x-hub-signature-256": "bad"}, raw_body=body) is False
|
||||
|
||||
|
||||
def test_enforce_webhook_verification_gate(monkeypatch):
|
||||
from praisonai.bots.webhook_security import (
|
||||
HmacWebhookVerifier,
|
||||
enforce_webhook_verification,
|
||||
)
|
||||
|
||||
monkeypatch.delenv("PRAISONAI_INSECURE_WEBHOOKS", raising=False)
|
||||
|
||||
assert enforce_webhook_verification(
|
||||
accepts_webhooks=False, verifier=None, headers={}, raw_body=b""
|
||||
) is True
|
||||
|
||||
assert enforce_webhook_verification(
|
||||
accepts_webhooks=True, verifier=None, headers={}, raw_body=b""
|
||||
) is False
|
||||
|
||||
monkeypatch.setenv("PRAISONAI_INSECURE_WEBHOOKS", "true")
|
||||
assert enforce_webhook_verification(
|
||||
accepts_webhooks=True, verifier=None, headers={}, raw_body=b""
|
||||
) is True
|
||||
monkeypatch.delenv("PRAISONAI_INSECURE_WEBHOOKS", raising=False)
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
secret = "s3cret"
|
||||
body = b"payload"
|
||||
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
verifier = HmacWebhookVerifier(secret, signature_headers=["X-Signature"])
|
||||
assert enforce_webhook_verification(
|
||||
accepts_webhooks=True,
|
||||
verifier=verifier,
|
||||
headers={"X-Signature": sig},
|
||||
raw_body=body,
|
||||
) is True
|
||||
|
||||
|
||||
def test_webhook_verifier_protocol_and_capabilities_roundtrip():
|
||||
from praisonaiagents.bots import (
|
||||
PlatformCapabilities,
|
||||
WebhookVerifierProtocol,
|
||||
)
|
||||
|
||||
assert WebhookVerifierProtocol is not None
|
||||
|
||||
caps = PlatformCapabilities(accepts_webhooks=True, verifies_webhook_signature=True)
|
||||
restored = PlatformCapabilities.from_dict(caps.to_dict())
|
||||
assert restored.accepts_webhooks is True
|
||||
assert restored.verifies_webhook_signature is True
|
||||
|
||||
|
||||
def test_jobs_agent_root_rejects_escape(tmp_path, monkeypatch):
|
||||
from praisonai.jobs.path_validation import validate_agent_file_path
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -31,3 +35,90 @@ def test_workflow_yaml_approve_gated_without_allow_dangerous():
|
||||
|
||||
after = set(registry._yaml_approved_tools.get() or ())
|
||||
assert after == before
|
||||
|
||||
|
||||
def test_search_tools_read_file_blocks_traversal(tmp_path):
|
||||
"""GHSA-4xxv: read_file must stay within workspace."""
|
||||
from praisonaiagents.context.fast.search_tools import read_file
|
||||
|
||||
outside = tmp_path / "secret.txt"
|
||||
outside.write_text("secret")
|
||||
ws = tmp_path / "ws"
|
||||
ws.mkdir()
|
||||
result = read_file(str(outside), workspace_root=str(ws))
|
||||
assert result["success"] is False
|
||||
assert "outside workspace" in result["error"].lower()
|
||||
|
||||
|
||||
def test_memory_output_file_contained(tmp_path, monkeypatch):
|
||||
"""GHSA-qjw5: auto-save output must stay in project root."""
|
||||
from praisonaiagents.agent.memory_mixin import MemoryMixin
|
||||
|
||||
class _Agent(MemoryMixin):
|
||||
def __init__(self):
|
||||
self._output_file = str(tmp_path.parent / "outside.txt")
|
||||
self.name = "t"
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("PRAISONAI_PROJECT_ROOT", raising=False)
|
||||
agent = _Agent()
|
||||
assert agent._save_output_to_file("data") is False
|
||||
|
||||
|
||||
def test_skill_tools_blocks_outside_script(tmp_path, monkeypatch):
|
||||
"""GHSA-c44f: skill script path must stay in working directory."""
|
||||
from praisonaiagents.tools.skill_tools import SkillTools
|
||||
|
||||
monkeypatch.setenv("PRAISONAI_AUTO_APPROVE", "true")
|
||||
outside = tmp_path / "evil.py"
|
||||
outside.write_text("print('x')")
|
||||
tools = SkillTools(working_directory=str(tmp_path / "ws"))
|
||||
(tmp_path / "ws").mkdir()
|
||||
result = tools.run_skill_script(str(outside))
|
||||
assert "outside working directory" in result.lower()
|
||||
|
||||
|
||||
def test_web_crawl_blocks_private_ip():
|
||||
"""GHSA-qg25: web_crawl SSRF blocks private targets."""
|
||||
from praisonaiagents.tools.web_crawl_tools import web_crawl
|
||||
|
||||
result = web_crawl("http://169.254.169.254/latest/meta-data/")
|
||||
assert "error" in result
|
||||
|
||||
|
||||
def test_approval_cache_varies_by_arguments():
|
||||
"""GHSA-29r9: approval cache must include tool arguments."""
|
||||
from praisonaiagents.approval.registry import ApprovalRegistry
|
||||
|
||||
registry = ApprovalRegistry()
|
||||
registry.mark_approved("run", {"cmd": "ls"})
|
||||
assert registry.is_already_approved("run", {"cmd": "ls"})
|
||||
assert not registry.is_already_approved("run", {"cmd": "rm -rf /"})
|
||||
|
||||
|
||||
def test_workflow_tools_py_gated(monkeypatch, tmp_path):
|
||||
"""GHSA-4gfv: tools.py loading requires PRAISONAI_ALLOW_LOCAL_TOOLS."""
|
||||
from praisonaiagents.workflows.workflows import Workflow
|
||||
|
||||
wf_dir = tmp_path / "wf"
|
||||
wf_dir.mkdir()
|
||||
(wf_dir / "tools.py").write_text(
|
||||
"from pydantic import BaseModel\nclass Out(BaseModel):\n x: int\n"
|
||||
)
|
||||
wf = Workflow(steps=[], file_path=str(wf_dir / "flow.yaml"))
|
||||
monkeypatch.delenv("PRAISONAI_ALLOW_LOCAL_TOOLS", raising=False)
|
||||
assert wf._resolve_pydantic_class("Out") is None
|
||||
|
||||
monkeypatch.setenv("PRAISONAI_ALLOW_LOCAL_TOOLS", "true")
|
||||
cls = wf._resolve_pydantic_class("Out")
|
||||
assert cls is not None
|
||||
|
||||
|
||||
def test_plugin_discovery_opt_in(monkeypatch):
|
||||
"""GHSA-m6wp: plugin auto-discovery is opt-in."""
|
||||
from praisonaiagents.plugins.manager import PluginManager
|
||||
|
||||
monkeypatch.delenv("PRAISONAI_ALLOW_PLUGIN_DISCOVERY", raising=False)
|
||||
manager = PluginManager()
|
||||
assert manager.auto_discover_plugins() == 0
|
||||
|
||||
@@ -171,3 +171,84 @@ class TestBudgetNoneOverhead:
|
||||
)
|
||||
# 0.0 is falsy → budget check is skipped (intentional)
|
||||
assert not agent._max_budget
|
||||
|
||||
|
||||
class TestPreCallEstimate:
|
||||
"""Test _estimate_min_call_cost helper used by the pre-call guard."""
|
||||
|
||||
def test_estimate_zero_for_empty_messages(self):
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test")
|
||||
assert agent._estimate_min_call_cost([], None) == 0.0
|
||||
|
||||
def test_estimate_scales_with_input(self):
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test", llm="gpt-4o")
|
||||
small = agent._estimate_min_call_cost(
|
||||
[{"role": "user", "content": "hi"}], None
|
||||
)
|
||||
large = agent._estimate_min_call_cost(
|
||||
[{"role": "user", "content": "x" * 100_000}], None
|
||||
)
|
||||
assert large > small
|
||||
|
||||
def test_estimate_includes_output_reservation(self):
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test", llm="gpt-4o")
|
||||
msgs = [{"role": "user", "content": "hello world"}]
|
||||
# A larger explicit max_tokens reservation costs more than a smaller one.
|
||||
small_out = agent._estimate_min_call_cost(msgs, 100)
|
||||
large_out = agent._estimate_min_call_cost(msgs, 5000)
|
||||
assert large_out > small_out
|
||||
|
||||
def test_estimate_reserves_default_output_when_max_tokens_unset(self):
|
||||
"""Unset max_tokens must still reserve a non-zero output cost so a small
|
||||
prompt with a large provider-default response cannot bypass the guard."""
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test", llm="gpt-4o")
|
||||
msgs = [{"role": "user", "content": "hi"}]
|
||||
# Input alone (2 chars -> 0 tokens) would otherwise be ~free; the default
|
||||
# output reservation guarantees a positive estimate.
|
||||
assert agent._estimate_min_call_cost(msgs, None) > 0.0
|
||||
|
||||
def test_estimate_handles_list_content(self):
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(name="test", instructions="test", llm="gpt-4o")
|
||||
msgs = [{"role": "user", "content": [{"type": "text", "text": "x" * 8000}]}]
|
||||
assert agent._estimate_min_call_cost(msgs, None) > 0.0
|
||||
|
||||
|
||||
class TestPreCallGuard:
|
||||
"""Test that the pre-call guard blocks an over-budget call before dispatch."""
|
||||
|
||||
def test_guard_blocks_before_dispatch(self):
|
||||
"""A huge input that breaches the cap raises before the LLM is called."""
|
||||
from praisonaiagents import Agent, ExecutionConfig, BudgetExceededError
|
||||
agent = Agent(
|
||||
name="test", instructions="test", llm="gpt-4o",
|
||||
execution=ExecutionConfig(max_budget=0.0001),
|
||||
)
|
||||
# Make the retry path explode if it is ever reached.
|
||||
agent._chat_completion_with_retry = MagicMock(
|
||||
side_effect=AssertionError("LLM dispatched despite budget guard")
|
||||
)
|
||||
messages = [{"role": "user", "content": "x" * 200_000}]
|
||||
with pytest.raises(BudgetExceededError):
|
||||
agent._chat_completion(messages)
|
||||
agent._chat_completion_with_retry.assert_not_called()
|
||||
# No spend recorded for the blocked call.
|
||||
assert agent._total_cost == 0.0
|
||||
|
||||
def test_guard_skips_when_warn_mode(self):
|
||||
"""warn mode keeps reactive-only behaviour (no pre-call block)."""
|
||||
from praisonaiagents import Agent, ExecutionConfig
|
||||
agent = Agent(
|
||||
name="test", instructions="test", llm="gpt-4o",
|
||||
execution=ExecutionConfig(max_budget=0.0001, on_budget_exceeded="warn"),
|
||||
)
|
||||
est = agent._estimate_min_call_cost(
|
||||
[{"role": "user", "content": "x" * 200_000}], None
|
||||
)
|
||||
# Estimate alone would exceed the cap, but warn mode must not pre-block.
|
||||
assert est >= agent._max_budget
|
||||
assert agent._on_budget_exceeded == "warn"
|
||||
@@ -410,6 +410,190 @@ class TestPostEditDiagnostics:
|
||||
assert "Diagnostics" in result
|
||||
|
||||
|
||||
class TestLSPDiagnostics:
|
||||
"""The post-edit diagnostics hook is wired to the existing LSP client."""
|
||||
|
||||
def test_off_mode_skips_lsp(self, tmp_path, monkeypatch):
|
||||
# ``off`` short-circuits before any LSP attempt (zero overhead).
|
||||
editor = EditTools(post_edit_diagnostics="off")
|
||||
called = {"n": 0}
|
||||
|
||||
def _spy(self, safe_path, display_path):
|
||||
called["n"] += 1
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(EditTools, "_try_lsp_diagnostics", _spy)
|
||||
p = tmp_path / "x.py"
|
||||
_write(p, "x = 1\n")
|
||||
result = editor.edit_file(str(p), "x = 1", "x = 2")
|
||||
assert "Diagnostics" not in result
|
||||
assert called["n"] == 0
|
||||
|
||||
def test_no_server_falls_back_to_checker(self, tmp_path):
|
||||
# With no language server installed for the extension, LSP returns None
|
||||
# and the legacy per-language checker still surfaces syntax errors.
|
||||
editor = EditTools(post_edit_diagnostics="auto")
|
||||
# No server is configured for an unknown extension -> None.
|
||||
assert editor._try_lsp_diagnostics(str(tmp_path / "f.unknownext"), "f") is None
|
||||
p = tmp_path / "broken.py"
|
||||
_write(p, "x = 1\n")
|
||||
result = editor.edit_file(str(p), "x = 1", "def (:")
|
||||
assert "Success" in result
|
||||
assert "Diagnostics" in result
|
||||
|
||||
def test_lsp_block_used_when_available(self, tmp_path, monkeypatch):
|
||||
# When the LSP path returns a formatted block, it is used verbatim and
|
||||
# the legacy checker is not consulted.
|
||||
editor = EditTools(post_edit_diagnostics="auto")
|
||||
|
||||
def _fake_lsp(self, safe_path, display_path):
|
||||
return f"\n\nDiagnostics (lsp:typescript):\n{display_path}:1:1: error: oops"
|
||||
|
||||
def _boom(self, safe_path):
|
||||
raise AssertionError("checker fallback should not run when LSP returns a block")
|
||||
|
||||
monkeypatch.setattr(EditTools, "_try_lsp_diagnostics", _fake_lsp)
|
||||
monkeypatch.setattr(EditTools, "_diagnostics_command", staticmethod(_boom))
|
||||
p = tmp_path / "a.ts"
|
||||
_write(p, "const x = 1;\n")
|
||||
result = editor.edit_file(str(p), "const x = 1;", "const y = 2;")
|
||||
assert "Success" in result
|
||||
assert "Diagnostics (lsp:typescript)" in result
|
||||
|
||||
def test_lsp_no_problems_auto_is_silent(self, tmp_path, monkeypatch):
|
||||
# An LSP run with no diagnostics in ``auto`` mode appends nothing.
|
||||
editor = EditTools(post_edit_diagnostics="auto")
|
||||
monkeypatch.setattr(
|
||||
EditTools, "_try_lsp_diagnostics",
|
||||
lambda self, safe_path, display_path: "",
|
||||
)
|
||||
p = tmp_path / "a.ts"
|
||||
_write(p, "const x = 1;\n")
|
||||
result = editor.edit_file(str(p), "const x = 1;", "const y = 2;")
|
||||
assert "Success" in result
|
||||
assert "Diagnostics" not in result
|
||||
|
||||
def test_lsp_failure_falls_back_to_checker(self, tmp_path, monkeypatch):
|
||||
# A raising LSP attempt must not break the edit; the checker fallback
|
||||
# still runs and surfaces the Python syntax error.
|
||||
editor = EditTools(post_edit_diagnostics="auto")
|
||||
|
||||
def _raise(self, safe_path, display_path):
|
||||
raise RuntimeError("server crashed")
|
||||
|
||||
monkeypatch.setattr(EditTools, "_try_lsp_diagnostics", _raise)
|
||||
p = tmp_path / "broken.py"
|
||||
_write(p, "x = 1\n")
|
||||
result = editor.edit_file(str(p), "x = 1", "def (:")
|
||||
assert "Success" in result
|
||||
assert "Diagnostics" in result
|
||||
|
||||
def test_lsp_unpublished_falls_back_to_checker(self, tmp_path, monkeypatch):
|
||||
# Regression: when an installed server starts/opens but never publishes
|
||||
# diagnostics within the budget (still indexing, debounced, or URI
|
||||
# mismatch), the LSP path must return None (not "") so the legacy
|
||||
# checker still runs and surfaces a real syntax error rather than
|
||||
# reporting a false all-clear.
|
||||
import asyncio
|
||||
from praisonaiagents.tools import edit_tools as et
|
||||
|
||||
editor = EditTools(post_edit_diagnostics="auto")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *a, **k):
|
||||
self.config = type("C", (), {"timeout": 5})()
|
||||
self._diagnostics = {} # server never publishes
|
||||
|
||||
async def start(self):
|
||||
return True
|
||||
|
||||
async def open_document(self, path):
|
||||
return True
|
||||
|
||||
async def get_diagnostics(self, path):
|
||||
return []
|
||||
|
||||
async def close_document(self, path):
|
||||
return None
|
||||
|
||||
async def stop(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(et, "_DIAGNOSTICS_TIMEOUT", 0.3, raising=False)
|
||||
import shutil
|
||||
monkeypatch.setattr(shutil, "which",
|
||||
lambda cmd: "/usr/bin/" + cmd, raising=False)
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.lsp.config.DEFAULT_SERVERS",
|
||||
{"python": {"command": "pyright-langserver", "args": []}},
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.lsp.client.LSPClient", _FakeClient, raising=False
|
||||
)
|
||||
|
||||
# No running loop -> the sync path uses asyncio.run; force fresh loop.
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
pytest.skip("running loop present; sync LSP path not exercised")
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
block = editor._try_lsp_diagnostics(str(tmp_path / "broken.py"), "broken.py")
|
||||
assert block is None # unpublished -> fall back, not a false all-clear
|
||||
|
||||
def test_lsp_clean_publish_is_authoritative(self, tmp_path, monkeypatch):
|
||||
# A genuine clean publish ([]) returns "" in auto mode and does NOT
|
||||
# redundantly run the subprocess checker.
|
||||
import asyncio
|
||||
from praisonaiagents.tools import edit_tools as et
|
||||
|
||||
editor = EditTools(post_edit_diagnostics="auto")
|
||||
uri_holder = {}
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *a, **k):
|
||||
self.config = type("C", (), {"timeout": 5})()
|
||||
self._diagnostics = uri_holder
|
||||
|
||||
async def start(self):
|
||||
return True
|
||||
|
||||
async def open_document(self, path):
|
||||
uri_holder[f"file://{os.path.abspath(path)}"] = []
|
||||
return True
|
||||
|
||||
async def get_diagnostics(self, path):
|
||||
return []
|
||||
|
||||
async def close_document(self, path):
|
||||
return None
|
||||
|
||||
async def stop(self):
|
||||
return None
|
||||
|
||||
import shutil
|
||||
monkeypatch.setattr(shutil, "which",
|
||||
lambda cmd: "/usr/bin/" + cmd, raising=False)
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.lsp.config.DEFAULT_SERVERS",
|
||||
{"python": {"command": "pyright-langserver", "args": []}},
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.lsp.client.LSPClient", _FakeClient, raising=False
|
||||
)
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
pytest.skip("running loop present; sync LSP path not exercised")
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
block = editor._try_lsp_diagnostics(str(tmp_path / "ok.py"), "ok.py")
|
||||
assert block == "" # published-and-clean -> authoritative all-clear
|
||||
|
||||
|
||||
class TestAutomaticStaleness:
|
||||
def test_edit_aborts_when_changed_after_read(self, tools, tmp_path):
|
||||
p = tmp_path / "a.py"
|
||||
|
||||
Generated
+1
-1
@@ -3125,7 +3125,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.76"
|
||||
version = "1.6.80"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -70,9 +70,20 @@ async def delete_dependency(
|
||||
raise HTTPException(status_code=404, detail="Dependency not found")
|
||||
if dep.issue_id != issue_id and dep.depends_on_issue_id != issue_id:
|
||||
raise HTTPException(status_code=404, detail="Dependency not found")
|
||||
|
||||
other_issue_id = (
|
||||
dep.depends_on_issue_id if dep.issue_id == issue_id else dep.issue_id
|
||||
)
|
||||
other_issue = await issue_svc.get(other_issue_id, workspace_id=workspace_id)
|
||||
if other_issue is None:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
|
||||
await require_delete_permission(
|
||||
workspace_id, user, session, resource_owner_id=issue.creator_id
|
||||
)
|
||||
await require_delete_permission(
|
||||
workspace_id, user, session, resource_owner_id=other_issue.creator_id
|
||||
)
|
||||
deleted = await svc.delete(dep_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Dependency not found")
|
||||
@@ -14,6 +14,7 @@ from ..deps import (
|
||||
get_db,
|
||||
require_delete_permission,
|
||||
require_issue_in_workspace,
|
||||
require_workspace_admin,
|
||||
require_workspace_member,
|
||||
)
|
||||
from ..schemas import LabelCreate, LabelResponse, LabelUpdate
|
||||
@@ -58,6 +59,7 @@ async def update_label(
|
||||
if label is None:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
|
||||
await require_workspace_admin(workspace_id, user, session)
|
||||
label = await svc.update(label_id, body.name, body.color)
|
||||
if label is None:
|
||||
raise HTTPException(status_code=404, detail="Label not found")
|
||||
|
||||
@@ -71,6 +71,12 @@ async def update_project(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = ProjectService(session)
|
||||
project = await svc.get(project_id, workspace_id=workspace_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
await require_delete_permission(
|
||||
workspace_id, user, session, resource_owner_id=project.lead_id
|
||||
)
|
||||
project = await svc.update(
|
||||
project_id,
|
||||
workspace_id=workspace_id,
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonai-platform"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
description = "Platform layer for PraisonAI — workspace, auth, issues, projects"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Platform GHSA regression tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_project_patch_has_owner_guard():
|
||||
"""GHSA-c78w: project PATCH must call require_delete_permission."""
|
||||
source = Path(__file__).resolve().parents[2] / "praisonai_platform/api/routes/projects.py"
|
||||
text = source.read_text()
|
||||
patch_block = text.split("async def update_project", 1)[1].split("async def delete_project", 1)[0]
|
||||
assert "require_delete_permission" in patch_block
|
||||
|
||||
|
||||
def test_label_patch_requires_admin_guard():
|
||||
"""GHSA-xxgv: label PATCH must require workspace admin."""
|
||||
source = Path(__file__).resolve().parents[2] / "praisonai_platform/api/routes/labels.py"
|
||||
text = source.read_text()
|
||||
patch_block = text.split("async def update_label", 1)[1].split("async def delete_label", 1)[0]
|
||||
assert "require_workspace_admin" in patch_block
|
||||
|
||||
|
||||
def test_dependency_delete_checks_both_issues():
|
||||
"""GHSA-mxmx: dependency delete must authorise both linked issues."""
|
||||
source = Path(__file__).resolve().parents[2] / "praisonai_platform/api/routes/dependencies.py"
|
||||
text = source.read_text()
|
||||
delete_block = text.split("async def delete_dependency", 1)[1]
|
||||
assert delete_block.count("require_delete_permission") >= 2
|
||||
assert "other_issue" in delete_block
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_patch_requires_owner_or_admin():
|
||||
"""GHSA-c78w: project PATCH must match DELETE permission model."""
|
||||
pytest.importorskip("fastapi")
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from praisonai_platform.api.routes import projects as routes
|
||||
|
||||
user = MagicMock()
|
||||
user.id = "member-1"
|
||||
session = AsyncMock()
|
||||
project = MagicMock()
|
||||
project.lead_id = "owner-1"
|
||||
|
||||
with patch.object(routes.ProjectService, "get", AsyncMock(return_value=project)):
|
||||
with patch.object(routes, "require_delete_permission", AsyncMock(side_effect=Exception("403"))) as guard:
|
||||
with pytest.raises(Exception, match="403"):
|
||||
await routes.update_project(
|
||||
"ws-1",
|
||||
"proj-1",
|
||||
MagicMock(),
|
||||
user=user,
|
||||
session=session,
|
||||
)
|
||||
guard.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_label_patch_requires_admin():
|
||||
"""GHSA-xxgv: shared label PATCH requires admin/owner."""
|
||||
pytest.importorskip("fastapi")
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from praisonai_platform.api.routes import labels as routes
|
||||
|
||||
label = MagicMock()
|
||||
label.workspace_id = "ws-1"
|
||||
|
||||
with patch.object(routes.LabelService, "get", AsyncMock(return_value=label)):
|
||||
with patch.object(routes, "require_workspace_admin", AsyncMock(side_effect=Exception("403"))) as guard:
|
||||
with pytest.raises(Exception, match="403"):
|
||||
await routes.update_label(
|
||||
"ws-1",
|
||||
"label-1",
|
||||
MagicMock(),
|
||||
user=MagicMock(),
|
||||
session=AsyncMock(),
|
||||
)
|
||||
guard.assert_awaited_once()
|
||||
Generated
+27
-1
@@ -509,6 +509,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "dnspython" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
@@ -1208,11 +1230,12 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-platform"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "alembic" },
|
||||
{ name = "email-validator" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "passlib", extra = ["bcrypt"] },
|
||||
@@ -1229,6 +1252,7 @@ postgres = [
|
||||
{ name = "psycopg2-binary" },
|
||||
]
|
||||
test = [
|
||||
{ name = "email-validator" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
@@ -1239,6 +1263,8 @@ requires-dist = [
|
||||
{ name = "aiosqlite", specifier = ">=0.20.0" },
|
||||
{ name = "alembic", specifier = ">=1.14.0" },
|
||||
{ name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.30.0" },
|
||||
{ name = "email-validator", specifier = ">=2.0.0" },
|
||||
{ name = "email-validator", marker = "extra == 'test'", specifier = ">=2.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" },
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-06-24",
|
||||
"lastUpdated": "2026-06-26",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"status": "PARITY_ACHIEVED",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 388,
|
||||
"pythonCoreFeatures": 392,
|
||||
"rustFeatures": 667,
|
||||
"gapCount": 106,
|
||||
"parityPercentage": 171.9
|
||||
"gapCount": 110,
|
||||
"parityPercentage": 170.2
|
||||
},
|
||||
"pythonCoreSDK": {
|
||||
"exports": [
|
||||
@@ -27,6 +27,7 @@
|
||||
"AgentOSProtocol",
|
||||
"AgentPluginProtocol",
|
||||
"AgentRunOutcome",
|
||||
"AgentRuntimeProtocol",
|
||||
"AgentTeam",
|
||||
"Agents",
|
||||
"ApprovalCallback",
|
||||
@@ -350,6 +351,7 @@
|
||||
"is_path_like",
|
||||
"is_policy_string",
|
||||
"list_memory_adapters",
|
||||
"list_runtimes",
|
||||
"list_toolsets",
|
||||
"load_plugin",
|
||||
"load_skill",
|
||||
@@ -364,6 +366,7 @@
|
||||
"register_display_callback",
|
||||
"register_memory_adapter",
|
||||
"register_memory_factory",
|
||||
"register_runtime",
|
||||
"register_tool",
|
||||
"register_toolset",
|
||||
"repeat",
|
||||
@@ -381,6 +384,7 @@
|
||||
"resolve_planning",
|
||||
"resolve_reflection",
|
||||
"resolve_routing",
|
||||
"resolve_runtime",
|
||||
"resolve_skills",
|
||||
"resolve_toolset",
|
||||
"resolve_toolsets",
|
||||
@@ -3089,6 +3093,15 @@
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "AgentRuntimeProtocol",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "medium",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "AgentTeam",
|
||||
"python": true,
|
||||
@@ -4997,6 +5010,15 @@
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "list_runtimes",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "list_toolsets",
|
||||
"python": true,
|
||||
@@ -5078,6 +5100,15 @@
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "register_runtime",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "register_toolset",
|
||||
"python": true,
|
||||
@@ -5213,6 +5244,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "resolve_runtime",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "resolve_skills",
|
||||
"python": true,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Rust Feature Parity Tracker
|
||||
|
||||
> **Python Features:** 388 | **Rust Features:** 667 | **Parity:** 74.2%
|
||||
> **Python Features:** 392 | **Rust Features:** 667 | **Parity:** 73.5%
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 388 |
|
||||
| Python Core Features | 392 |
|
||||
| Rust Features | 667 |
|
||||
| **Actual Gap Count** | **100** |
|
||||
| **Actual Gap Count** | **104** |
|
||||
| Language Limitations (N/A) | 5 |
|
||||
| **Parity** | **74.2%** |
|
||||
| **Parity** | **73.5%** |
|
||||
|
||||
## Implemented Features
|
||||
|
||||
@@ -698,6 +698,7 @@ These Python features cannot be directly implemented in Rust due to reserved key
|
||||
- ❌ `AGGRESSIVE_POLICY`
|
||||
- ❌ `AgentMessageEvent`
|
||||
- ❌ `AgentRunOutcome`
|
||||
- ❌ `AgentRuntimeProtocol`
|
||||
- ❌ `AsyncLearnProtocol`
|
||||
- ❌ `AutoApproveBackend`
|
||||
- ❌ `AutoMemory`
|
||||
@@ -780,13 +781,16 @@ These Python features cannot be directly implemented in Rust due to reserved key
|
||||
- ❌ `if_`
|
||||
- ❌ `include`
|
||||
- ❌ `list_memory_adapters`
|
||||
- ❌ `list_runtimes`
|
||||
- ❌ `list_toolsets`
|
||||
- ❌ `load_skill`
|
||||
- ❌ `parallel_handoffs`
|
||||
- ❌ `register_memory_adapter`
|
||||
- ❌ `register_memory_factory`
|
||||
- ❌ `register_runtime`
|
||||
- ❌ `register_tool`
|
||||
- ❌ `register_toolset`
|
||||
- ❌ `resolve_runtime`
|
||||
- ❌ `resolve_toolset`
|
||||
- ❌ `resolve_toolsets`
|
||||
- ❌ `unregister_toolset`
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-06-24",
|
||||
"lastUpdated": "2026-06-26",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 388,
|
||||
"pythonCoreFeatures": 392,
|
||||
"pythonWrapperFeatures": 100,
|
||||
"typescriptFeatures": 1188,
|
||||
"gapCount": 95,
|
||||
"typescriptFeatures": 1190,
|
||||
"gapCount": 99,
|
||||
"priorityP0": 2,
|
||||
"priorityP1": 5,
|
||||
"priorityP2": 6,
|
||||
"priorityP3": 82
|
||||
"priorityP3": 86
|
||||
},
|
||||
"pythonCoreSDK": {
|
||||
"path": "/home/runner/work/PraisonAI/PraisonAI/src/praisonai-agents/praisonaiagents",
|
||||
@@ -31,6 +31,7 @@
|
||||
"AgentOSProtocol",
|
||||
"AgentPluginProtocol",
|
||||
"AgentRunOutcome",
|
||||
"AgentRuntimeProtocol",
|
||||
"AgentTeam",
|
||||
"Agents",
|
||||
"ApprovalCallback",
|
||||
@@ -354,6 +355,7 @@
|
||||
"is_path_like",
|
||||
"is_policy_string",
|
||||
"list_memory_adapters",
|
||||
"list_runtimes",
|
||||
"list_toolsets",
|
||||
"load_plugin",
|
||||
"load_skill",
|
||||
@@ -368,6 +370,7 @@
|
||||
"register_display_callback",
|
||||
"register_memory_adapter",
|
||||
"register_memory_factory",
|
||||
"register_runtime",
|
||||
"register_tool",
|
||||
"register_toolset",
|
||||
"repeat",
|
||||
@@ -385,6 +388,7 @@
|
||||
"resolve_planning",
|
||||
"resolve_reflection",
|
||||
"resolve_routing",
|
||||
"resolve_runtime",
|
||||
"resolve_skills",
|
||||
"resolve_toolset",
|
||||
"resolve_toolsets",
|
||||
@@ -636,6 +640,7 @@
|
||||
"AGGRESSIVE_POLICY",
|
||||
"AgentMessageEvent",
|
||||
"AgentRunOutcome",
|
||||
"AgentRuntimeProtocol",
|
||||
"Agents",
|
||||
"AsyncLearnProtocol",
|
||||
"AutoMemory",
|
||||
@@ -709,11 +714,14 @@
|
||||
"has_memory_adapter",
|
||||
"has_toolset",
|
||||
"list_memory_adapters",
|
||||
"list_runtimes",
|
||||
"list_toolsets",
|
||||
"memory",
|
||||
"register_memory_adapter",
|
||||
"register_memory_factory",
|
||||
"register_runtime",
|
||||
"register_toolset",
|
||||
"resolve_runtime",
|
||||
"resolve_toolset",
|
||||
"resolve_toolsets",
|
||||
"tools",
|
||||
@@ -1462,6 +1470,8 @@
|
||||
"createWorkflowHooks",
|
||||
"create_context_agent",
|
||||
"cronExpressions",
|
||||
"crwCrawl",
|
||||
"crwScrape",
|
||||
"db",
|
||||
"defineTool",
|
||||
"detect_url_scheme",
|
||||
@@ -2898,6 +2908,8 @@
|
||||
"createToolsRegistry",
|
||||
"createTracingMiddleware",
|
||||
"createValidationMiddleware",
|
||||
"crwCrawl",
|
||||
"crwScrape",
|
||||
"exaSearch",
|
||||
"firecrawlCrawl",
|
||||
"firecrawlScrape",
|
||||
@@ -4228,6 +4240,15 @@
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "AgentRuntimeProtocol",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "medium",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "AgentTeam",
|
||||
"python": true,
|
||||
@@ -6136,6 +6157,15 @@
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "list_runtimes",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "list_toolsets",
|
||||
"python": true,
|
||||
@@ -6217,6 +6247,15 @@
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "register_runtime",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "register_toolset",
|
||||
"python": true,
|
||||
@@ -6352,6 +6391,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "resolve_runtime",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "resolve_skills",
|
||||
"python": true,
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# Feature Parity Tracker
|
||||
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-06-24
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-06-26
|
||||
> **Source of Truth:** Python SDK (praisonaiagents)
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 388 |
|
||||
| Python Core Features | 392 |
|
||||
| Python Wrapper Features | 100 |
|
||||
| TypeScript Features | 1188 |
|
||||
| **Gap Count** | **95** |
|
||||
| TypeScript Features | 1190 |
|
||||
| **Gap Count** | **99** |
|
||||
| P0 (Critical) | 2 |
|
||||
| P1 (High) | 5 |
|
||||
| P2 (Medium) | 6 |
|
||||
| P3 (Low) | 82 |
|
||||
| P3 (Low) | 86 |
|
||||
|
||||
## Gap Matrix
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
| `enable\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
|
||||
### P3_Advanced (180 done, 82 todo)
|
||||
### P3_Advanced (180 done, 86 todo)
|
||||
|
||||
| Feature | Python | TypeScript | Effort | Status |
|
||||
|---------|--------|------------|--------|--------|
|
||||
@@ -167,6 +167,7 @@
|
||||
| `AGGRESSIVE\_POLICY` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `AgentMessageEvent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `AgentRunOutcome` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `AgentRuntimeProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `AsyncLearnProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `AutoApproveBackend` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `BALANCED\_POLICY` | ✅ | ❌ | low | ⏳ TODO |
|
||||
@@ -237,10 +238,13 @@
|
||||
| `has\_memory\_adapter` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `has\_toolset` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `list\_memory\_adapters` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `list\_runtimes` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `list\_toolsets` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `register\_memory\_adapter` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `register\_memory\_factory` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `register\_runtime` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `register\_toolset` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `resolve\_runtime` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `resolve\_toolset` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `resolve\_toolsets` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `unregister\_toolset` | ✅ | ❌ | low | ⏳ TODO |
|
||||
@@ -584,10 +588,10 @@ from praisonaiagents import Memory
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>other</strong> (87 exports)</summary>
|
||||
<summary><strong>other</strong> (91 exports)</summary>
|
||||
|
||||
```python
|
||||
from praisonaiagents import AGGRESSIVE_POLICY, AgentMessageEvent, AgentRunOutcome, Agents, AsyncLearnProtocol, AutoMemory, AutonomyConfig, BALANCED_POLICY, BackendNotAvailableError, BudgetExceededError...
|
||||
from praisonaiagents import AGGRESSIVE_POLICY, AgentMessageEvent, AgentRunOutcome, AgentRuntimeProtocol, Agents, AsyncLearnProtocol, AutoMemory, AutonomyConfig, BALANCED_POLICY, BackendNotAvailableError...
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -921,7 +925,7 @@ import { AgentStats, AgentTelemetry, MetricEntry, MinimalTelemetry, PerformanceM
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>tools</strong> (87 exports)</summary>
|
||||
<summary><strong>tools</strong> (89 exports)</summary>
|
||||
|
||||
```typescript
|
||||
import { BaseTool, BudgetExceededError, DelegatorConfig, FunctionTool, InstallHints, MissingDependencyError, MissingEnvVarError, PraisonTool, RedactionHooks, RegisteredTool... } from 'praisonai';
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Agent, MCP, TransportType } from 'praisonai-ts';
|
||||
|
||||
async function main() {
|
||||
// Example 1: Automatic transport detection (default behavior)
|
||||
const mcpAuto = new MCP('http://127.0.0.1:8080/sse'); // Will use SSE
|
||||
await mcpAuto.initialize();
|
||||
console.log(`Auto-detected transport: ${mcpAuto.transportType}`);
|
||||
|
||||
// Example 2: Explicit SSE transport
|
||||
const mcpSSE = new MCP('http://127.0.0.1:8080/api', 'sse');
|
||||
await mcpSSE.initialize();
|
||||
console.log(`Explicit SSE transport: ${mcpSSE.transportType}`);
|
||||
|
||||
// Example 3: Explicit HTTP-Streaming transport
|
||||
const mcpHTTP = new MCP('http://127.0.0.1:8080/stream', 'http-streaming');
|
||||
await mcpHTTP.initialize();
|
||||
console.log(`Explicit HTTP-Streaming transport: ${mcpHTTP.transportType}`);
|
||||
|
||||
// Example 4: Auto-detection with non-SSE URL
|
||||
const mcpAutoHTTP = new MCP('http://127.0.0.1:8080/api'); // Will use HTTP-Streaming
|
||||
await mcpAutoHTTP.initialize();
|
||||
console.log(`Auto-detected transport for non-SSE URL: ${mcpAutoHTTP.transportType}`);
|
||||
|
||||
// Create tool execution functions
|
||||
const toolFunctions = Object.fromEntries(
|
||||
[...mcpAuto].map(tool => [
|
||||
tool.name,
|
||||
async (args: any) => tool.execute(args)
|
||||
])
|
||||
);
|
||||
|
||||
// Create agent with MCP tools
|
||||
const agent = new Agent({
|
||||
instructions: 'You are a helpful assistant with access to MCP tools.',
|
||||
name: 'MCPTransportAgent',
|
||||
tools: mcpAuto.toOpenAITools(),
|
||||
toolFunctions
|
||||
});
|
||||
|
||||
// Use the agent
|
||||
const response = await agent.runSync('What tools are available?');
|
||||
console.log('Agent response:', response);
|
||||
|
||||
// Cleanup
|
||||
await mcpAuto.close();
|
||||
await mcpSSE.close();
|
||||
await mcpHTTP.close();
|
||||
await mcpAutoHTTP.close();
|
||||
}
|
||||
|
||||
// Run the example
|
||||
main().catch(console.error);
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "praisonai",
|
||||
"version": "1.7.2",
|
||||
"version": "1.7.3",
|
||||
"description": "PraisonAI TypeScript AI Agents Framework - Node.js, npm, and Javascript AI Agents Framework",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -114,4 +114,4 @@
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@ export {
|
||||
codeExecution, tavilySearch, tavilyExtract, tavilyCrawl,
|
||||
exaSearch, perplexitySearch, parallelSearch,
|
||||
firecrawlScrape, firecrawlCrawl,
|
||||
crwScrape, crwCrawl,
|
||||
superagentGuard, superagentRedact, superagentVerify,
|
||||
valyuWebSearch, valyuFinanceSearch, valyuPaperSearch, valyuBioSearch,
|
||||
valyuPatentSearch, valyuSecSearch, valyuEconomicsSearch, valyuCompanyResearch,
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface AgentOSConfig {
|
||||
/** Name of the application (default: "PraisonAI App") */
|
||||
name?: string;
|
||||
|
||||
/** Host address to bind to (default: "0.0.0.0") */
|
||||
/** Host address to bind to (default: "127.0.0.1") */
|
||||
host?: string;
|
||||
|
||||
/** Port number to listen on (default: 8000) */
|
||||
@@ -68,7 +68,7 @@ export interface AgentOSConfig {
|
||||
*/
|
||||
export const DEFAULT_AGENTOS_CONFIG: Required<Omit<AgentOSConfig, 'metadata'>> & { metadata: Record<string, any> } = {
|
||||
name: 'PraisonAI App',
|
||||
host: '0.0.0.0',
|
||||
host: '127.0.0.1',
|
||||
port: 8000,
|
||||
reload: false,
|
||||
corsOrigins: ['*'],
|
||||
@@ -89,7 +89,7 @@ export const DEFAULT_AGENTOS_CONFIG: Required<Omit<AgentOSConfig, 'metadata'>> &
|
||||
export function mergeConfig(userConfig?: AgentOSConfig): Required<Omit<AgentOSConfig, 'metadata'>> & { metadata: Record<string, any> } {
|
||||
const apiKey = userConfig?.apiKey ?? process.env.PRAISONAI_AGENTOS_API_KEY ?? '';
|
||||
|
||||
return {
|
||||
const merged = {
|
||||
...DEFAULT_AGENTOS_CONFIG,
|
||||
...userConfig,
|
||||
apiKey,
|
||||
@@ -98,6 +98,15 @@ export function mergeConfig(userConfig?: AgentOSConfig): Required<Omit<AgentOSCo
|
||||
...userConfig?.metadata,
|
||||
},
|
||||
};
|
||||
|
||||
const exposedHosts = new Set(['0.0.0.0', '::', '::0']);
|
||||
if (exposedHosts.has(merged.host) && !merged.apiKey) {
|
||||
throw new Error(
|
||||
'AgentOS requires apiKey (or PRAISONAI_AGENTOS_API_KEY) when binding to a network-exposed host'
|
||||
);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* fastCRW Tool
|
||||
*
|
||||
* Web scraping, crawling, and content extraction.
|
||||
* Firecrawl-compatible web scraper; single binary; self-host or cloud.
|
||||
* Reuses @mendable/firecrawl-js pointed at the fastCRW API.
|
||||
*/
|
||||
|
||||
import type { ToolMetadata, PraisonTool, ToolExecutionContext } from '../registry/types';
|
||||
import { MissingDependencyError, MissingEnvVarError } from '../registry/types';
|
||||
|
||||
/** Default fastCRW cloud base URL. Override via CRW_API_URL for self-host. */
|
||||
const DEFAULT_CRW_API_URL = 'https://fastcrw.com/api';
|
||||
|
||||
export const CRW_METADATA: ToolMetadata = {
|
||||
id: 'crw',
|
||||
displayName: 'fastCRW',
|
||||
description: 'Firecrawl-compatible web scraping, crawling, and content extraction for LLM-ready data',
|
||||
tags: ['scrape', 'crawl', 'extract', 'web'],
|
||||
requiredEnv: ['CRW_API_KEY'],
|
||||
optionalEnv: ['CRW_API_URL'],
|
||||
install: {
|
||||
npm: 'npm install @mendable/firecrawl-js',
|
||||
pnpm: 'pnpm add @mendable/firecrawl-js',
|
||||
yarn: 'yarn add @mendable/firecrawl-js',
|
||||
bun: 'bun add @mendable/firecrawl-js',
|
||||
},
|
||||
docsSlug: 'tools/crw',
|
||||
capabilities: {
|
||||
crawl: true,
|
||||
extract: true,
|
||||
},
|
||||
packageName: '@mendable/firecrawl-js',
|
||||
};
|
||||
|
||||
export interface CrwScrapeConfig {
|
||||
formats?: ('markdown' | 'html' | 'rawHtml' | 'links' | 'screenshot')[];
|
||||
onlyMainContent?: boolean;
|
||||
includeTags?: string[];
|
||||
excludeTags?: string[];
|
||||
waitFor?: number;
|
||||
}
|
||||
|
||||
export interface CrwCrawlConfig {
|
||||
limit?: number;
|
||||
maxDepth?: number;
|
||||
includePaths?: string[];
|
||||
excludePaths?: string[];
|
||||
allowBackwardLinks?: boolean;
|
||||
allowExternalLinks?: boolean;
|
||||
}
|
||||
|
||||
export interface CrwScrapeInput {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface CrwCrawlInput {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface CrwScrapeResult {
|
||||
content: string;
|
||||
markdown?: string;
|
||||
html?: string;
|
||||
links?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CrwCrawlResult {
|
||||
pages: Array<{
|
||||
url: string;
|
||||
content: string;
|
||||
markdown?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function loadCrwPackage() {
|
||||
if (!process.env.CRW_API_KEY) {
|
||||
throw new MissingEnvVarError(
|
||||
CRW_METADATA.id,
|
||||
'CRW_API_KEY',
|
||||
CRW_METADATA.docsSlug
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await import('@mendable/firecrawl-js');
|
||||
} catch {
|
||||
throw new MissingDependencyError(
|
||||
CRW_METADATA.id,
|
||||
CRW_METADATA.packageName,
|
||||
CRW_METADATA.install,
|
||||
CRW_METADATA.requiredEnv,
|
||||
CRW_METADATA.docsSlug
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fastCRW Scrape tool.
|
||||
*
|
||||
* Scrapes a single web page using the Firecrawl-compatible fastCRW API,
|
||||
* returning LLM-ready content in markdown and optionally HTML, links, and metadata.
|
||||
*
|
||||
* @param config - Optional scraping configuration (formats, content filters, wait time).
|
||||
* @returns A PraisonTool that scrapes a single web page.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { crwScrape } from 'praisonai';
|
||||
*
|
||||
* const scraper = crwScrape({ formats: ['markdown', 'links'], onlyMainContent: true });
|
||||
* const result = await scraper.execute({ url: 'https://example.com' });
|
||||
* console.log(result.content);
|
||||
* ```
|
||||
*/
|
||||
export function crwScrape(config?: CrwScrapeConfig): PraisonTool<CrwScrapeInput, CrwScrapeResult> {
|
||||
return {
|
||||
name: 'crwScrape',
|
||||
description: 'Scrape a single web page and extract its content in LLM-ready format.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'The URL to scrape',
|
||||
},
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
execute: async (input: CrwScrapeInput, context?: ToolExecutionContext): Promise<CrwScrapeResult> => {
|
||||
const pkg = await loadCrwPackage();
|
||||
|
||||
// fastCRW is Firecrawl-compatible: reuse the Firecrawl client with a custom apiUrl
|
||||
const FirecrawlApp = (pkg as Record<string, unknown>).default || pkg;
|
||||
if (typeof FirecrawlApp === 'function') {
|
||||
const app = new (FirecrawlApp as new (options: { apiKey: string; apiUrl: string }) => {
|
||||
scrapeUrl: (url: string, options?: CrwScrapeConfig) => Promise<{ data?: { markdown?: string; html?: string; links?: string[]; metadata?: Record<string, unknown> } }>;
|
||||
})({ apiKey: process.env.CRW_API_KEY!, apiUrl: process.env.CRW_API_URL || DEFAULT_CRW_API_URL });
|
||||
|
||||
const result = await app.scrapeUrl(input.url, config);
|
||||
return {
|
||||
content: result.data?.markdown || '',
|
||||
markdown: result.data?.markdown,
|
||||
html: result.data?.html,
|
||||
links: result.data?.links,
|
||||
metadata: result.data?.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
return { content: '' };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fastCRW Crawl tool.
|
||||
*
|
||||
* Crawls a website starting from a URL, following links and extracting content
|
||||
* from multiple pages using the Firecrawl-compatible fastCRW API.
|
||||
*
|
||||
* @param config - Optional crawl configuration (depth limits, path filters, link policies).
|
||||
* @returns A PraisonTool that crawls a website and extracts content from multiple pages.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { crwCrawl } from 'praisonai';
|
||||
*
|
||||
* const crawler = crwCrawl({ limit: 10, maxDepth: 2, includePaths: ['/docs'] });
|
||||
* const result = await crawler.execute({ url: 'https://example.com' });
|
||||
* console.log(result.pages.length, 'pages crawled');
|
||||
* ```
|
||||
*/
|
||||
export function crwCrawl(config?: CrwCrawlConfig): PraisonTool<CrwCrawlInput, CrwCrawlResult> {
|
||||
return {
|
||||
name: 'crwCrawl',
|
||||
description: 'Crawl a website and extract content from multiple pages.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'The starting URL to crawl',
|
||||
},
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
execute: async (input: CrwCrawlInput, context?: ToolExecutionContext): Promise<CrwCrawlResult> => {
|
||||
const pkg = await loadCrwPackage();
|
||||
|
||||
const FirecrawlApp = (pkg as Record<string, unknown>).default || pkg;
|
||||
if (typeof FirecrawlApp === 'function') {
|
||||
const app = new (FirecrawlApp as new (options: { apiKey: string; apiUrl: string }) => {
|
||||
crawlUrl: (url: string, options?: CrwCrawlConfig) => Promise<{ data?: Array<{ url: string; markdown?: string }> }>;
|
||||
})({ apiKey: process.env.CRW_API_KEY!, apiUrl: process.env.CRW_API_URL || DEFAULT_CRW_API_URL });
|
||||
|
||||
const result = await app.crawlUrl(input.url, config);
|
||||
return {
|
||||
pages: (result.data || []).map(page => ({
|
||||
url: page.url,
|
||||
content: page.markdown || '',
|
||||
markdown: page.markdown,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return { pages: [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory functions for registry
|
||||
*/
|
||||
export function createCrwScrapeTool(config?: CrwScrapeConfig): PraisonTool<unknown, unknown> {
|
||||
return crwScrape(config) as PraisonTool<unknown, unknown>;
|
||||
}
|
||||
|
||||
export function createCrwCrawlTool(config?: CrwCrawlConfig): PraisonTool<unknown, unknown> {
|
||||
return crwCrawl(config) as PraisonTool<unknown, unknown>;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export { EXA_METADATA } from './exa';
|
||||
export { PERPLEXITY_METADATA } from './perplexity';
|
||||
export { PARALLEL_METADATA } from './parallel';
|
||||
export { FIRECRAWL_METADATA } from './firecrawl';
|
||||
export { CRW_METADATA } from './crw';
|
||||
export { SUPERAGENT_METADATA } from './superagent';
|
||||
export { VALYU_METADATA } from './valyu';
|
||||
export { BEDROCK_AGENTCORE_METADATA } from './bedrock-agentcore';
|
||||
@@ -41,6 +42,10 @@ export type { ParallelSearchConfig, ParallelSearchInput, ParallelSearchResult }
|
||||
export { firecrawlScrape, firecrawlCrawl, createFirecrawlScrapeTool, createFirecrawlCrawlTool } from './firecrawl';
|
||||
export type { FirecrawlScrapeConfig, FirecrawlScrapeInput, FirecrawlScrapeResult, FirecrawlCrawlConfig, FirecrawlCrawlInput, FirecrawlCrawlResult } from './firecrawl';
|
||||
|
||||
// fastCRW
|
||||
export { crwScrape, crwCrawl, createCrwScrapeTool, createCrwCrawlTool } from './crw';
|
||||
export type { CrwScrapeConfig, CrwScrapeInput, CrwScrapeResult, CrwCrawlConfig, CrwCrawlInput, CrwCrawlResult } from './crw';
|
||||
|
||||
// Superagent
|
||||
export { superagentGuard, superagentRedact, superagentVerify, createSuperagentGuardTool, createSuperagentRedactTool, createSuperagentVerifyTool } from './superagent';
|
||||
export type { GuardConfig, GuardInput, GuardResult, RedactConfig, RedactInput, RedactResult, VerifyConfig, VerifyInput, VerifyResult } from './superagent';
|
||||
@@ -90,6 +95,7 @@ export async function getAllBuiltinMetadata() {
|
||||
const { PERPLEXITY_METADATA } = await import('./perplexity');
|
||||
const { PARALLEL_METADATA } = await import('./parallel');
|
||||
const { FIRECRAWL_METADATA } = await import('./firecrawl');
|
||||
const { CRW_METADATA } = await import('./crw');
|
||||
const { SUPERAGENT_METADATA } = await import('./superagent');
|
||||
const { VALYU_METADATA } = await import('./valyu');
|
||||
const { BEDROCK_AGENTCORE_METADATA } = await import('./bedrock-agentcore');
|
||||
@@ -103,6 +109,7 @@ export async function getAllBuiltinMetadata() {
|
||||
PERPLEXITY_METADATA,
|
||||
PARALLEL_METADATA,
|
||||
FIRECRAWL_METADATA,
|
||||
CRW_METADATA,
|
||||
SUPERAGENT_METADATA,
|
||||
VALYU_METADATA,
|
||||
BEDROCK_AGENTCORE_METADATA,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { MCPTool, MCPToolInfo, MCP as SSEMCP } from './mcpSse';
|
||||
import { HTTPStreamingTransport, MCPHttpStreaming } from './mcpHttpStreaming';
|
||||
|
||||
export type TransportType = 'auto' | 'sse' | 'http-streaming';
|
||||
|
||||
export class MCP implements Iterable<MCPTool> {
|
||||
tools: MCPTool[] = [];
|
||||
private client: Client | null = null;
|
||||
private transport: TransportType;
|
||||
private actualTransport: 'sse' | 'http-streaming' | null = null;
|
||||
|
||||
constructor(
|
||||
private url: string,
|
||||
transport: TransportType = 'auto',
|
||||
private debug = false
|
||||
) {
|
||||
this.transport = transport;
|
||||
|
||||
// Auto-detect transport type based on URL
|
||||
if (transport === 'auto') {
|
||||
this.actualTransport = url.endsWith('/sse') ? 'sse' : 'http-streaming';
|
||||
} else if (transport === 'sse' || transport === 'http-streaming') {
|
||||
this.actualTransport = transport;
|
||||
} else {
|
||||
throw new Error(`Unknown transport type: ${transport}`);
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(`MCP client initialized for URL: ${url} with transport: ${this.actualTransport}`);
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.client) {
|
||||
if (this.debug) console.log('MCP client already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.client = new Client({ name: 'praisonai-ts-mcp', version: '1.0.0' });
|
||||
|
||||
// Create transport based on selection
|
||||
let transport;
|
||||
if (this.actualTransport === 'sse') {
|
||||
transport = new SSEClientTransport(new URL(this.url));
|
||||
} else if (this.actualTransport === 'http-streaming') {
|
||||
transport = new HTTPStreamingTransport(new URL(this.url));
|
||||
} else {
|
||||
throw new Error(`Invalid transport type: ${this.actualTransport}`);
|
||||
}
|
||||
|
||||
await this.client.connect(transport);
|
||||
const { tools } = await this.client.listTools();
|
||||
this.tools = tools.map((t: any) => new MCPTool({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema
|
||||
}, this.client as Client));
|
||||
|
||||
if (this.debug) {
|
||||
console.log(`Initialized MCP with ${this.tools.length} tools using ${this.actualTransport} transport`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.client) {
|
||||
await this.client.close().catch(() => {});
|
||||
this.client = null;
|
||||
}
|
||||
throw new Error(`Failed to initialize MCP client: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
[Symbol.iterator](): Iterator<MCPTool> {
|
||||
return this.tools[Symbol.iterator]();
|
||||
}
|
||||
|
||||
toOpenAITools() {
|
||||
return this.tools.map(t => t.toOpenAITool());
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.client) {
|
||||
try {
|
||||
await this.client.close();
|
||||
} catch (error) {
|
||||
if (this.debug) {
|
||||
console.warn('Error closing MCP client:', error);
|
||||
}
|
||||
} finally {
|
||||
this.client = null;
|
||||
this.tools = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.client !== null;
|
||||
}
|
||||
|
||||
get transportType(): string {
|
||||
return this.actualTransport || 'not initialized';
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export components for backward compatibility
|
||||
export { MCPTool, MCPToolInfo } from './mcpSse';
|
||||
export { HTTPStreamingTransport } from './mcpHttpStreaming';
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import { MCPTool, MCPToolInfo } from './mcpSse';
|
||||
|
||||
interface ToolDefinition {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class HTTPStreamingTransport implements Transport {
|
||||
private url: URL;
|
||||
private headers: Record<string, string>;
|
||||
private closed = false;
|
||||
private reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||
private writer: WritableStreamDefaultWriter<Uint8Array> | null = null;
|
||||
private messageQueue: Array<any> = [];
|
||||
private initialized = false;
|
||||
|
||||
constructor(url: URL, headers: Record<string, string> = {}) {
|
||||
this.url = url;
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Minimal implementation: mark as initialized
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
if (this.reader) {
|
||||
await this.reader.cancel();
|
||||
this.reader = null;
|
||||
}
|
||||
if (this.writer) {
|
||||
await this.writer.close();
|
||||
this.writer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async send(message: any): Promise<void> {
|
||||
if (this.closed) {
|
||||
throw new Error('Transport is closed');
|
||||
}
|
||||
// Minimal implementation: process message locally
|
||||
// In a real implementation, this would send via HTTP
|
||||
if (message.method === 'initialize') {
|
||||
const response = {
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
result: {
|
||||
protocolVersion: '0.1.0',
|
||||
capabilities: {}
|
||||
}
|
||||
};
|
||||
this.messageQueue.push(response);
|
||||
} else if (message.method === 'tools/list') {
|
||||
const response = {
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
result: {
|
||||
tools: []
|
||||
}
|
||||
};
|
||||
this.messageQueue.push(response);
|
||||
}
|
||||
}
|
||||
|
||||
async receive(): Promise<any> {
|
||||
if (this.closed) {
|
||||
throw new Error('Transport is closed');
|
||||
}
|
||||
// Minimal implementation: return queued messages
|
||||
if (this.messageQueue.length > 0) {
|
||||
return this.messageQueue.shift();
|
||||
}
|
||||
// Return empty response if no messages
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({ jsonrpc: "2.0", id: null, result: {} });
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class MCPHttpStreaming implements Iterable<MCPTool> {
|
||||
tools: MCPTool[] = [];
|
||||
private client: Client | null = null;
|
||||
|
||||
constructor(private url: string, private debug = false) {
|
||||
if (debug) {
|
||||
console.log(`MCP HTTP-Streaming client initialized for URL: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.client) {
|
||||
if (this.debug) console.log('MCP HTTP-Streaming client already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.client = new Client({ name: 'praisonai-ts-mcp', version: '1.0.0' });
|
||||
const transport = new HTTPStreamingTransport(new URL(this.url));
|
||||
await this.client.connect(transport);
|
||||
const { tools } = await this.client.listTools();
|
||||
this.tools = tools.map((t: ToolDefinition) => new MCPTool({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema
|
||||
}, this.client as Client));
|
||||
|
||||
if (this.debug) console.log(`Initialized MCP HTTP-Streaming with ${this.tools.length} tools`);
|
||||
} catch (error) {
|
||||
if (this.client) {
|
||||
await this.client.close().catch(() => {});
|
||||
this.client = null;
|
||||
}
|
||||
throw new Error(`Failed to initialize MCP HTTP-Streaming client: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
[Symbol.iterator](): Iterator<MCPTool> {
|
||||
return this.tools[Symbol.iterator]();
|
||||
}
|
||||
|
||||
toOpenAITools() {
|
||||
return this.tools.map(t => t.toOpenAITool());
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.client) {
|
||||
try {
|
||||
await this.client.close();
|
||||
} catch (error) {
|
||||
if (this.debug) {
|
||||
console.warn('Error closing MCP HTTP-Streaming client:', error);
|
||||
}
|
||||
} finally {
|
||||
this.client = null;
|
||||
this.tools = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.client !== null;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { exaSearch, EXA_METADATA, createExaSearchTool } from './builtins/exa';
|
||||
import { perplexitySearch, PERPLEXITY_METADATA, createPerplexitySearchTool } from './builtins/perplexity';
|
||||
import { parallelSearch, PARALLEL_METADATA, createParallelSearchTool } from './builtins/parallel';
|
||||
import { firecrawlScrape, firecrawlCrawl, FIRECRAWL_METADATA, createFirecrawlScrapeTool, createFirecrawlCrawlTool } from './builtins/firecrawl';
|
||||
import { crwScrape, crwCrawl, CRW_METADATA, createCrwScrapeTool, createCrwCrawlTool } from './builtins/crw';
|
||||
import { superagentGuard, superagentRedact, superagentVerify, SUPERAGENT_METADATA, createSuperagentGuardTool, createSuperagentRedactTool, createSuperagentVerifyTool } from './builtins/superagent';
|
||||
import {
|
||||
valyuWebSearch, valyuFinanceSearch, valyuPaperSearch, valyuBioSearch,
|
||||
@@ -42,6 +43,7 @@ import type { ExaSearchConfig } from './builtins/exa';
|
||||
import type { PerplexitySearchConfig } from './builtins/perplexity';
|
||||
import type { ParallelSearchConfig } from './builtins/parallel';
|
||||
import type { FirecrawlScrapeConfig, FirecrawlCrawlConfig } from './builtins/firecrawl';
|
||||
import type { CrwScrapeConfig, CrwCrawlConfig } from './builtins/crw';
|
||||
import type { GuardConfig, RedactConfig, VerifyConfig } from './builtins/superagent';
|
||||
import type { ValyuSearchConfig } from './builtins/valyu';
|
||||
import type { CodeInterpreterConfig, BrowserConfig } from './builtins/bedrock-agentcore';
|
||||
@@ -77,6 +79,10 @@ export function registerBuiltinTools(): void {
|
||||
registry.register({ ...FIRECRAWL_METADATA, id: 'firecrawl-scrape' }, createFirecrawlScrapeTool as ToolFactory);
|
||||
registry.register({ ...FIRECRAWL_METADATA, id: 'firecrawl-crawl' }, createFirecrawlCrawlTool as ToolFactory);
|
||||
|
||||
// fastCRW
|
||||
registry.register({ ...CRW_METADATA, id: 'crw-scrape' }, createCrwScrapeTool as ToolFactory);
|
||||
registry.register({ ...CRW_METADATA, id: 'crw-crawl' }, createCrwCrawlTool as ToolFactory);
|
||||
|
||||
// Superagent
|
||||
registry.register({ ...SUPERAGENT_METADATA, id: 'superagent-guard' }, createSuperagentGuardTool as ToolFactory);
|
||||
registry.register({ ...SUPERAGENT_METADATA, id: 'superagent-redact' }, createSuperagentRedactTool as ToolFactory);
|
||||
@@ -136,6 +142,11 @@ export const tools = {
|
||||
firecrawlScrape: (config?: FirecrawlScrapeConfig) => firecrawlScrape(config),
|
||||
firecrawlCrawl: (config?: FirecrawlCrawlConfig) => firecrawlCrawl(config),
|
||||
|
||||
// fastCRW
|
||||
crw: (config?: CrwScrapeConfig) => crwScrape(config),
|
||||
crwScrape: (config?: CrwScrapeConfig) => crwScrape(config),
|
||||
crwCrawl: (config?: CrwCrawlConfig) => crwCrawl(config),
|
||||
|
||||
// Superagent (Security)
|
||||
guard: (config?: GuardConfig) => superagentGuard(config),
|
||||
redact: (config?: RedactConfig) => superagentRedact(config),
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('AgentOSConfig', () => {
|
||||
describe('DEFAULT_AGENTOS_CONFIG', () => {
|
||||
it('should have correct default values', () => {
|
||||
expect(DEFAULT_AGENTOS_CONFIG.name).toBe('PraisonAI App');
|
||||
expect(DEFAULT_AGENTOS_CONFIG.host).toBe('0.0.0.0');
|
||||
expect(DEFAULT_AGENTOS_CONFIG.host).toBe('127.0.0.1');
|
||||
expect(DEFAULT_AGENTOS_CONFIG.port).toBe(8000);
|
||||
expect(DEFAULT_AGENTOS_CONFIG.reload).toBe(false);
|
||||
expect(DEFAULT_AGENTOS_CONFIG.corsOrigins).toEqual(['*']);
|
||||
@@ -47,10 +47,15 @@ describe('AgentOSConfig', () => {
|
||||
expect(result.port).toBe(9000);
|
||||
expect(result.debug).toBe(true);
|
||||
// Defaults preserved
|
||||
expect(result.host).toBe('0.0.0.0');
|
||||
expect(result.host).toBe('127.0.0.1');
|
||||
expect(result.corsOrigins).toEqual(['*']);
|
||||
});
|
||||
|
||||
it('requires api key when binding to 0.0.0.0', () => {
|
||||
expect(() => mergeConfig({ host: '0.0.0.0' })).toThrow(/apiKey/);
|
||||
expect(() => mergeConfig({ host: '0.0.0.0', apiKey: 'secret' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('should merge metadata objects', () => {
|
||||
const userConfig: AgentOSConfig = {
|
||||
metadata: { key: 'value' },
|
||||
|
||||
@@ -3,8 +3,8 @@ class Praisonai < Formula
|
||||
|
||||
desc "AI tools for various AI applications"
|
||||
homepage "https://github.com/MervinPraison/PraisonAI"
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.76.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.76.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.80.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.80.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -6,6 +6,7 @@ handling nested event loop scenarios without creating a new event loop
|
||||
on every call (which is expensive and breaks multi-agent workflows).
|
||||
"""
|
||||
import asyncio
|
||||
import atexit
|
||||
import os
|
||||
import threading
|
||||
import concurrent.futures
|
||||
@@ -24,6 +25,10 @@ class AsyncBridge:
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._lock = threading.Lock()
|
||||
# Identity of the thread currently holding ``self._lock``. Set under the
|
||||
# lock by ``get()``/``submit()`` so ``_spawn_locked()`` can verify the
|
||||
# *caller* (not merely *someone*) owns the lock.
|
||||
self._lock_owner: int | None = None
|
||||
|
||||
def _spawn_locked(self) -> asyncio.AbstractEventLoop:
|
||||
"""Create the loop+thread; caller must hold ``self._lock``.
|
||||
@@ -31,11 +36,17 @@ class AsyncBridge:
|
||||
The thread is marked ``daemon=True`` so that short-lived scripts
|
||||
(e.g. CLI commands, smoke tests) exit cleanly without waiting on
|
||||
the background loop to be shut down explicitly. Long-running
|
||||
server processes should call :func:`shutdown` explicitly to cancel
|
||||
server processes should call :meth:`shutdown` explicitly to cancel
|
||||
in-flight tasks before process exit.
|
||||
"""
|
||||
# Create the loop+thread; caller must hold self._lock.
|
||||
# The lock contract is now enforced structurally by submit() method.
|
||||
# Enforce the "caller must hold the lock" contract. We compare the
|
||||
# recorded lock owner against the current thread so the check detects
|
||||
# both an unlocked lock *and* a lock held by a different thread (a bare
|
||||
# non-blocking acquire could only prove the lock was free for *anyone*).
|
||||
if self._lock_owner != threading.get_ident():
|
||||
raise AssertionError(
|
||||
"_spawn_locked() must be called while holding self._lock"
|
||||
)
|
||||
if self._loop is None or self._loop.is_closed():
|
||||
self._loop = asyncio.new_event_loop()
|
||||
self._thread = threading.Thread(
|
||||
@@ -48,13 +59,21 @@ class AsyncBridge:
|
||||
|
||||
def get(self) -> asyncio.AbstractEventLoop:
|
||||
with self._lock:
|
||||
return self._spawn_locked()
|
||||
self._lock_owner = threading.get_ident()
|
||||
try:
|
||||
return self._spawn_locked()
|
||||
finally:
|
||||
self._lock_owner = None
|
||||
|
||||
def submit(self, coro):
|
||||
"""Atomically (re)spawn loop if needed and submit coro. Returns concurrent.futures.Future."""
|
||||
with self._lock:
|
||||
loop = self._spawn_locked()
|
||||
return asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
self._lock_owner = threading.get_ident()
|
||||
try:
|
||||
loop = self._spawn_locked()
|
||||
return asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
finally:
|
||||
self._lock_owner = None
|
||||
|
||||
def run_sync(self, coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) -> T:
|
||||
try:
|
||||
@@ -112,17 +131,41 @@ class AsyncBridge:
|
||||
if not loop.is_closed():
|
||||
loop.close()
|
||||
|
||||
# Backwards-compatible module-level shared default:
|
||||
_DEFAULT_BRIDGE: AsyncBridge | None = None
|
||||
_DEFAULT_LOCK = threading.Lock()
|
||||
# Backwards-compatible module-level shared default.
|
||||
#
|
||||
# The shared bridge's lifecycle is owned exclusively by the process (via the
|
||||
# ``atexit`` hook registered on first use). It is intentionally NOT exposed as a
|
||||
# public ``shutdown()`` on the module surface: in a multi-agent / multi-tenant
|
||||
# process every ``run_sync()`` caller shares this single loop+thread, so allowing
|
||||
# any one caller to tear it down would silently cancel in-flight work belonging
|
||||
# to every other agent/session. Embedders that need explicit lifecycle control
|
||||
# must construct and own their own :class:`AsyncBridge` instance and pass it via
|
||||
# dependency injection.
|
||||
|
||||
# Single stable shared instance. It is created eagerly (cheap: no loop/thread is
|
||||
# spawned until the first ``run_sync()`` call) so the reference never has to be
|
||||
# rebound. Keeping the *same* object means callers that captured ``_BG`` always
|
||||
# observe its live state, and only the process-exit hook tears it down.
|
||||
_BG: AsyncBridge = AsyncBridge()
|
||||
|
||||
|
||||
def _default_bridge() -> AsyncBridge:
|
||||
global _DEFAULT_BRIDGE
|
||||
if _DEFAULT_BRIDGE is None:
|
||||
with _DEFAULT_LOCK:
|
||||
if _DEFAULT_BRIDGE is None:
|
||||
_DEFAULT_BRIDGE = AsyncBridge()
|
||||
return _DEFAULT_BRIDGE
|
||||
return _BG
|
||||
|
||||
|
||||
def _shutdown_default() -> None:
|
||||
"""Private process-exit hook: tear down the shared default bridge.
|
||||
|
||||
Registered with :mod:`atexit`. This is the ONLY sanctioned way the shared
|
||||
default is shut down; it is deliberately absent from the public module
|
||||
surface so a single caller cannot terminate async work for the whole
|
||||
process. Embedders needing explicit lifecycle control own their own
|
||||
:class:`AsyncBridge` instance.
|
||||
"""
|
||||
_BG.shutdown()
|
||||
|
||||
|
||||
atexit.register(_shutdown_default)
|
||||
|
||||
|
||||
def run_sync(coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) -> T:
|
||||
@@ -145,12 +188,3 @@ def run_sync(coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) ->
|
||||
Any exception raised by the coroutine
|
||||
"""
|
||||
return _default_bridge().run_sync(coro, timeout=timeout)
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
"""Shut down ONLY the shared default bridge (not user-owned instances)."""
|
||||
global _DEFAULT_BRIDGE
|
||||
with _DEFAULT_LOCK:
|
||||
if _DEFAULT_BRIDGE is not None:
|
||||
_DEFAULT_BRIDGE.shutdown()
|
||||
_DEFAULT_BRIDGE = None
|
||||
@@ -10,16 +10,8 @@ from typing import Any
|
||||
|
||||
def _build_config_list() -> list[dict[str, Any]]:
|
||||
"""Reuse the same env/keyfile resolution the CLI already performs."""
|
||||
# Import LLM endpoint resolution from the same path the CLI uses
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint()
|
||||
|
||||
return [{
|
||||
'model': ep.model,
|
||||
'base_url': ep.base_url,
|
||||
'api_key': ep.api_key,
|
||||
'api_type': 'openai' # AutoGen expects this field
|
||||
}]
|
||||
from praisonai.llm.config import build_config_list
|
||||
return build_config_list()
|
||||
|
||||
|
||||
def run(agent_file: str,
|
||||
|
||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user