Compare commits

...
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison ac4eafc3a0 fix: detect single-backslash, home-relative and bare relative Windows paths
Addresses Gemini and Greptile review feedback: is_path_like now recognizes
\path, ~\path and relative paths containing backslashes (e.g. knowledge\docs)
so equivalent Windows configurations resolve as knowledge sources.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-27 02:07:57 +00:00
codingFeng101 efc07126ae Detect Windows knowledge paths 2026-06-27 09:54:36 +08:00
Mervin Praison 911db9d701 Merge pull request #2343 from MervinPraison/fix/pipeline-status-labels-merge-gate-dispatch
fix: pipeline status labels and reliable merge-gate dispatch
2026-06-26 23:48:04 +01:00
MervinPraison 91af284ccd docs: auto-update documentation parity trackers [skip ci] 2026-06-26 22:47:55 +00:00
Mervin Praison 3ab13e9f70 Merge pull request #2347 from MervinPraison/claude/issue-2344-20260626-1711
fix: remove unreferenced private methods in agent/ mixins
2026-06-26 23:47:24 +01:00
Mervin Praison acce8aab79 Merge pull request #2346 from MervinPraison/claude/issue-2345-20260626-1711
refactor: delegate _apply_tool_truncation_async to sync impl
2026-06-26 23:47:02 +01:00
Mervin Praison d75cf6b73b Merge pull request #2341 from MervinPraison/claude/issue-2340-20260626-1610
fix: remove dead deprecated _process_interactive_prompt_async
2026-06-26 23:46:33 +01:00
Mervin Praison b340b8d07d Merge pull request #2338 from MervinPraison/claude/issue-90-20260626-1428
fix: clear provider guidance on praisonai --init when no LLM key set
2026-06-26 23:46:13 +01:00
MervinPraison ec51edf33c docs: auto-update feature parity trackers [skip ci] 2026-06-26 22:45:59 +00:00
Mervin Praison 3aecf1ad54 Merge pull request #2335 from MervinPraison/claude/issue-1939-20260613-0842
feat: auto runtime selection with plugin harness registry
2026-06-26 23:45:34 +01:00
MervinPraison 63fe1366d2 docs: auto-update api.md [skip ci] 2026-06-26 20:34:46 +00:00
Mervin Praison c79e0f71ae Merge pull request #2342 from MervinPraison/claude/issue-2339-20260626-1610
fix: consolidate duplicate AutoGen config_list builder into shared helper
2026-06-26 21:34:28 +01:00
Mervin Praison 1891dade5d Merge pull request #2337 from MervinPraison/claude/issue-1341-20260409-1606
fix: add comment to shell_tools execute_command
2026-06-26 21:34:27 +01:00
Mervin Praison 7efa8fd458 Merge pull request #2336 from MervinPraison/claude/issue-722-20250705_065525
feat: MCP HTTP-Streaming support
2026-06-26 21:34:25 +01:00
praisonai-triage-agent[bot]andMervin Praison c23b733877 fix: preserve [tool-truncation-async] log tag via log_tag param
Addresses Greptile P2: async truncation was emitting the sync
[tool-truncation] log tag after delegation. Add an optional
log_tag param to the sync helper so the async wrapper can pass
"tool-truncation-async", restoring the diagnostic distinction
without re-introducing sync/async drift.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 17:26:45 +00:00
praisonai-triage-agent[bot]andMervin Praison 2699326ec6 fix: remove unreferenced private methods in agent/ mixins (fixes #2344)
Remove four dead private methods with zero callers across praisonaiagents/
and tests/:
- execution_mixin.py: _safe_sleep_async (sync _safe_sleep is the live path)
- unified_execution_mixin.py: _resolve_cli_backend_at_turn_time (placeholder)
- chat_mixin.py: _finalize_unified_achat_response (never invoked)
- chat_mixin.py: _process_stream_response (only an unrelated OpenAIClient test)

Pure dead-code cleanup; no behaviour or public API change.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 17:16:32 +00:00
praisonai-triage-agent[bot]andMervin Praison d1e26a1c1a fix: address reviewer feedback in merge-gate and pipeline-status scripts
- Reorder resolvePrNumberFromWorkflowRun to check linked PRs before head
  branch (Gemini): more accurate and avoids a pulls.list call
- Drop unreachable base.repo.name fallback in isInternalPullRequestLink
  (CodeRabbit): bare repo name can never match owner/repo
- Paginate labels and tolerate 422 in ensurePipelineLabels (Gemini/CodeRabbit)
- Skip full pagination when maxPrs <= 100 in syncOpenPullRequests (Gemini)

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 17:16:02 +00:00
praisonai-triage-agent[bot]andMervin Praison 4416a2afc7 refactor: delegate _apply_tool_truncation_async to sync impl (fixes #2345)
The async tool-truncation method was a byte-identical copy of the sync
version with no await points. Make it a thin delegate to eliminate ~28
duplicated lines and remove sync/async drift risk.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 17:13:18 +00:00
MervinPraisonandCursor 45b2052afb fix: pipeline status labels and reliable merge-gate dispatch
Filter fork sync PRs in resolvePrNumberFromWorkflowRun, dispatch merge gate
explicitly from claude.yml after PR runs, and sync pipeline/* stage/blocker
labels via auto-pr-comment, merge-gate scan, and 15-min sync workflow.

Co-authored-by: Cursor <[email protected]>
2026-06-26 18:03:33 +01:00
Mervin Praison 078bb69ad4 Merge pull request #2334 from MervinPraison/claude/issue-2251-20260626
fix: reliably kick review chain when bot PRs miss pull_request opened
2026-06-26 17:56:26 +01:00
praisonai-triage-agent[bot]andMervinPraison 5b84a84972 fix: consolidate duplicate AutoGen config_list builder into shared helper (fixes #2339)
Co-authored-by: MervinPraison <[email protected]>
2026-06-26 16:12:45 +00:00
praisonai-triage-agent[bot]andMervinPraison fb713566af fix: remove dead deprecated _process_interactive_prompt_async (fixes #2340)
Co-authored-by: MervinPraison <[email protected]>
2026-06-26 16:11:32 +00:00
praisonai-triage-agent[bot]andMervin Praison 85fa5719fd fix: align --init provider preflight with runtime credential/model resolution
Address Greptile P1 findings: the preflight gate could disagree with what
AutoGenerator actually does at runtime, so a user could pass the guard and
still hit a raw provider auth error.

- Inject stored credentials into env before gating so keys saved via
  `praisonai setup` reach the env-only AutoGenerator resolver.
- Gate on the exact runtime model (MODEL_NAME / OPENAI_MODEL_NAME) so a stale
  OpenAI override with only a non-OpenAI key is caught with guidance instead
  of failing later.
- Add regression tests: stored-credential injection, runtime model override
  gating, and the real PraisonAI.main() --init early-return path asserting the
  generator is never constructed.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 14:53:30 +00:00
claude[bot]andMervin Praison df99d777a3 fix: Address code review issues with minimal changes
- Implement functional HTTP-Streaming transport in Python and TypeScript
- Fix ClientSession initialization with proper read/write parameters
- Fix event loop handling and remove unused imports
- Improve error handling and initialization synchronization
- Both transports now use message queues to prevent runtime errors

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 14:40:42 +00:00
claude[bot]andMervin Praison d85fb91ec3 fix: Address code review issues with minimal changes
- Remove unused imports (json, List, get_session_from_context)
- Fix event loop handling to avoid conflicts with existing loops
- Add TypeScript interface for tool definitions instead of using any
- Update receive methods to return placeholder instead of throwing errors
- Simplify transport detection logic by removing unnecessary elif
- Add finally block to ensure init_done.set() is always called

These changes maintain backward compatibility while fixing the critical
runtime issues identified in the code review.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 14:40:38 +00:00
claude[bot]andMervin Praison 11f43e9fbd feat: Add HTTP-Streaming support for MCP with backward compatibility
- Add HTTP-Streaming transport module for Python MCP implementation
- Update main MCP module to support transport selection (auto, sse, http-streaming)
- Add TypeScript HTTP-Streaming transport implementation
- Create unified MCP class for TypeScript with transport selection
- Maintain full backward compatibility for existing code
- Auto-detect transport based on URL patterns (/sse uses SSE, others use HTTP-streaming)
- Add example demonstrating transport selection usage

Fixes #722

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 14:40:28 +00:00
praisonai-triage-agent[bot]andMervin Praison 7ee4854c19 fix: address critical runtime issues found in review
- Fix TypeError in builtin.py: pop prompt/messages from kwargs to avoid duplicate argument error
- Fix async/sync mismatch: use aexecute() instead of execute() in async context
- Fix Python 3.8/3.9 compatibility: handle TypeError when entry_points() doesn't accept group parameter
- Fix priority docstring: clarify that lower numbers = higher priority with correct example
- Implement lazy loading for entry points: defer module loading until runtime is resolved

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 14:39:27 +00:00
praisonai-triage-agent[bot]andMervinPraison 2001632dd3 feat: implement auto runtime selection with plugin harness registry (fixes #1939)
- Add AgentRuntimeProtocol with supports() and selection_priority() methods
- Implement auto resolution logic in runtime/resolve.py with priority-based selection
- Add runtime registry with entry point discovery for plugin harnesses
- Include built-in praisonai runtime as default fallback
- Add comprehensive unit tests for auto selection behavior
- Export runtime functionality through core SDK __init__.py

Supports:
- Auto mode selects highest-priority supporting runtime
- Explicit runtime selection by ID
- Entry point registration via 'praisonai.runtimes' group
- Tie-breaking by priority and fallback to praisonai default
- Lazy loading of plugin dependencies on selection only

Co-authored-by: MervinPraison <[email protected]>
2026-06-26 14:39:14 +00:00
praisonai-triage-agent[bot]andMervin Praison 4706567535 fix: harden bot-pr-recovery workflow against template injection
- Move pr_number workflow_dispatch input into env var, read via process.env (CodeRabbit Major/Security: template-injection)
- Set persist-credentials: false on checkout in bot-pr-recovery.yml and auto-pr-comment.yml (zizmor artipacked)

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 14:33:34 +00:00
praisonai-triage-agent[bot]andMervin Praison 3184351b8a fix: clear provider guidance on praisonai --init when no LLM key set (fixes #90)
Non-programmer users running `praisonai --init` with no API key (or a
non-OpenAI key for the OpenAI-default model) previously got a raw stack
trace, reinforcing the impression that PraisonAI is OpenAI-only. Add a
pre-flight credential check that emits an actionable message pointing at
the existing interactive `praisonai setup` wizard (no shell `export`
needed) and lists supported providers.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 14:32:53 +00:00
praisonai-triage-agent[bot]andMervin Praison 2625cf9904 fix: harden bot-pr review chain per reviewer feedback
- chainKickPosted now requires BOTH coderabbit + qodo markers; kickReviewChain posts only the missing one (closes partial-failure idempotency gap)
- recoverStalledBotPrs enforces isBotOpenedPr even for manual pr_number dispatch (fixes Greptile P1 security: no longer kicks arbitrary human/fork PRs) and paginates open PRs
- findOpenPrForIssue requires bot-authored PR to avoid kicking human prefix branches
- kickReviewChain accepts pre-fetched comments to avoid redundant API calls (Gemini perf)
- self-test covers new dual-marker + helper exports

Co-authored-by: Mervin Praison <[email protected]>
2026-06-26 14:31:19 +00:00
MervinPraisonandCursor 9beda70776 fix: reliably kick review chain when bot PRs miss pull_request opened
Add shared bot-pr-review-chain helper, post-create kick in claude.yml via
GH_TOKEN PAT, idempotent bot-pr-trigger-reviews, and a 15-min recovery scanner.
Fixes #2251

Co-authored-by: Cursor <[email protected]>
2026-06-26 15:18:28 +01:00
MervinPraison b5a45bf26a docs: auto-update feature parity trackers [skip ci] 2026-06-26 14:16:48 +00:00
Mervin Praison 6baffd53a9 Merge pull request #1967 from us/feat/add-fastcrw
feat: add fastCRW tool
2026-06-26 15:16:26 +01:00
Mervin Praison 688a2f2ce6 Merge pull request #1986 from Anil-matcha/feat/muapi-provider-example
feat: add MuAPI image generation provider example
2026-06-26 15:15:04 +01:00
MervinPraisonandCursor d2c897b6ff fix: unblock merge gate after conflict rebase and repair auto-dispatch
Clear the 12h merge-conflict cooldown once Claude posts rebase complete and
FINAL review is current on HEAD. Use repository_dispatch instead of
createWorkflowDispatch so event-driven assess runs reliably with GH_TOKEN.

Co-authored-by: Cursor <[email protected]>
2026-06-26 14:00:31 +01:00
Anil Matcha db3e8fb563 fix(muapi): allowlist models, safe dict access, guard module-level side effects
- ALLOWED_MODELS set prevents arbitrary path injection via model param
- .get() on all response dict accesses with explicit error messages
- Agent/Tool instantiation moved inside __main__ guard to avoid
  side effects on import (env var reads, network, PraisonAI init)
2026-06-26 15:41:52 +05:30
us 52faf97c7f docs: add JSDoc to crwScrape and crwCrawl public APIs 2026-06-23 01:32:15 +03:00
us 03dd1bf032 feat: add fastCRW tool 2026-06-23 01:32:15 +03:00
praisonai-triage-agent[bot]andMervin Praison d0064a4e70 fix: Remove redundant comment violating PEP 257
- Removed non-idiomatic comment placed between function signature and docstring
- Comment was redundant with the docstring content
- Fixes violation of PEP 257 which requires docstring as first statement

Co-authored-by: Mervin Praison <[email protected]>
2026-06-19 22:51:56 +00:00
Anil Chandra Naidu Matcha 376214d181 feat: add MuAPI image generation provider example 2026-06-17 11:07:33 +05:30
praisonai-triage-agent[bot]andMervinPraison 854eaaf05c feat: add comment to shell_tools.py execute_command method
Add '# shell command execution' comment at the top of the execute_command
method docstring as requested in issue #1341.

Co-authored-by: MervinPraison <[email protected]>
2026-04-09 16:08:45 +00:00
37 changed files with 1880 additions and 325 deletions

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);
+153
View File
@@ -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,
};
+36
View File
@@ -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);
+96 -11
View File
@@ -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);
+202
View File
@@ -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,
};
+63 -27
View File
@@ -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);
+43
View File
@@ -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);
+20 -7
View File
@@ -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'
+57
View File
@@ -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
);
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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."""
+43 -3
View File
@@ -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,
+8 -4
View File
@@ -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`
+52 -4
View File
@@ -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,
+12 -8
View File
@@ -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);
+1
View File
@@ -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,
+221
View File
@@ -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,
+108
View File
@@ -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;
}
}
+11
View File
@@ -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),
+2 -10
View File
@@ -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,
+2 -10
View File
@@ -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()
+64 -19
View File
@@ -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
+37
View File
@@ -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()