mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 17:31:00 -10:00
Compare commits
30
Commits
No files matched your search
@@ -112,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);
|
||||
@@ -305,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,
|
||||
@@ -320,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, {
|
||||
@@ -679,6 +717,9 @@ module.exports = {
|
||||
claudeRunBlocksPr,
|
||||
hasBlockingClaudeRunForPr,
|
||||
isCiOnlyChange,
|
||||
isInternalPullRequestLink,
|
||||
resolvePrNumberFromLinkedPullRequests,
|
||||
resolvePrNumberFromHeadBranch,
|
||||
resolvePrNumberFromWorkflowRun,
|
||||
WORKFLOW_ONLY_LABEL,
|
||||
getAgentPyChange,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run: node .github/scripts/pipeline-status-selftest.js
|
||||
*/
|
||||
const ps = require('./pipeline-status.js');
|
||||
const mg = require('./merge-gate.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
const kickComments = [
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
{ user: { login: 'MervinPraison' }, body: '/review' },
|
||||
];
|
||||
|
||||
const finalComment = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@claude You are the FINAL architecture reviewer.',
|
||||
created_at: '2026-06-26T10:00:00Z',
|
||||
};
|
||||
|
||||
assert(
|
||||
'reviews pending when not kicked',
|
||||
ps.deriveStage([], { ready: false, reasons: ['no FINAL Claude review trigger'] })
|
||||
=== 'pipeline/reviews-pending'
|
||||
);
|
||||
assert(
|
||||
'final pending after kick',
|
||||
ps.deriveStage(kickComments, { ready: false, reasons: ['no FINAL Claude review trigger'] })
|
||||
=== 'pipeline/final-claude-pending'
|
||||
);
|
||||
assert(
|
||||
'awaiting merge gate after final',
|
||||
ps.deriveStage(
|
||||
[...kickComments, finalComment],
|
||||
{ ready: false, reasons: ['recent @claude within 35min'] }
|
||||
) === 'pipeline/awaiting-merge-gate'
|
||||
);
|
||||
assert(
|
||||
'merge ready',
|
||||
ps.deriveStage([...kickComments, finalComment], { ready: true, reasons: [] })
|
||||
=== 'pipeline/merge-ready'
|
||||
);
|
||||
|
||||
const blockers = ps.deriveBlockerLabels({
|
||||
ready: false,
|
||||
reasons: ['CI not green on HEAD', 'SDK code added without test file changes — requires manual review'],
|
||||
});
|
||||
assert('maps ci blocker', blockers.includes('pipeline/blocked:ci'));
|
||||
assert('maps manual blocker', blockers.includes('pipeline/blocked:manual-review'));
|
||||
|
||||
assert(
|
||||
'internal link matches upstream base',
|
||||
mg.isInternalPullRequestLink(
|
||||
{ base: { repo: { full_name: 'MervinPraison/PraisonAI' } } },
|
||||
'MervinPraison',
|
||||
'PraisonAI'
|
||||
)
|
||||
);
|
||||
assert(
|
||||
'fork sync link rejected',
|
||||
!mg.isInternalPullRequestLink(
|
||||
{ number: 21, base: { repo: { full_name: 'Milkmange/PraisonAI' } } },
|
||||
'MervinPraison',
|
||||
'PraisonAI'
|
||||
)
|
||||
);
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Sync pipeline stage/blocker labels on open PRs.
|
||||
* @see .github/workflows/pipeline-status-sync.yml
|
||||
*/
|
||||
|
||||
const mergeGate = require('./merge-gate.js');
|
||||
const chain = require('./bot-pr-review-chain.js');
|
||||
|
||||
const PIPELINE_PREFIX = 'pipeline/';
|
||||
const STAGE_LABELS = [
|
||||
'pipeline/reviews-pending',
|
||||
'pipeline/final-claude-pending',
|
||||
'pipeline/awaiting-merge-gate',
|
||||
'pipeline/merge-ready',
|
||||
'pipeline/merged',
|
||||
];
|
||||
const BLOCKER_LABELS = [
|
||||
'pipeline/blocked:ci',
|
||||
'pipeline/blocked:conflict',
|
||||
'pipeline/blocked:manual-review',
|
||||
'pipeline/blocked:cooldown',
|
||||
'pipeline/blocked:stale-final',
|
||||
'pipeline/blocked:no-final',
|
||||
];
|
||||
const ALL_PIPELINE_LABELS = [...STAGE_LABELS, ...BLOCKER_LABELS];
|
||||
|
||||
const LABEL_SPECS = [
|
||||
{ name: 'pipeline/reviews-pending', color: 'fbca04', description: 'Waiting for CodeRabbit/Qodo/Copilot reviews' },
|
||||
{ name: 'pipeline/final-claude-pending', color: 'd4c5f9', description: 'Reviews done; waiting for FINAL @claude' },
|
||||
{ name: 'pipeline/awaiting-merge-gate', color: 'c5def5', description: 'FINAL done; waiting for merge gate / CI' },
|
||||
{ name: 'pipeline/merge-ready', color: '0e8a16', description: 'Eligible for merge gate auto-merge' },
|
||||
{ name: 'pipeline/merged', color: '6f42c1', description: 'Merged via pipeline' },
|
||||
{ name: 'pipeline/blocked:ci', color: 'd93f0b', description: 'Blocked: CI not green on HEAD' },
|
||||
{ name: 'pipeline/blocked:conflict', color: 'b60205', description: 'Blocked: merge conflict or rebase pending' },
|
||||
{ name: 'pipeline/blocked:manual-review', color: 'e99695', description: 'Blocked: requires manual review' },
|
||||
{ name: 'pipeline/blocked:cooldown', color: 'fef2c0', description: 'Blocked: post-push or @claude cooldown' },
|
||||
{ name: 'pipeline/blocked:stale-final', color: 'f9d0c4', description: 'Blocked: FINAL stale after new commits' },
|
||||
{ name: 'pipeline/blocked:no-final', color: 'ededed', description: 'Blocked: no FINAL @claude trigger yet' },
|
||||
];
|
||||
|
||||
function deriveStage(comments, evalResult) {
|
||||
if (evalResult.ready) return 'pipeline/merge-ready';
|
||||
if (!chain.chainKickPosted(comments)) return 'pipeline/reviews-pending';
|
||||
if (!mergeGate.hasFinalClaudeReviewTrigger(comments)) return 'pipeline/final-claude-pending';
|
||||
return 'pipeline/awaiting-merge-gate';
|
||||
}
|
||||
|
||||
function reasonToBlockerLabel(reason) {
|
||||
const r = (reason || '').toLowerCase();
|
||||
if (r.includes('ci not green') || r.includes('sdk code changed but no ci')) return 'pipeline/blocked:ci';
|
||||
if (
|
||||
r.includes('conflict') ||
|
||||
r.includes('mergestate=dirty') ||
|
||||
r.includes('not mergeable')
|
||||
) return 'pipeline/blocked:conflict';
|
||||
if (
|
||||
r.includes('manual') ||
|
||||
r.includes('requires manual') ||
|
||||
r.includes('sensitive path') ||
|
||||
r.includes('no-auto-merge') ||
|
||||
r.includes('manual-only label') ||
|
||||
r.includes('without test') ||
|
||||
r.includes('files changed') ||
|
||||
r.includes('possible secret') ||
|
||||
r.includes('agent.py')
|
||||
) return 'pipeline/blocked:manual-review';
|
||||
if (r.includes('recent @claude') || r.includes('post-push buffer')) return 'pipeline/blocked:cooldown';
|
||||
if (r.includes('stale final')) return 'pipeline/blocked:stale-final';
|
||||
if (r.includes('no final claude')) return 'pipeline/blocked:no-final';
|
||||
if (r.includes('final claude not complete')) return 'pipeline/blocked:stale-final';
|
||||
if (r.includes('claude.yml in progress') || r.includes('claude-merge-gate-active')) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveBlockerLabels(evalResult) {
|
||||
if (evalResult.ready) return [];
|
||||
const labels = new Set();
|
||||
for (const reason of evalResult.reasons || []) {
|
||||
const mapped = reasonToBlockerLabel(reason);
|
||||
if (mapped && mapped.startsWith('pipeline/blocked:')) labels.add(mapped);
|
||||
}
|
||||
return [...labels];
|
||||
}
|
||||
|
||||
function computePipelineLabels(comments, evalResult) {
|
||||
const stage = deriveStage(comments, evalResult);
|
||||
const blockers = stage === 'pipeline/merge-ready' ? [] : deriveBlockerLabels(evalResult);
|
||||
return { stage, blockers, all: [stage, ...blockers] };
|
||||
}
|
||||
|
||||
async function ensurePipelineLabels(github, owner, repo, core) {
|
||||
let existing;
|
||||
if (typeof github.paginate === 'function') {
|
||||
existing = await github.paginate(github.rest.issues.listLabelsForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
} else {
|
||||
({ data: existing } = await github.rest.issues.listLabelsForRepo({
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
}));
|
||||
}
|
||||
const names = new Set(existing.map((l) => l.name));
|
||||
for (const spec of LABEL_SPECS) {
|
||||
if (names.has(spec.name)) continue;
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: spec.name,
|
||||
color: spec.color,
|
||||
description: spec.description,
|
||||
});
|
||||
core?.info?.(`Created label ${spec.name}`);
|
||||
} catch (err) {
|
||||
if (err.status !== 422) throw err;
|
||||
core?.info?.(`Label ${spec.name} already exists`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncPipelineLabels(github, owner, repo, prNumber, core) {
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
if (ctx.pr.state !== 'open') return { synced: false, reason: 'not_open' };
|
||||
|
||||
const evalResult = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core
|
||||
);
|
||||
const { stage, blockers, all } = computePipelineLabels(ctx.comments, evalResult);
|
||||
|
||||
const current = ctx.labels.filter((l) => l.startsWith(PIPELINE_PREFIX));
|
||||
const desired = new Set(all);
|
||||
const toRemove = current.filter((l) => !desired.has(l) && l !== 'pipeline/merged');
|
||||
const toAdd = all.filter((l) => !current.includes(l));
|
||||
|
||||
for (const name of toRemove) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name });
|
||||
} catch (err) {
|
||||
if (err.status !== 404) throw err;
|
||||
}
|
||||
}
|
||||
if (toAdd.length) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: toAdd,
|
||||
});
|
||||
}
|
||||
|
||||
core?.info?.(`PR #${prNumber}: stage=${stage} blockers=[${blockers.join(', ')}]`);
|
||||
return { synced: true, stage, blockers, ready: evalResult.ready, reasons: evalResult.reasons };
|
||||
}
|
||||
|
||||
async function syncOpenPullRequests(github, owner, repo, options, core) {
|
||||
const { maxPrs = 20 } = options || {};
|
||||
await ensurePipelineLabels(github, owner, repo, core);
|
||||
let prs;
|
||||
if (maxPrs <= 100) {
|
||||
({ data: prs } = await github.rest.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
}));
|
||||
} else {
|
||||
prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
let synced = 0;
|
||||
for (const pr of prs) {
|
||||
if (synced >= maxPrs) break;
|
||||
if (pr.draft) continue;
|
||||
if (pr.head?.repo?.full_name && pr.head.repo.full_name !== `${owner}/${repo}`) continue;
|
||||
await syncPipelineLabels(github, owner, repo, pr.number, core);
|
||||
synced += 1;
|
||||
}
|
||||
core?.info?.(`Pipeline label sync complete (${synced} PR(s))`);
|
||||
return synced;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STAGE_LABELS,
|
||||
BLOCKER_LABELS,
|
||||
ALL_PIPELINE_LABELS,
|
||||
deriveStage,
|
||||
deriveBlockerLabels,
|
||||
computePipelineLabels,
|
||||
ensurePipelineLabels,
|
||||
syncPipelineLabels,
|
||||
syncOpenPullRequests,
|
||||
};
|
||||
@@ -216,6 +216,10 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
@@ -327,6 +331,19 @@ jobs:
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Qodo fallback path (same polling approach)
|
||||
# ================================================================
|
||||
@@ -342,6 +359,10 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
@@ -434,6 +455,19 @@ jobs:
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Gemini — restores the trigger lost from the deleted
|
||||
# chain-claude-after-copilot.yml workflow.
|
||||
@@ -551,6 +585,17 @@ jobs:
|
||||
await github.rest.issues.createComment({ issue_number: pr, owner, repo, body: finalBody });
|
||||
core.info(`Posted missing FINAL Claude review on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# Scheduled: re-post @claude when FINAL is stale (Claude pushed after last FINAL trigger)
|
||||
stale-final-recovery-scheduled:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
@@ -611,6 +656,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -624,4 +670,15 @@ jobs:
|
||||
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;
|
||||
await chain.kickReviewChain(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
await chain.kickReviewChain(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
- 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);
|
||||
@@ -134,6 +134,7 @@ 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);
|
||||
@@ -194,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'
|
||||
|
||||
@@ -352,3 +352,44 @@ jobs:
|
||||
await chain.kickReviewChainForIssue(
|
||||
github, context.repo.owner, context.repo.repo, issueNumber, core
|
||||
);
|
||||
|
||||
- name: Sync pipeline status labels for PR
|
||||
if: steps.check_fork.outputs.is_pr == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const prNumber = parseInt('${{ steps.check_fork.outputs.pr_number }}', 10);
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, prNumber, core);
|
||||
|
||||
- name: Dispatch merge gate when PR pipeline ready
|
||||
if: steps.check_fork.outputs.is_pr == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = parseInt('${{ steps.check_fork.outputs.pr_number }}', 10);
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, {
|
||||
skipGlobalClaudeRunCheck: true,
|
||||
skipRecentClaudeCooldown: true,
|
||||
}
|
||||
);
|
||||
if (!check.ready) {
|
||||
core.info(`PR #${prNumber} not ready for merge gate: ${check.reasons.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
event_type: 'claude-merge-gate',
|
||||
client_payload: { pr_number: prNumber },
|
||||
});
|
||||
core.info(`Dispatched merge gate for PR #${prNumber}`);
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Pipeline Status Sync
|
||||
|
||||
# Sync pipeline/* stage and blocker labels on open internal PRs.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Sync pipeline labels on open PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
await ps.syncOpenPullRequests(
|
||||
github, context.repo.owner, context.repo.repo, { maxPrs: 30 }, core
|
||||
);
|
||||
@@ -1377,7 +1377,7 @@ export { // Python parity additions
|
||||
MinimalTelemetry, AgentTelemetry, PerformanceMonitor, TelemetryCollector, TelemetryIntegration, cleanupTelemetry, cleanupTelemetryResources, createAgentTelemetry, createConsoleSink, createHTTPSink, createPerformanceMonitor, createTelemetryIntegration, disablePerformanceMode, disableTelemetry, enablePerformanceMode, enableTelemetry, getMinimalTelemetry, getTelemetry } from "./telemetry";
|
||||
export { // Subagent Tool (agent-as-tool pattern)
|
||||
SubagentTool, BaseTool, FunctionTool, ToolRegistry, ToolResult, ToolValidationError, createDelegator, createSubagentTool, createSubagentTools, createTool, getRegistry, getTool, registerTool, tool, validateTool } from "./tools";
|
||||
export { airweaveSearch, bedrockBrowserClick, bedrockBrowserFill, bedrockBrowserNavigate, bedrockCodeInterpreter, codeExecution, codeMode, createCustomTool, exaSearch, firecrawlCrawl, firecrawlScrape, parallelSearch, perplexitySearch, registerCustomTool, registerLocalTool, registerNpmTool, superagentGuard, superagentRedact, superagentVerify, tavilyCrawl, tavilyExtract, tavilySearch, valyuBioSearch, valyuCompanyResearch, valyuEconomicsSearch, valyuFinanceSearch, valyuPaperSearch, valyuPatentSearch, valyuSecSearch, valyuWebSearch } from "./tools/builtins";
|
||||
export { airweaveSearch, bedrockBrowserClick, bedrockBrowserFill, bedrockBrowserNavigate, bedrockCodeInterpreter, codeExecution, codeMode, createCustomTool, crwCrawl, crwScrape, exaSearch, firecrawlCrawl, firecrawlScrape, parallelSearch, perplexitySearch, registerCustomTool, registerLocalTool, registerNpmTool, superagentGuard, superagentRedact, superagentVerify, tavilyCrawl, tavilyExtract, tavilySearch, valyuBioSearch, valyuCompanyResearch, valyuEconomicsSearch, valyuFinanceSearch, valyuPaperSearch, valyuPatentSearch, valyuSecSearch, valyuWebSearch } from "./tools/builtins";
|
||||
export { BudgetExceededError, MissingDependencyError, MissingEnvVarError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getToolsRegistry, get_registry, get_tool, register_tool, resetToolsRegistry, validate_tool } from "./tools/registry";
|
||||
export type { InstallHints, PraisonTool, RedactionHooks, RegisteredTool, ToolCapabilities, ToolExecutionContext, ToolExecutionResult, ToolFactory, ToolHooks, ToolInstallStatus, ToolLimits, ToolLogger, ToolMetadata, ToolMiddleware, ToolParameterProperty, ToolParameterSchema } from "./tools/registry";
|
||||
export { registerBuiltinTools, tools } from "./tools/tools";
|
||||
|
||||
@@ -18,7 +18,7 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 20 | 42 | 11340 |
|
||||
| ✅ Agent | 21 | 42 | 11340 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2880 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 575 |
|
||||
| ✅ Approval | 1 | 3 | 1344 |
|
||||
|
||||
@@ -235,6 +235,12 @@ _LAZY_IMPORTS = {
|
||||
'CliBackendResult': ('praisonaiagents.cli_backend.protocols', 'CliBackendResult'),
|
||||
'CliBackendDelta': ('praisonaiagents.cli_backend.protocols', 'CliBackendDelta'),
|
||||
|
||||
# Runtime Selection (Auto harness selection and registry)
|
||||
'AgentRuntimeProtocol': ('praisonaiagents.runtime.protocols', 'AgentRuntimeProtocol'),
|
||||
'resolve_runtime': ('praisonaiagents.runtime.registry', 'resolve_runtime'),
|
||||
'register_runtime': ('praisonaiagents.runtime.registry', 'register_runtime'),
|
||||
'list_runtimes': ('praisonaiagents.runtime.registry', 'list_runtimes'),
|
||||
|
||||
# Agent classes
|
||||
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
|
||||
'RetryBackoffConfig': ('praisonaiagents.agent.retry_utils', 'RetryBackoffConfig'),
|
||||
|
||||
@@ -614,24 +614,6 @@ Your Goal: {self.goal}"""
|
||||
# Fallback to str(response) is still fine, but now it's visible
|
||||
return str(response)
|
||||
|
||||
def _process_stream_response(self, messages, temperature, start_time, formatted_tools=None, reasoning_steps=False):
|
||||
"""Internal helper for streaming response processing with real-time events."""
|
||||
if self._openai_client is None:
|
||||
return None
|
||||
|
||||
return self._openai_client.process_stream_response(
|
||||
messages=messages,
|
||||
model=self.llm,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
start_time=start_time,
|
||||
console=self.console,
|
||||
display_fn=_get_display_functions()['display_generating'] if self.verbose else None,
|
||||
reasoning_steps=reasoning_steps,
|
||||
stream_callback=self.stream_emitter.emit,
|
||||
emit_events=True
|
||||
)
|
||||
|
||||
def _get_compaction_policy(self):
|
||||
"""
|
||||
Get the active compaction policy for this agent.
|
||||
@@ -786,7 +768,7 @@ Your Goal: {self.goal}"""
|
||||
from ..context.policy import CompactionRoute
|
||||
return CompactionRoute.COMPACT_NEEDED, compacted_msgs
|
||||
|
||||
def _apply_tool_truncation(self, messages, compactor, policy):
|
||||
def _apply_tool_truncation(self, messages, compactor, policy, log_tag="tool-truncation"):
|
||||
"""Apply targeted tool output truncation."""
|
||||
from ..context.policy import CompactionRoute
|
||||
import logging
|
||||
@@ -812,7 +794,7 @@ Your Goal: {self.goal}"""
|
||||
new_tokens = compactor.count_total_tokens(truncated_msgs)
|
||||
|
||||
logging.info(
|
||||
f"[tool-truncation] {self.name}: {original_tokens}→{new_tokens} tokens "
|
||||
f"[{log_tag}] {self.name}: {original_tokens}→{new_tokens} tokens "
|
||||
f"(truncated large tool outputs)"
|
||||
)
|
||||
|
||||
@@ -906,36 +888,8 @@ Your Goal: {self.goal}"""
|
||||
return CompactionRoute.COMPACT_NEEDED, compacted_msgs
|
||||
|
||||
async def _apply_tool_truncation_async(self, messages, compactor, policy):
|
||||
"""Async version of _apply_tool_truncation."""
|
||||
from ..context.policy import CompactionRoute
|
||||
import logging
|
||||
|
||||
# Create a copy to avoid modifying original
|
||||
truncated_msgs = []
|
||||
|
||||
for msg in messages:
|
||||
if msg.get("role") == "tool" or msg.get("tool_call_id"):
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and len(content) > 1000:
|
||||
# Truncate large tool outputs
|
||||
truncated_msg = msg.copy()
|
||||
head = content[:300]
|
||||
tail = content[-200:] if len(content) > 500 else ""
|
||||
truncated_msg["content"] = f"{head}\n...[truncated {len(content):,} chars for context budget]...\n{tail}"
|
||||
truncated_msgs.append(truncated_msg)
|
||||
continue
|
||||
|
||||
truncated_msgs.append(msg)
|
||||
|
||||
original_tokens = compactor.count_total_tokens(messages)
|
||||
new_tokens = compactor.count_total_tokens(truncated_msgs)
|
||||
|
||||
logging.info(
|
||||
f"[tool-truncation-async] {self.name}: {original_tokens}→{new_tokens} tokens "
|
||||
f"(truncated large tool outputs)"
|
||||
)
|
||||
|
||||
return CompactionRoute.TRUNCATE_TOOLS, truncated_msgs
|
||||
"""Async wrapper around _apply_tool_truncation (no awaitable work involved)."""
|
||||
return self._apply_tool_truncation(messages, compactor, policy, log_tag="tool-truncation-async")
|
||||
|
||||
def _get_next_fallback_model(self, fallback_index):
|
||||
"""Get the next fallback model from the chain, if available.
|
||||
@@ -1695,151 +1649,6 @@ Your Goal: {self.goal}"""
|
||||
task_name, task_description, task_id, response_format, stream_callback, emit_events
|
||||
)
|
||||
|
||||
async def _finalize_unified_achat_response(
|
||||
self,
|
||||
response,
|
||||
original_prompt,
|
||||
temperature,
|
||||
tools,
|
||||
output_json,
|
||||
output_pydantic,
|
||||
reasoning_steps,
|
||||
stream,
|
||||
messages,
|
||||
chat_history_length,
|
||||
start_time,
|
||||
task_name=None,
|
||||
task_description=None,
|
||||
task_id=None,
|
||||
):
|
||||
"""Post-process unified achat completion (parity with sync _chat_impl)."""
|
||||
if not response:
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
if hasattr(response, "choices") and response.choices:
|
||||
msg = response.choices[0].message
|
||||
content = getattr(msg, "content", None)
|
||||
response_text = content.strip() if content else ""
|
||||
if (
|
||||
reasoning_steps
|
||||
and getattr(msg, "reasoning_content", None)
|
||||
):
|
||||
response_text = msg.reasoning_content.strip()
|
||||
else:
|
||||
extracted = self._extract_llm_response_content(response)
|
||||
response_text = (extracted or "").strip() if isinstance(extracted, str) else str(extracted or "")
|
||||
|
||||
if output_json or output_pydantic:
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
self._persist_message("assistant", response_text)
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(
|
||||
response_text, original_prompt, temperature, tools,
|
||||
task_name, task_description, task_id,
|
||||
)
|
||||
self._execute_callback_and_display(
|
||||
original_prompt, validated_response, time.time() - start_time,
|
||||
task_name, task_description, task_id,
|
||||
)
|
||||
return await self._atrigger_after_agent_hook(
|
||||
original_prompt, validated_response, start_time,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
if not self.self_reflect:
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
self._persist_message("assistant", response_text)
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(
|
||||
response_text, original_prompt, temperature, tools,
|
||||
task_name, task_description, task_id,
|
||||
)
|
||||
self._execute_callback_and_display(
|
||||
original_prompt, validated_response, time.time() - start_time,
|
||||
task_name, task_description, task_id,
|
||||
)
|
||||
return await self._atrigger_after_agent_hook(
|
||||
original_prompt, validated_response, start_time,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
# Self-reflection (legacy OpenAI path)
|
||||
reflection_count = 0
|
||||
while True:
|
||||
reflection_prompt = f"""
|
||||
Reflect on your previous response: '{response_text}'.
|
||||
{self.reflect_prompt if self.reflect_prompt else "Identify any flaws, improvements, or actions."}
|
||||
Provide a "satisfactory" status ('yes' or 'no').
|
||||
Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
"""
|
||||
reflection_messages = messages + [
|
||||
{"role": "assistant", "content": response_text},
|
||||
{"role": "user", "content": reflection_prompt},
|
||||
]
|
||||
try:
|
||||
if self._openai_client is None:
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
return await self._atrigger_after_agent_hook(
|
||||
original_prompt, response_text, start_time,
|
||||
)
|
||||
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
|
||||
model=self.reflect_llm if self.reflect_llm else self.llm,
|
||||
messages=reflection_messages,
|
||||
temperature=temperature,
|
||||
response_format=_get_display_functions()['ReflectionOutput'],
|
||||
)
|
||||
reflection_output = reflection_response.choices[0].message.parsed
|
||||
if reflection_output.satisfactory == "yes" and reflection_count >= self.min_reflect - 1:
|
||||
break
|
||||
if reflection_count >= self.max_reflect - 1:
|
||||
break
|
||||
regenerate_messages = reflection_messages + [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
f"Self Reflection: {reflection_output.reflection} "
|
||||
f"Satisfactory?: {reflection_output.satisfactory}"
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": "Now regenerate your response using the reflection you made"},
|
||||
]
|
||||
new_response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
messages=regenerate_messages,
|
||||
temperature=temperature,
|
||||
)
|
||||
response_text = new_response.choices[0].message.content
|
||||
reflection_count += 1
|
||||
except Exception as e:
|
||||
logging.error("Reflection parsing failed.", exc_info=True)
|
||||
reflection_count += 1
|
||||
if reflection_count >= self.max_reflect:
|
||||
break
|
||||
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(
|
||||
response_text, original_prompt, temperature, tools,
|
||||
task_name, task_description, task_id,
|
||||
)
|
||||
self._execute_callback_and_display(
|
||||
original_prompt, validated_response, time.time() - start_time,
|
||||
task_name, task_description, task_id,
|
||||
)
|
||||
return await self._atrigger_after_agent_hook(
|
||||
original_prompt, validated_response, start_time,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for OpenAI client: {e}")
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
def _execute_callback_and_display(self, prompt: str, response: str, generation_time: float, task_name=None, task_description=None, task_id=None):
|
||||
"""Helper method to execute callbacks and display interaction.
|
||||
|
||||
|
||||
@@ -35,10 +35,6 @@ class ExecutionMixin:
|
||||
"""Synchronous sleep - safe for sync contexts only."""
|
||||
time.sleep(duration)
|
||||
|
||||
async def _safe_sleep_async(self, duration: float) -> None:
|
||||
"""Async sleep - use this in async contexts."""
|
||||
await asyncio.sleep(duration)
|
||||
|
||||
def generate_task(self) -> 'Task':
|
||||
"""Generate a Task object from the agent's instructions"""
|
||||
from ..task.task import Task
|
||||
|
||||
@@ -35,22 +35,6 @@ class UnifiedExecutionMixin:
|
||||
with a single async-first implementation plus sync bridge.
|
||||
"""
|
||||
|
||||
def _resolve_cli_backend_at_turn_time(self):
|
||||
"""
|
||||
Resolve CLI backend at turn-time instead of construction time.
|
||||
|
||||
This allows for runtime-specific backend selection based on model changes
|
||||
or other runtime conditions.
|
||||
|
||||
Returns:
|
||||
The resolved CLI backend instance, or current backend if no override needed.
|
||||
"""
|
||||
# For now, this is a placeholder for future backend override logic
|
||||
# The runtime resolution infrastructure is in place, but the policy
|
||||
# decisions about when to switch backends are not yet implemented
|
||||
# Always return the current backend until override logic is implemented
|
||||
return getattr(self, '_cli_backend', None)
|
||||
|
||||
async def _unified_chat_impl(
|
||||
self,
|
||||
prompt: str,
|
||||
|
||||
@@ -107,12 +107,26 @@ def is_path_like(value: str) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
|
||||
# Check for path indicators
|
||||
if value.startswith(("./", "../", "/", "~/")):
|
||||
# Check for path indicators (POSIX and Windows-style prefixes)
|
||||
if value.startswith(("./", "../", "/", "~/", ".\\", "..\\", "\\\\", "\\", "~\\")):
|
||||
return True
|
||||
|
||||
|
||||
# Check for Windows drive paths like C:\data or C:/data
|
||||
if (
|
||||
len(value) >= 3
|
||||
and value[1] == ":"
|
||||
and value[0].isalpha()
|
||||
and value[2] in ("\\", "/")
|
||||
):
|
||||
return True
|
||||
|
||||
# Check for directory indicator
|
||||
if value.endswith("/"):
|
||||
if value.endswith(("/", "\\")):
|
||||
return True
|
||||
|
||||
# Check for embedded backslash separators (relative Windows path like "knowledge\docs").
|
||||
# Backslash is not valid in normal config string values, so it is a strong path signal.
|
||||
if "\\" in value:
|
||||
return True
|
||||
|
||||
# Check for file extension (common ones)
|
||||
|
||||
@@ -189,4 +189,4 @@ class PraisonAIRuntime:
|
||||
type="error",
|
||||
content=str(e),
|
||||
metadata={'runtime': self.runtime_id}
|
||||
)
|
||||
)
|
||||
@@ -334,6 +334,29 @@ class TestKnowledgePresets:
|
||||
)
|
||||
assert isinstance(result, MockKnowledgeConfig)
|
||||
assert "docs/" in result.sources
|
||||
|
||||
def test_knowledge_windows_path_string(self):
|
||||
"""Windows-style directory paths should be treated as sources."""
|
||||
from praisonaiagents.config.param_resolver import resolve
|
||||
|
||||
result = resolve(
|
||||
value="C:\\knowledge",
|
||||
param_name="knowledge",
|
||||
config_class=MockKnowledgeConfig,
|
||||
string_mode="path_as_source",
|
||||
)
|
||||
assert isinstance(result, MockKnowledgeConfig)
|
||||
assert result.sources == ["C:\\knowledge"]
|
||||
|
||||
for windows_path in ("\\knowledge", "~\\knowledge", "knowledge\\docs"):
|
||||
result = resolve(
|
||||
value=windows_path,
|
||||
param_name="knowledge",
|
||||
config_class=MockKnowledgeConfig,
|
||||
string_mode="path_as_source",
|
||||
)
|
||||
assert isinstance(result, MockKnowledgeConfig)
|
||||
assert result.sources == [windows_path]
|
||||
|
||||
def test_knowledge_array_sources(self):
|
||||
"""Array of paths should be sources list."""
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"status": "PARITY_ACHIEVED",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 388,
|
||||
"pythonCoreFeatures": 392,
|
||||
"rustFeatures": 667,
|
||||
"gapCount": 106,
|
||||
"parityPercentage": 171.9
|
||||
"gapCount": 110,
|
||||
"parityPercentage": 170.2
|
||||
},
|
||||
"pythonCoreSDK": {
|
||||
"exports": [
|
||||
@@ -27,6 +27,7 @@
|
||||
"AgentOSProtocol",
|
||||
"AgentPluginProtocol",
|
||||
"AgentRunOutcome",
|
||||
"AgentRuntimeProtocol",
|
||||
"AgentTeam",
|
||||
"Agents",
|
||||
"ApprovalCallback",
|
||||
@@ -350,6 +351,7 @@
|
||||
"is_path_like",
|
||||
"is_policy_string",
|
||||
"list_memory_adapters",
|
||||
"list_runtimes",
|
||||
"list_toolsets",
|
||||
"load_plugin",
|
||||
"load_skill",
|
||||
@@ -364,6 +366,7 @@
|
||||
"register_display_callback",
|
||||
"register_memory_adapter",
|
||||
"register_memory_factory",
|
||||
"register_runtime",
|
||||
"register_tool",
|
||||
"register_toolset",
|
||||
"repeat",
|
||||
@@ -381,6 +384,7 @@
|
||||
"resolve_planning",
|
||||
"resolve_reflection",
|
||||
"resolve_routing",
|
||||
"resolve_runtime",
|
||||
"resolve_skills",
|
||||
"resolve_toolset",
|
||||
"resolve_toolsets",
|
||||
@@ -3089,6 +3093,15 @@
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "AgentRuntimeProtocol",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "medium",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "AgentTeam",
|
||||
"python": true,
|
||||
@@ -4997,6 +5010,15 @@
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "list_runtimes",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "list_toolsets",
|
||||
"python": true,
|
||||
@@ -5078,6 +5100,15 @@
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "register_runtime",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "register_toolset",
|
||||
"python": true,
|
||||
@@ -5213,6 +5244,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "resolve_runtime",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "resolve_skills",
|
||||
"python": true,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Rust Feature Parity Tracker
|
||||
|
||||
> **Python Features:** 388 | **Rust Features:** 667 | **Parity:** 74.2%
|
||||
> **Python Features:** 392 | **Rust Features:** 667 | **Parity:** 73.5%
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 388 |
|
||||
| Python Core Features | 392 |
|
||||
| Rust Features | 667 |
|
||||
| **Actual Gap Count** | **100** |
|
||||
| **Actual Gap Count** | **104** |
|
||||
| Language Limitations (N/A) | 5 |
|
||||
| **Parity** | **74.2%** |
|
||||
| **Parity** | **73.5%** |
|
||||
|
||||
## Implemented Features
|
||||
|
||||
@@ -698,6 +698,7 @@ These Python features cannot be directly implemented in Rust due to reserved key
|
||||
- ❌ `AGGRESSIVE_POLICY`
|
||||
- ❌ `AgentMessageEvent`
|
||||
- ❌ `AgentRunOutcome`
|
||||
- ❌ `AgentRuntimeProtocol`
|
||||
- ❌ `AsyncLearnProtocol`
|
||||
- ❌ `AutoApproveBackend`
|
||||
- ❌ `AutoMemory`
|
||||
@@ -780,13 +781,16 @@ These Python features cannot be directly implemented in Rust due to reserved key
|
||||
- ❌ `if_`
|
||||
- ❌ `include`
|
||||
- ❌ `list_memory_adapters`
|
||||
- ❌ `list_runtimes`
|
||||
- ❌ `list_toolsets`
|
||||
- ❌ `load_skill`
|
||||
- ❌ `parallel_handoffs`
|
||||
- ❌ `register_memory_adapter`
|
||||
- ❌ `register_memory_factory`
|
||||
- ❌ `register_runtime`
|
||||
- ❌ `register_tool`
|
||||
- ❌ `register_toolset`
|
||||
- ❌ `resolve_runtime`
|
||||
- ❌ `resolve_toolset`
|
||||
- ❌ `resolve_toolsets`
|
||||
- ❌ `unregister_toolset`
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 388,
|
||||
"pythonCoreFeatures": 392,
|
||||
"pythonWrapperFeatures": 100,
|
||||
"typescriptFeatures": 1190,
|
||||
"gapCount": 95,
|
||||
"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",
|
||||
@@ -4232,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,
|
||||
@@ -6140,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,
|
||||
@@ -6221,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,
|
||||
@@ -6356,6 +6391,15 @@
|
||||
"status": "DONE",
|
||||
"category": "config"
|
||||
},
|
||||
{
|
||||
"feature": "resolve_runtime",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
"feature": "resolve_skills",
|
||||
"python": true,
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 388 |
|
||||
| Python Core Features | 392 |
|
||||
| Python Wrapper Features | 100 |
|
||||
| TypeScript Features | 1190 |
|
||||
| **Gap Count** | **95** |
|
||||
| **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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -10,16 +10,8 @@ from typing import Any
|
||||
|
||||
def _build_config_list() -> list[dict[str, Any]]:
|
||||
"""Reuse the same env/keyfile resolution the CLI already performs."""
|
||||
# Import LLM endpoint resolution from the same path the CLI uses
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint()
|
||||
|
||||
return [{
|
||||
'model': ep.model,
|
||||
'base_url': ep.base_url,
|
||||
'api_key': ep.api_key,
|
||||
'api_type': 'openai' # AutoGen expects this field
|
||||
}]
|
||||
from praisonai.llm.config import build_config_list
|
||||
return build_config_list()
|
||||
|
||||
|
||||
def run(agent_file: str,
|
||||
|
||||
@@ -384,16 +384,8 @@ class BaseAutoGenerator:
|
||||
config_list: Optional LLM configuration list
|
||||
"""
|
||||
# Resolve LLM endpoint configuration from environment variables
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint()
|
||||
|
||||
self.config_list = config_list or [
|
||||
{
|
||||
'model': ep.model,
|
||||
'base_url': ep.base_url,
|
||||
'api_key': ep.api_key
|
||||
}
|
||||
]
|
||||
from praisonai.llm.config import build_config_list
|
||||
self.config_list = config_list or build_config_list(include_api_type=False)
|
||||
self._openai_client = None # lazy, per-instance
|
||||
self._async_openai_client = None # lazy, per-instance async client
|
||||
self._client_lock = threading.Lock()
|
||||
|
||||
@@ -112,6 +112,58 @@ def _get_agents_generator():
|
||||
from praisonai.agents_generator import AgentsGenerator
|
||||
return AgentsGenerator
|
||||
|
||||
|
||||
def _provider_preflight_message():
|
||||
"""Return a guidance message if no LLM provider credential is configured.
|
||||
|
||||
PraisonAI is provider-agnostic (OpenAI, Anthropic, Google/Gemini, Groq,
|
||||
Cohere, Ollama, OpenRouter, plus 100+ via LiteLLM). Flows that call an LLM
|
||||
immediately — such as ``praisonai --init`` — should fail with clear,
|
||||
actionable guidance (pointing at ``praisonai setup``) instead of a raw stack
|
||||
trace when the user has not configured any provider yet.
|
||||
|
||||
Returns:
|
||||
A user-facing message string when no provider is configured, or ``None``
|
||||
when a provider credential is available (so the caller proceeds). The
|
||||
check itself never raises; on any unexpected error it returns ``None``
|
||||
so it can never block a properly configured user.
|
||||
"""
|
||||
try:
|
||||
from praisonai.llm.credentials import (
|
||||
inject_credentials_into_env,
|
||||
is_configured,
|
||||
)
|
||||
|
||||
# Mirror the runtime credential resolution so the gate cannot disagree
|
||||
# with what generation actually does:
|
||||
# 1. AutoGenerator resolves via env-only resolve_llm_endpoint(), so a
|
||||
# key stored via `praisonai setup` must be exported into the env
|
||||
# first or it never reaches the LLM call.
|
||||
# 2. The runtime model honours MODEL_NAME / OPENAI_MODEL_NAME, so gate
|
||||
# on that exact model (not the inferred provider default) — a stale
|
||||
# OpenAI model override with only a non-OpenAI key must still be
|
||||
# caught here instead of failing later with a raw auth error.
|
||||
inject_credentials_into_env()
|
||||
import os as _os
|
||||
runtime_model = _os.environ.get("MODEL_NAME") or _os.environ.get(
|
||||
"OPENAI_MODEL_NAME"
|
||||
)
|
||||
if is_configured(model=runtime_model):
|
||||
return None
|
||||
except Exception:
|
||||
return None # never block on the check itself
|
||||
return (
|
||||
"No LLM provider is configured.\n\n"
|
||||
"PraisonAI supports OpenAI, Anthropic, Google/Gemini, Groq, "
|
||||
"Cohere, Ollama, OpenRouter and 100+ models via LiteLLM.\n\n"
|
||||
"Easiest setup (interactive, no shell 'export' needed):\n"
|
||||
" praisonai setup\n\n"
|
||||
"Or set a provider API key, for example:\n"
|
||||
" export OPENAI_API_KEY=... # OpenAI\n"
|
||||
" export ANTHROPIC_API_KEY=... # Anthropic Claude\n"
|
||||
" export GEMINI_API_KEY=... # Google Gemini\n"
|
||||
)
|
||||
|
||||
# Use centralized availability detection
|
||||
from .._framework_availability import is_available
|
||||
|
||||
@@ -252,17 +304,8 @@ class PraisonAI:
|
||||
self._interactive_mode = False # Flag for interactive TUI mode
|
||||
# Create config_list with AutoGen compatibility
|
||||
# Resolve LLM endpoint configuration from environment variables
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint()
|
||||
|
||||
self.config_list = [
|
||||
{
|
||||
'model': ep.model,
|
||||
'base_url': ep.base_url,
|
||||
'api_key': ep.api_key,
|
||||
'api_type': 'openai' # AutoGen expects this field
|
||||
}
|
||||
]
|
||||
from praisonai.llm.config import build_config_list
|
||||
self.config_list = build_config_list()
|
||||
self.agent_file = agent_file
|
||||
self.framework = framework
|
||||
|
||||
@@ -742,6 +785,16 @@ class PraisonAI:
|
||||
self.topic = temp_topic
|
||||
|
||||
self.agent_file = "agents.yaml"
|
||||
|
||||
# Pre-flight: ensure an LLM provider credential is configured before
|
||||
# calling the LLM. Without this, a user with no API key (or a
|
||||
# non-OpenAI key for an OpenAI-default model) gets a raw stack trace
|
||||
# instead of clear, actionable guidance.
|
||||
preflight = _provider_preflight_message()
|
||||
if preflight:
|
||||
print(preflight)
|
||||
return preflight
|
||||
|
||||
AutoGenerator = _get_auto_generator()
|
||||
generator = AutoGenerator(topic=self.topic, framework=self.framework, agent_file=self.agent_file)
|
||||
self.agent_file = generator.generate(merge=getattr(args, 'merge', False))
|
||||
@@ -6902,14 +6955,6 @@ Provide a concise summary (max 200 words):"""
|
||||
execution_queue = session_state['execution_queue']
|
||||
execution_queue.put({'prompt': prompt})
|
||||
|
||||
def _process_interactive_prompt_async(self, prompt, tools_list, console, session_state=None):
|
||||
"""
|
||||
DEPRECATED: This method is kept for backward compatibility.
|
||||
Use _submit_prompt_to_worker instead for true non-blocking behavior.
|
||||
"""
|
||||
# Just submit to worker queue - non-blocking
|
||||
self._submit_prompt_to_worker(prompt, session_state)
|
||||
|
||||
def _process_interactive_prompt(self, prompt, tools_list, console, show_profiling=False, session_state=None):
|
||||
"""Process a prompt in interactive mode with streaming."""
|
||||
from rich.live import Live
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Shared AutoGen-style ``config_list`` builder.
|
||||
|
||||
Provides a single owner for turning a resolved LLM endpoint into the
|
||||
``[{model, base_url, api_key, api_type}]`` shape that AutoGen and the
|
||||
PraisonAI wrappers expect. Keeps endpoint->config mapping in one place so
|
||||
new fields or provider quirks only need to change here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_config_list(*, include_api_type: bool = True) -> list[dict[str, Any]]:
|
||||
"""Build an AutoGen-style ``config_list`` from the resolved endpoint.
|
||||
|
||||
Reuses the same env/keyfile resolution the CLI already performs via
|
||||
:func:`praisonai.llm.env.resolve_llm_endpoint`.
|
||||
|
||||
Args:
|
||||
include_api_type: When True, include ``api_type='openai'`` which
|
||||
AutoGen expects. Set False to match callers that historically
|
||||
omit it.
|
||||
|
||||
Returns:
|
||||
A single-entry list with the resolved model, base URL and API key.
|
||||
"""
|
||||
from praisonai.llm.env import resolve_llm_endpoint
|
||||
ep = resolve_llm_endpoint()
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
'model': ep.model,
|
||||
'base_url': ep.base_url,
|
||||
'api_key': ep.api_key,
|
||||
}
|
||||
if include_api_type:
|
||||
entry['api_type'] = 'openai' # AutoGen expects this field
|
||||
return [entry]
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Unit tests for the `praisonai --init` provider pre-flight guard.
|
||||
|
||||
Verifies that when no LLM provider credential is configured, the guard returns a
|
||||
clear, actionable message (pointing at `praisonai setup`) instead of letting the
|
||||
`--init` flow call the LLM and surface a raw stack trace. When a provider IS
|
||||
configured, the guard must return None so generation proceeds.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
import praisonai.cli.main as cli_main
|
||||
from praisonai.cli.main import _provider_preflight_message
|
||||
except ImportError as e: # pragma: no cover - environment guard
|
||||
pytest.skip(f"Could not import praisonai.cli.main: {e}", allow_module_level=True)
|
||||
|
||||
|
||||
class TestProviderPreflightMessage:
|
||||
def test_returns_message_when_unconfigured(self):
|
||||
with patch("praisonai.llm.credentials.is_configured", return_value=False):
|
||||
msg = _provider_preflight_message()
|
||||
|
||||
assert msg is not None
|
||||
assert "No LLM provider is configured" in msg
|
||||
# Points beginners at the no-export interactive setup.
|
||||
assert "praisonai setup" in msg
|
||||
# Mentions multiple providers so users know it's not OpenAI-only.
|
||||
assert "Anthropic" in msg and "Gemini" in msg
|
||||
|
||||
def test_returns_none_when_configured(self):
|
||||
with patch("praisonai.llm.credentials.is_configured", return_value=True):
|
||||
assert _provider_preflight_message() is None
|
||||
|
||||
def test_never_blocks_on_internal_error(self):
|
||||
# If the credential check itself raises, the guard must not block a
|
||||
# potentially configured user (returns None -> generation proceeds).
|
||||
with patch(
|
||||
"praisonai.llm.credentials.is_configured",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
assert _provider_preflight_message() is None
|
||||
|
||||
def test_injects_stored_credentials_before_gating(self):
|
||||
# A key stored via `praisonai setup` must be exported into the env
|
||||
# before gating, otherwise the env-only AutoGenerator path would still
|
||||
# fail with a raw auth error despite the guard passing.
|
||||
with patch(
|
||||
"praisonai.llm.credentials.inject_credentials_into_env"
|
||||
) as inject, patch(
|
||||
"praisonai.llm.credentials.is_configured", return_value=True
|
||||
):
|
||||
assert _provider_preflight_message() is None
|
||||
inject.assert_called_once()
|
||||
|
||||
def test_gates_on_runtime_model_override(self):
|
||||
# With a stale OpenAI model override and only a non-OpenAI key, the
|
||||
# gate must use the exact runtime model so the mismatch is caught here
|
||||
# (message returned) rather than failing later at the LLM call.
|
||||
with patch.dict(
|
||||
"os.environ", {"MODEL_NAME": "gpt-4o-mini"}, clear=False
|
||||
), patch(
|
||||
"praisonai.llm.credentials.inject_credentials_into_env"
|
||||
), patch(
|
||||
"praisonai.llm.credentials.is_configured", return_value=False
|
||||
) as is_configured:
|
||||
msg = _provider_preflight_message()
|
||||
|
||||
assert msg is not None
|
||||
# The runtime model override must be passed through to the gate.
|
||||
is_configured.assert_called_once_with(model="gpt-4o-mini")
|
||||
|
||||
|
||||
class TestInitGuardWiring:
|
||||
def test_init_returns_early_without_calling_generator(self):
|
||||
# The real --init path must print guidance and return early WITHOUT
|
||||
# constructing the AutoGenerator when no provider is configured. This
|
||||
# protects against the guard being removed or moved after generation.
|
||||
instance = cli_main.PraisonAI.__new__(cli_main.PraisonAI)
|
||||
instance.agent_file = "agents.yaml"
|
||||
instance.config_list = [{"model": "gpt-4o-mini"}]
|
||||
instance.framework = None
|
||||
instance.auto = False
|
||||
instance.init = False
|
||||
instance.topic = ""
|
||||
|
||||
# Use a plain namespace (not MagicMock) so unset attributes don't read
|
||||
# as truthy and trip earlier branches before the --init guard.
|
||||
from types import SimpleNamespace
|
||||
|
||||
args = SimpleNamespace(
|
||||
command=None,
|
||||
framework=None,
|
||||
model=None,
|
||||
prompt_flag=None,
|
||||
file=None,
|
||||
direct_prompt=None,
|
||||
deploy=False,
|
||||
auto=False,
|
||||
init="build me a team",
|
||||
ui=None,
|
||||
merge=False,
|
||||
)
|
||||
|
||||
with patch.object(instance, "parse_args", return_value=(args, [])), patch.object(
|
||||
cli_main, "_load_env_once"
|
||||
), patch.object(
|
||||
instance, "read_stdin_if_available", return_value=None
|
||||
), patch.object(
|
||||
instance, "read_file_if_provided", return_value=None
|
||||
), patch.object(
|
||||
cli_main, "_provider_preflight_message", return_value="SETUP GUIDANCE"
|
||||
), patch.object(
|
||||
cli_main, "_get_auto_generator"
|
||||
) as get_gen, patch("builtins.print"):
|
||||
result = instance.main()
|
||||
|
||||
assert result == "SETUP GUIDANCE"
|
||||
get_gen.assert_not_called()
|
||||
Reference in new issue
Block a user