mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 01:08:39 -10:00
Compare commits
42
Commits
No files matched your search
@@ -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,
|
||||
};
|
||||
@@ -77,6 +77,31 @@ assert('issues event never blocks', !mg.hasBlockingClaudeRunForPr(
|
||||
'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([
|
||||
@@ -87,4 +112,15 @@ assert('sdk with tests ok', mg.missingTestsReason([
|
||||
// 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);
|
||||
@@ -75,14 +75,53 @@ function hasRecentClaudeTrigger(comments, minutes = 35) {
|
||||
});
|
||||
}
|
||||
|
||||
function hasRecentConflictComment(comments) {
|
||||
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;
|
||||
return comments.some((c) => {
|
||||
if (!AUTO_ACTORS.includes(c.user.login)) return false;
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!body.includes('@claude') || !body.includes('merge conflict')) return false;
|
||||
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) {
|
||||
@@ -266,11 +305,35 @@ function isCiOnlyChange(files) {
|
||||
return files.every((f) => CI_ONLY_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
|
||||
}
|
||||
|
||||
async function resolvePrNumberFromWorkflowRun(github, owner, repo, workflowRun) {
|
||||
const linked = workflowRun.pull_requests || [];
|
||||
if (linked.length > 0 && linked[0].number) return linked[0].number;
|
||||
const branch = workflowRun.head_branch;
|
||||
if (!branch) return null;
|
||||
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,
|
||||
@@ -281,6 +344,20 @@ async function resolvePrNumberFromWorkflowRun(github, owner, repo, workflowRun)
|
||||
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, {
|
||||
@@ -522,7 +599,9 @@ async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, op
|
||||
reasons.push('fork PR');
|
||||
}
|
||||
|
||||
if (hasRecentConflictComment(ctx.comments)) reasons.push('recent merge-conflict @claude');
|
||||
if (hasRecentConflictComment(ctx.comments, ctx.headPushedAt)) {
|
||||
reasons.push('recent merge-conflict @claude');
|
||||
}
|
||||
if (!skipRecentClaudeCooldown && hasRecentClaudeTrigger(ctx.comments, 35)) {
|
||||
reasons.push('recent @claude within 35min');
|
||||
}
|
||||
@@ -625,6 +704,9 @@ module.exports = {
|
||||
isClaudeTriggerNoise,
|
||||
hasRecentClaudeTrigger,
|
||||
hasRecentConflictComment,
|
||||
isConflictRebaseTriggerComment,
|
||||
isConflictRebaseCompletionComment,
|
||||
conflictRebaseQuiescent,
|
||||
hasHumanChangesRequested,
|
||||
hasAnyChangesRequested,
|
||||
hasFinalClaudeReviewTrigger,
|
||||
@@ -635,6 +717,9 @@ module.exports = {
|
||||
claudeRunBlocksPr,
|
||||
hasBlockingClaudeRunForPr,
|
||||
isCiOnlyChange,
|
||||
isInternalPullRequestLink,
|
||||
resolvePrNumberFromLinkedPullRequests,
|
||||
resolvePrNumberFromHeadBranch,
|
||||
resolvePrNumberFromWorkflowRun,
|
||||
WORKFLOW_ONLY_LABEL,
|
||||
getAgentPyChange,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -216,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
|
||||
@@ -327,6 +331,19 @@ jobs:
|
||||
});
|
||||
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)
|
||||
# ================================================================
|
||||
@@ -342,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
|
||||
@@ -434,6 +455,19 @@ jobs:
|
||||
});
|
||||
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.
|
||||
@@ -551,6 +585,17 @@ jobs:
|
||||
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'
|
||||
@@ -611,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);
|
||||
@@ -27,14 +27,18 @@ on:
|
||||
- Core Tests
|
||||
types:
|
||||
- completed
|
||||
repository_dispatch:
|
||||
types:
|
||||
- claude-merge-gate
|
||||
|
||||
concurrency:
|
||||
group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.workflow_run.id || 'scan' }}
|
||||
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
|
||||
@@ -56,8 +60,10 @@ jobs:
|
||||
|
||||
- name: Dispatch merge gate when PR pipeline is ready
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PAT: ${{ secrets.GH_TOKEN }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
@@ -65,6 +71,11 @@ jobs:
|
||||
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;
|
||||
@@ -93,12 +104,11 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: 'claude-merge-gate.yml',
|
||||
ref: 'main',
|
||||
inputs: { pr_number: String(prNumber) },
|
||||
event_type: 'claude-merge-gate',
|
||||
client_payload: { pr_number: prNumber },
|
||||
});
|
||||
core.info(`Dispatched merge gate for PR #${prNumber} after ${wr.name}`);
|
||||
|
||||
@@ -124,10 +134,11 @@ jobs:
|
||||
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 = '${{ github.event.inputs.pr_number }}'.trim();
|
||||
const inputPr = String(process.env.DISPATCH_PR_NUMBER || '').trim();
|
||||
|
||||
const candidates = [];
|
||||
const skipped = [];
|
||||
@@ -184,6 +195,8 @@ jobs:
|
||||
});
|
||||
}
|
||||
|
||||
await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 20 }, core);
|
||||
|
||||
claude-assess:
|
||||
needs: scan-candidates
|
||||
if: needs.scan-candidates.outputs.has_candidates == 'true'
|
||||
|
||||
@@ -336,3 +336,60 @@ jobs:
|
||||
mcp__github__get_issue_comments
|
||||
mcp__github__update_issue
|
||||
timeout_minutes: 30
|
||||
|
||||
- name: Kick review chain for bot-created PR
|
||||
if: |
|
||||
steps.check_fork.outputs.is_pr != 'true' &&
|
||||
(github.event_name == 'issues' || github.event_name == 'issue_comment') &&
|
||||
github.event.issue != null
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
|
||||
const issueNumber = context.payload.issue.number;
|
||||
await chain.kickReviewChainForIssue(
|
||||
github, context.repo.owner, context.repo.repo, issueNumber, core
|
||||
);
|
||||
|
||||
- name: Sync pipeline status labels for PR
|
||||
if: steps.check_fork.outputs.is_pr == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const prNumber = parseInt('${{ steps.check_fork.outputs.pr_number }}', 10);
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, prNumber, core);
|
||||
|
||||
- name: Dispatch merge gate when PR pipeline ready
|
||||
if: steps.check_fork.outputs.is_pr == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = parseInt('${{ steps.check_fork.outputs.pr_number }}', 10);
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, {
|
||||
skipGlobalClaudeRunCheck: true,
|
||||
skipRecentClaudeCooldown: true,
|
||||
}
|
||||
);
|
||||
if (!check.ready) {
|
||||
core.info(`PR #${prNumber} not ready for merge gate: ${check.reasons.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
event_type: 'claude-merge-gate',
|
||||
client_payload: { pr_number: prNumber },
|
||||
});
|
||||
core.info(`Dispatched merge gate for PR #${prNumber}`);
|
||||
@@ -0,0 +1,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
|
||||
);
|
||||
@@ -1377,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";
|
||||
|
||||
@@ -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)
|
||||
@@ -18,7 +18,7 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 20 | 42 | 11340 |
|
||||
| ✅ Agent | 21 | 42 | 11340 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2880 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 575 |
|
||||
| ✅ Approval | 1 | 3 | 1344 |
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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.
|
||||
@@ -1695,151 +1649,6 @@ Your Goal: {self.goal}"""
|
||||
task_name, task_description, task_id, response_format, stream_callback, emit_events
|
||||
)
|
||||
|
||||
async def _finalize_unified_achat_response(
|
||||
self,
|
||||
response,
|
||||
original_prompt,
|
||||
temperature,
|
||||
tools,
|
||||
output_json,
|
||||
output_pydantic,
|
||||
reasoning_steps,
|
||||
stream,
|
||||
messages,
|
||||
chat_history_length,
|
||||
start_time,
|
||||
task_name=None,
|
||||
task_description=None,
|
||||
task_id=None,
|
||||
):
|
||||
"""Post-process unified achat completion (parity with sync _chat_impl)."""
|
||||
if not response:
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
if hasattr(response, "choices") and response.choices:
|
||||
msg = response.choices[0].message
|
||||
content = getattr(msg, "content", None)
|
||||
response_text = content.strip() if content else ""
|
||||
if (
|
||||
reasoning_steps
|
||||
and getattr(msg, "reasoning_content", None)
|
||||
):
|
||||
response_text = msg.reasoning_content.strip()
|
||||
else:
|
||||
extracted = self._extract_llm_response_content(response)
|
||||
response_text = (extracted or "").strip() if isinstance(extracted, str) else str(extracted or "")
|
||||
|
||||
if output_json or output_pydantic:
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
self._persist_message("assistant", response_text)
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(
|
||||
response_text, original_prompt, temperature, tools,
|
||||
task_name, task_description, task_id,
|
||||
)
|
||||
self._execute_callback_and_display(
|
||||
original_prompt, validated_response, time.time() - start_time,
|
||||
task_name, task_description, task_id,
|
||||
)
|
||||
return await self._atrigger_after_agent_hook(
|
||||
original_prompt, validated_response, start_time,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
if not self.self_reflect:
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
self._persist_message("assistant", response_text)
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(
|
||||
response_text, original_prompt, temperature, tools,
|
||||
task_name, task_description, task_id,
|
||||
)
|
||||
self._execute_callback_and_display(
|
||||
original_prompt, validated_response, time.time() - start_time,
|
||||
task_name, task_description, task_id,
|
||||
)
|
||||
return await self._atrigger_after_agent_hook(
|
||||
original_prompt, validated_response, start_time,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
# Self-reflection (legacy OpenAI path)
|
||||
reflection_count = 0
|
||||
while True:
|
||||
reflection_prompt = f"""
|
||||
Reflect on your previous response: '{response_text}'.
|
||||
{self.reflect_prompt if self.reflect_prompt else "Identify any flaws, improvements, or actions."}
|
||||
Provide a "satisfactory" status ('yes' or 'no').
|
||||
Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
"""
|
||||
reflection_messages = messages + [
|
||||
{"role": "assistant", "content": response_text},
|
||||
{"role": "user", "content": reflection_prompt},
|
||||
]
|
||||
try:
|
||||
if self._openai_client is None:
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
return await self._atrigger_after_agent_hook(
|
||||
original_prompt, response_text, start_time,
|
||||
)
|
||||
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
|
||||
model=self.reflect_llm if self.reflect_llm else self.llm,
|
||||
messages=reflection_messages,
|
||||
temperature=temperature,
|
||||
response_format=_get_display_functions()['ReflectionOutput'],
|
||||
)
|
||||
reflection_output = reflection_response.choices[0].message.parsed
|
||||
if reflection_output.satisfactory == "yes" and reflection_count >= self.min_reflect - 1:
|
||||
break
|
||||
if reflection_count >= self.max_reflect - 1:
|
||||
break
|
||||
regenerate_messages = reflection_messages + [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
f"Self Reflection: {reflection_output.reflection} "
|
||||
f"Satisfactory?: {reflection_output.satisfactory}"
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": "Now regenerate your response using the reflection you made"},
|
||||
]
|
||||
new_response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
messages=regenerate_messages,
|
||||
temperature=temperature,
|
||||
)
|
||||
response_text = new_response.choices[0].message.content
|
||||
reflection_count += 1
|
||||
except Exception as e:
|
||||
logging.error("Reflection parsing failed.", exc_info=True)
|
||||
reflection_count += 1
|
||||
if reflection_count >= self.max_reflect:
|
||||
break
|
||||
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(
|
||||
response_text, original_prompt, temperature, tools,
|
||||
task_name, task_description, task_id,
|
||||
)
|
||||
self._execute_callback_and_display(
|
||||
original_prompt, validated_response, time.time() - start_time,
|
||||
task_name, task_description, task_id,
|
||||
)
|
||||
return await self._atrigger_after_agent_hook(
|
||||
original_prompt, validated_response, start_time,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for OpenAI client: {e}")
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
def _execute_callback_and_display(self, prompt: str, response: str, generation_time: float, task_name=None, task_description=None, task_id=None):
|
||||
"""Helper method to execute callbacks and display interaction.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -189,4 +189,4 @@ class PraisonAIRuntime:
|
||||
type="error",
|
||||
content=str(e),
|
||||
metadata={'runtime': self.runtime_id}
|
||||
)
|
||||
)
|
||||
@@ -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."""
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
"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`
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"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,
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
|
||||
| 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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -384,16 +384,8 @@ class BaseAutoGenerator:
|
||||
config_list: Optional LLM configuration list
|
||||
"""
|
||||
# Resolve LLM endpoint configuration from environment variables
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint()
|
||||
|
||||
self.config_list = config_list or [
|
||||
{
|
||||
'model': ep.model,
|
||||
'base_url': ep.base_url,
|
||||
'api_key': ep.api_key
|
||||
}
|
||||
]
|
||||
from praisonai.llm.config import build_config_list
|
||||
self.config_list = config_list or build_config_list(include_api_type=False)
|
||||
self._openai_client = None # lazy, per-instance
|
||||
self._async_openai_client = None # lazy, per-instance async client
|
||||
self._client_lock = threading.Lock()
|
||||
|
||||
@@ -112,6 +112,58 @@ def _get_agents_generator():
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
return AgentsGenerator
|
||||
|
||||
|
||||
def _provider_preflight_message():
|
||||
"""Return a guidance message if no LLM provider credential is configured.
|
||||
|
||||
PraisonAI is provider-agnostic (OpenAI, Anthropic, Google/Gemini, Groq,
|
||||
Cohere, Ollama, OpenRouter, plus 100+ via LiteLLM). Flows that call an LLM
|
||||
immediately — such as ``praisonai --init`` — should fail with clear,
|
||||
actionable guidance (pointing at ``praisonai setup``) instead of a raw stack
|
||||
trace when the user has not configured any provider yet.
|
||||
|
||||
Returns:
|
||||
A user-facing message string when no provider is configured, or ``None``
|
||||
when a provider credential is available (so the caller proceeds). The
|
||||
check itself never raises; on any unexpected error it returns ``None``
|
||||
so it can never block a properly configured user.
|
||||
"""
|
||||
try:
|
||||
from praisonai.llm.credentials import (
|
||||
inject_credentials_into_env,
|
||||
is_configured,
|
||||
)
|
||||
|
||||
# Mirror the runtime credential resolution so the gate cannot disagree
|
||||
# with what generation actually does:
|
||||
# 1. AutoGenerator resolves via env-only resolve_llm_endpoint(), so a
|
||||
# key stored via `praisonai setup` must be exported into the env
|
||||
# first or it never reaches the LLM call.
|
||||
# 2. The runtime model honours MODEL_NAME / OPENAI_MODEL_NAME, so gate
|
||||
# on that exact model (not the inferred provider default) — a stale
|
||||
# OpenAI model override with only a non-OpenAI key must still be
|
||||
# caught here instead of failing later with a raw auth error.
|
||||
inject_credentials_into_env()
|
||||
import os as _os
|
||||
runtime_model = _os.environ.get("MODEL_NAME") or _os.environ.get(
|
||||
"OPENAI_MODEL_NAME"
|
||||
)
|
||||
if is_configured(model=runtime_model):
|
||||
return None
|
||||
except Exception:
|
||||
return None # never block on the check itself
|
||||
return (
|
||||
"No LLM provider is configured.\n\n"
|
||||
"PraisonAI supports OpenAI, Anthropic, Google/Gemini, Groq, "
|
||||
"Cohere, Ollama, OpenRouter and 100+ models via LiteLLM.\n\n"
|
||||
"Easiest setup (interactive, no shell 'export' needed):\n"
|
||||
" praisonai setup\n\n"
|
||||
"Or set a provider API key, for example:\n"
|
||||
" export OPENAI_API_KEY=... # OpenAI\n"
|
||||
" export ANTHROPIC_API_KEY=... # Anthropic Claude\n"
|
||||
" export GEMINI_API_KEY=... # Google Gemini\n"
|
||||
)
|
||||
|
||||
# Use centralized availability detection
|
||||
from .._framework_availability import is_available
|
||||
|
||||
@@ -252,17 +304,8 @@ class PraisonAI:
|
||||
self._interactive_mode = False # Flag for interactive TUI mode
|
||||
# Create config_list with AutoGen compatibility
|
||||
# Resolve LLM endpoint configuration from environment variables
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint()
|
||||
|
||||
self.config_list = [
|
||||
{
|
||||
'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
|
||||
self.config_list = build_config_list()
|
||||
self.agent_file = agent_file
|
||||
self.framework = framework
|
||||
|
||||
@@ -742,6 +785,16 @@ class PraisonAI:
|
||||
self.topic = temp_topic
|
||||
|
||||
self.agent_file = "agents.yaml"
|
||||
|
||||
# Pre-flight: ensure an LLM provider credential is configured before
|
||||
# calling the LLM. Without this, a user with no API key (or a
|
||||
# non-OpenAI key for an OpenAI-default model) gets a raw stack trace
|
||||
# instead of clear, actionable guidance.
|
||||
preflight = _provider_preflight_message()
|
||||
if preflight:
|
||||
print(preflight)
|
||||
return preflight
|
||||
|
||||
AutoGenerator = _get_auto_generator()
|
||||
generator = AutoGenerator(topic=self.topic, framework=self.framework, agent_file=self.agent_file)
|
||||
self.agent_file = generator.generate(merge=getattr(args, 'merge', False))
|
||||
@@ -6902,14 +6955,6 @@ Provide a concise summary (max 200 words):"""
|
||||
execution_queue = session_state['execution_queue']
|
||||
execution_queue.put({'prompt': prompt})
|
||||
|
||||
def _process_interactive_prompt_async(self, prompt, tools_list, console, session_state=None):
|
||||
"""
|
||||
DEPRECATED: This method is kept for backward compatibility.
|
||||
Use _submit_prompt_to_worker instead for true non-blocking behavior.
|
||||
"""
|
||||
# Just submit to worker queue - non-blocking
|
||||
self._submit_prompt_to_worker(prompt, session_state)
|
||||
|
||||
def _process_interactive_prompt(self, prompt, tools_list, console, show_profiling=False, session_state=None):
|
||||
"""Process a prompt in interactive mode with streaming."""
|
||||
from rich.live import Live
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Shared AutoGen-style ``config_list`` builder.
|
||||
|
||||
Provides a single owner for turning a resolved LLM endpoint into the
|
||||
``[{model, base_url, api_key, api_type}]`` shape that AutoGen and the
|
||||
PraisonAI wrappers expect. Keeps endpoint->config mapping in one place so
|
||||
new fields or provider quirks only need to change here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_config_list(*, include_api_type: bool = True) -> list[dict[str, Any]]:
|
||||
"""Build an AutoGen-style ``config_list`` from the resolved endpoint.
|
||||
|
||||
Reuses the same env/keyfile resolution the CLI already performs via
|
||||
:func:`praisonai.llm.env.resolve_llm_endpoint`.
|
||||
|
||||
Args:
|
||||
include_api_type: When True, include ``api_type='openai'`` which
|
||||
AutoGen expects. Set False to match callers that historically
|
||||
omit it.
|
||||
|
||||
Returns:
|
||||
A single-entry list with the resolved model, base URL and API key.
|
||||
"""
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint()
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
'model': ep.model,
|
||||
'base_url': ep.base_url,
|
||||
'api_key': ep.api_key,
|
||||
}
|
||||
if include_api_type:
|
||||
entry['api_type'] = 'openai' # AutoGen expects this field
|
||||
return [entry]
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Unit tests for the `praisonai --init` provider pre-flight guard.
|
||||
|
||||
Verifies that when no LLM provider credential is configured, the guard returns a
|
||||
clear, actionable message (pointing at `praisonai setup`) instead of letting the
|
||||
`--init` flow call the LLM and surface a raw stack trace. When a provider IS
|
||||
configured, the guard must return None so generation proceeds.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
import praisonai.cli.main as cli_main
|
||||
from praisonai.cli.main import _provider_preflight_message
|
||||
except ImportError as e: # pragma: no cover - environment guard
|
||||
pytest.skip(f"Could not import praisonai.cli.main: {e}", allow_module_level=True)
|
||||
|
||||
|
||||
class TestProviderPreflightMessage:
|
||||
def test_returns_message_when_unconfigured(self):
|
||||
with patch("praisonai.llm.credentials.is_configured", return_value=False):
|
||||
msg = _provider_preflight_message()
|
||||
|
||||
assert msg is not None
|
||||
assert "No LLM provider is configured" in msg
|
||||
# Points beginners at the no-export interactive setup.
|
||||
assert "praisonai setup" in msg
|
||||
# Mentions multiple providers so users know it's not OpenAI-only.
|
||||
assert "Anthropic" in msg and "Gemini" in msg
|
||||
|
||||
def test_returns_none_when_configured(self):
|
||||
with patch("praisonai.llm.credentials.is_configured", return_value=True):
|
||||
assert _provider_preflight_message() is None
|
||||
|
||||
def test_never_blocks_on_internal_error(self):
|
||||
# If the credential check itself raises, the guard must not block a
|
||||
# potentially configured user (returns None -> generation proceeds).
|
||||
with patch(
|
||||
"praisonai.llm.credentials.is_configured",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
assert _provider_preflight_message() is None
|
||||
|
||||
def test_injects_stored_credentials_before_gating(self):
|
||||
# A key stored via `praisonai setup` must be exported into the env
|
||||
# before gating, otherwise the env-only AutoGenerator path would still
|
||||
# fail with a raw auth error despite the guard passing.
|
||||
with patch(
|
||||
"praisonai.llm.credentials.inject_credentials_into_env"
|
||||
) as inject, patch(
|
||||
"praisonai.llm.credentials.is_configured", return_value=True
|
||||
):
|
||||
assert _provider_preflight_message() is None
|
||||
inject.assert_called_once()
|
||||
|
||||
def test_gates_on_runtime_model_override(self):
|
||||
# With a stale OpenAI model override and only a non-OpenAI key, the
|
||||
# gate must use the exact runtime model so the mismatch is caught here
|
||||
# (message returned) rather than failing later at the LLM call.
|
||||
with patch.dict(
|
||||
"os.environ", {"MODEL_NAME": "gpt-4o-mini"}, clear=False
|
||||
), patch(
|
||||
"praisonai.llm.credentials.inject_credentials_into_env"
|
||||
), patch(
|
||||
"praisonai.llm.credentials.is_configured", return_value=False
|
||||
) as is_configured:
|
||||
msg = _provider_preflight_message()
|
||||
|
||||
assert msg is not None
|
||||
# The runtime model override must be passed through to the gate.
|
||||
is_configured.assert_called_once_with(model="gpt-4o-mini")
|
||||
|
||||
|
||||
class TestInitGuardWiring:
|
||||
def test_init_returns_early_without_calling_generator(self):
|
||||
# The real --init path must print guidance and return early WITHOUT
|
||||
# constructing the AutoGenerator when no provider is configured. This
|
||||
# protects against the guard being removed or moved after generation.
|
||||
instance = cli_main.PraisonAI.__new__(cli_main.PraisonAI)
|
||||
instance.agent_file = "agents.yaml"
|
||||
instance.config_list = [{"model": "gpt-4o-mini"}]
|
||||
instance.framework = None
|
||||
instance.auto = False
|
||||
instance.init = False
|
||||
instance.topic = ""
|
||||
|
||||
# Use a plain namespace (not MagicMock) so unset attributes don't read
|
||||
# as truthy and trip earlier branches before the --init guard.
|
||||
from types import SimpleNamespace
|
||||
|
||||
args = SimpleNamespace(
|
||||
command=None,
|
||||
framework=None,
|
||||
model=None,
|
||||
prompt_flag=None,
|
||||
file=None,
|
||||
direct_prompt=None,
|
||||
deploy=False,
|
||||
auto=False,
|
||||
init="build me a team",
|
||||
ui=None,
|
||||
merge=False,
|
||||
)
|
||||
|
||||
with patch.object(instance, "parse_args", return_value=(args, [])), patch.object(
|
||||
cli_main, "_load_env_once"
|
||||
), patch.object(
|
||||
instance, "read_stdin_if_available", return_value=None
|
||||
), patch.object(
|
||||
instance, "read_file_if_provided", return_value=None
|
||||
), patch.object(
|
||||
cli_main, "_provider_preflight_message", return_value="SETUP GUIDANCE"
|
||||
), patch.object(
|
||||
cli_main, "_get_auto_generator"
|
||||
) as get_gen, patch("builtins.print"):
|
||||
result = instance.main()
|
||||
|
||||
assert result == "SETUP GUIDANCE"
|
||||
get_gen.assert_not_called()
|
||||
Reference in new issue
Block a user