mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 01:08:39 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6d585d86e |
No files matched your search
@@ -288,7 +288,7 @@ runs:
|
||||
fi
|
||||
|
||||
# Add allowed tools (include GitHub tools by default)
|
||||
TOOLS="mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__add_issue_comment,mcp__github__get_pull_request,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,Read,Write,Edit,Bash"
|
||||
TOOLS="mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__create_comment,Read,Write,Edit,Bash"
|
||||
if [[ -n "$INPUT_ALLOWED_TOOLS" ]]; then
|
||||
TOOLS="$TOOLS,$INPUT_ALLOWED_TOOLS"
|
||||
fi
|
||||
@@ -328,7 +328,6 @@ runs:
|
||||
|
||||
# Execute Claude Code with timeout, using stdin for the prompt
|
||||
TIMEOUT_SECONDS=$(($INPUT_TIMEOUT_MINUTES * 60))
|
||||
timeout $TIMEOUT_SECONDS claude "${CMD_ARGS[@]}" < "$PROMPT_FILE" \
|
||||
| tee "$GITHUB_WORKSPACE/.claude-assess-output.jsonl"
|
||||
timeout $TIMEOUT_SECONDS claude "${CMD_ARGS[@]}" < "$PROMPT_FILE"
|
||||
|
||||
echo "✅ Claude Code execution completed"
|
||||
@@ -213,33 +213,6 @@ assert(
|
||||
mg.findMergeGateVerdict(recentVerdictOnHead, null, '2026-06-27T09:55:00Z') === 'APPROVE'
|
||||
);
|
||||
|
||||
assert(
|
||||
'scan kickoff text is not a verdict',
|
||||
mg.findMergeGateVerdict([
|
||||
{
|
||||
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
|
||||
created_at: '2026-06-27T10:05:00Z',
|
||||
},
|
||||
], null, '2026-06-27T09:55:00Z') === null
|
||||
);
|
||||
|
||||
const finalWithFinished = [
|
||||
{ user: { login: 'MervinPraison' }, body: '@claude You are the FINAL architecture reviewer.', created_at: '2026-06-27T10:00:00Z' },
|
||||
{ user: { login: 'praisonai-triage-agent[bot]' }, body: 'Claude finished', created_at: '2026-06-27T10:05:00Z' },
|
||||
];
|
||||
assert('final complete requires Claude finished reply', mg.finalClaudeCompletedOnSha(finalWithFinished, '2026-06-27T09:55:00Z'));
|
||||
assert('final trigger alone is incomplete', !mg.finalClaudeCompletedOnSha(
|
||||
[{ user: { login: 'MervinPraison' }, body: '@claude You are the FINAL architecture reviewer.', created_at: '2026-06-27T10:00:00Z' }],
|
||||
'2026-06-27T09:55:00Z'
|
||||
));
|
||||
|
||||
assert('recent scan comment detected', mg.hasRecentMergeGateScanComment([
|
||||
{ body: '**Merge gate scan** — eligible', created_at: new Date().toISOString() },
|
||||
]));
|
||||
assert('old scan comment not recent', !mg.hasRecentMergeGateScanComment([
|
||||
{ body: '**Merge gate scan** — eligible', created_at: '2020-01-01T00:00:00Z' },
|
||||
]));
|
||||
|
||||
// Sensitive + secrets
|
||||
assert('workflow path sensitive', mg.sensitivePathReasons([{ filename: '.github/workflows/foo.yml' }]).length === 1);
|
||||
assert('ci-only label exempts workflows', mg.sensitivePathReasons(
|
||||
@@ -284,13 +257,8 @@ const finalAfterRebase = {
|
||||
body: '@claude You are the FINAL architecture reviewer.',
|
||||
created_at: conflictIso(-20 * 60 * 1000),
|
||||
};
|
||||
const claudeFinishedAfterRebase = {
|
||||
user: { login: 'praisonai-triage-agent[bot]' },
|
||||
body: 'Claude finished',
|
||||
created_at: conflictIso(-19 * 60 * 1000),
|
||||
};
|
||||
const headAfterRebase = conflictIso(-21 * 60 * 1000);
|
||||
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase, claudeFinishedAfterRebase];
|
||||
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(
|
||||
@@ -357,26 +325,5 @@ assert('substantive dispatches still counted', mg.countSubstantiveMergeGateRuns(
|
||||
{ event: 'schedule' },
|
||||
]) === 2);
|
||||
assert('countSubstantiveMergeGateRuns null-safe', mg.countSubstantiveMergeGateRuns(null) === 0);
|
||||
assert('rate limit threshold is 200', mg.MIN_CORE_RATE_LIMIT_REMAINING === 200);
|
||||
|
||||
(async () => {
|
||||
const skipLow = await mg.shouldSkipMergeGateDispatch(
|
||||
{
|
||||
rest: {
|
||||
rateLimit: {
|
||||
get: async () => ({ data: { resources: { core: { remaining: 50 } } } }),
|
||||
},
|
||||
},
|
||||
},
|
||||
'owner',
|
||||
'repo',
|
||||
1,
|
||||
{ info: () => {} }
|
||||
);
|
||||
assert('rate limit low skips dispatch', skipLow === true);
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
})().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -315,16 +315,7 @@ function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin =
|
||||
function finalClaudeCompletedOnSha(comments, headPushedAt) {
|
||||
if (!hasFinalClaudeReviewTrigger(comments)) return false;
|
||||
if (isStaleFinalAfterPush(comments, headPushedAt)) return false;
|
||||
const finals = comments.filter(isFinalClaudeTriggerComment);
|
||||
if (finals.length === 0) return false;
|
||||
const latestFinal = finals.reduce((a, b) =>
|
||||
new Date(a.created_at) > new Date(b.created_at) ? a : b
|
||||
);
|
||||
const finalTime = new Date(latestFinal.created_at).getTime();
|
||||
return comments.some((c) => {
|
||||
if (!isClaudeFinalReplyComment(c)) return false;
|
||||
return new Date(c.created_at).getTime() >= finalTime - 60000;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const FINAL_CLAUDE_REVIEW_BODY =
|
||||
@@ -487,7 +478,6 @@ function hasBlockingClaudeRunForPr(runs, headRef) {
|
||||
const MERGE_GATE_WORKFLOW_FILE = 'claude-merge-gate.yml';
|
||||
const MERGE_GATE_ACTIVE_LABEL = 'claude-merge-gate-active';
|
||||
const MAX_PENDING_MERGE_GATE_RUNS = 3;
|
||||
const MIN_CORE_RATE_LIMIT_REMAINING = 200;
|
||||
|
||||
function mergeGateDispatchBlockedReason({ labels, pendingRuns, maxPending = MAX_PENDING_MERGE_GATE_RUNS }) {
|
||||
if ((labels || []).includes(MERGE_GATE_ACTIVE_LABEL)) {
|
||||
@@ -524,25 +514,8 @@ async function countPendingMergeGateRuns(github, owner, repo) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
async function getCoreRateLimitRemaining(github) {
|
||||
try {
|
||||
const { data } = await github.rest.rateLimit.get();
|
||||
return data?.resources?.core?.remaining ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function shouldSkipMergeGateDispatch(github, owner, repo, prNumber, core, options = {}) {
|
||||
const maxPending = options.maxPending ?? MAX_PENDING_MERGE_GATE_RUNS;
|
||||
const minCoreRemaining = options.minCoreRemaining ?? MIN_CORE_RATE_LIMIT_REMAINING;
|
||||
const remaining = await getCoreRateLimitRemaining(github);
|
||||
if (remaining !== null && remaining < minCoreRemaining) {
|
||||
core?.info?.(
|
||||
`Skip merge gate dispatch for PR #${prNumber}: rate limit low (${remaining} core remaining)`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
const ctx = await loadPrContext(github, owner, repo, prNumber);
|
||||
const pendingRuns = await countPendingMergeGateRuns(github, owner, repo);
|
||||
const reason = mergeGateDispatchBlockedReason({
|
||||
@@ -1029,33 +1002,18 @@ async function selectMergeGateCandidates(github, owner, repo, prNumbers, maxCand
|
||||
}
|
||||
|
||||
const AUTOMATED_FALLBACK_MARKER = 'Automated fallback —';
|
||||
const MERGE_GATE_SCAN_MARKER = 'Merge gate scan';
|
||||
|
||||
function isAutomatedFallbackVerdict(body) {
|
||||
return (body || '').includes(AUTOMATED_FALLBACK_MARKER);
|
||||
}
|
||||
|
||||
function isMergeGateVerdictComment(body) {
|
||||
const text = body || '';
|
||||
if (text.includes(MERGE_GATE_SCAN_MARKER)) return false;
|
||||
return /(?:^|\n)\s*MERGE_GATE_VERDICT:\s*(APPROVE|BLOCK)\b/m.test(text);
|
||||
}
|
||||
|
||||
function hasRecentMergeGateScanComment(comments, withinMinutes = 30) {
|
||||
const cutoff = Date.now() - withinMinutes * 60 * 1000;
|
||||
return (comments || []).some((c) => {
|
||||
if (!(c.body || '').includes(MERGE_GATE_SCAN_MARKER)) return false;
|
||||
return new Date(c.created_at).getTime() > cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
function findMergeGateVerdict(comments, minCreatedAt = null, headPushedAt = null, options = {}) {
|
||||
const { excludeAutomatedFallback = false } = options || {};
|
||||
const minTime = minCreatedAt ? new Date(minCreatedAt).getTime() : 0;
|
||||
const headTime = headPushedAt ? new Date(headPushedAt).getTime() - 60000 : 0;
|
||||
const gateComments = comments
|
||||
.filter((c) => {
|
||||
if (!isMergeGateVerdictComment(c.body)) return false;
|
||||
if (!(c.body || '').includes('MERGE_GATE_VERDICT:')) return false;
|
||||
if (excludeAutomatedFallback && isAutomatedFallbackVerdict(c.body)) return false;
|
||||
const created = new Date(c.created_at).getTime();
|
||||
if (minTime && created < minTime) return false;
|
||||
@@ -1065,8 +1023,8 @@ function findMergeGateVerdict(comments, minCreatedAt = null, headPushedAt = null
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
if (gateComments.length === 0) return null;
|
||||
const body = gateComments[0].body || '';
|
||||
if (/MERGE_GATE_VERDICT:\s*APPROVE\b/.test(body)) return 'APPROVE';
|
||||
if (/MERGE_GATE_VERDICT:\s*BLOCK\b/.test(body)) return 'BLOCK';
|
||||
if (body.includes('MERGE_GATE_VERDICT: APPROVE')) return 'APPROVE';
|
||||
if (body.includes('MERGE_GATE_VERDICT: BLOCK')) return 'BLOCK';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1137,14 +1095,10 @@ module.exports = {
|
||||
selectMergeGateCandidates,
|
||||
isAutomatedFallbackVerdict,
|
||||
AUTOMATED_FALLBACK_MARKER,
|
||||
isMergeGateVerdictComment,
|
||||
hasRecentMergeGateScanComment,
|
||||
findMergeGateVerdict,
|
||||
MERGE_GATE_WORKFLOW_FILE,
|
||||
MERGE_GATE_ACTIVE_LABEL,
|
||||
MAX_PENDING_MERGE_GATE_RUNS,
|
||||
MIN_CORE_RATE_LIMIT_REMAINING,
|
||||
getCoreRateLimitRemaining,
|
||||
mergeGateDispatchBlockedReason,
|
||||
countSubstantiveMergeGateRuns,
|
||||
countPendingMergeGateRuns,
|
||||
|
||||
@@ -201,26 +201,17 @@ jobs:
|
||||
'**Actions:** wait for CI and the Claude review chain, or add label `needs-manual-review` and merge manually.',
|
||||
'**Opt out:** label `no-auto-merge`.',
|
||||
].join('\n');
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, skipped[0].pr);
|
||||
if (!mergeGate.hasRecentMergeGateScanComment(ctx.comments)) {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: skipped[0].pr, body,
|
||||
});
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: skipped[0].pr, body,
|
||||
});
|
||||
} else if (inputPr && candidates.length === 1) {
|
||||
const prNum = candidates[0].pr_number;
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNum);
|
||||
if (!mergeGate.hasRecentMergeGateScanComment(ctx.comments)) {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNum,
|
||||
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge on an APPROVE verdict.',
|
||||
});
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: candidates[0].pr_number,
|
||||
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
|
||||
});
|
||||
}
|
||||
|
||||
await ps.syncOpenPullRequests(
|
||||
github, owner, repo, { maxPrs: 10, dispatchMergeGate: false }, core
|
||||
);
|
||||
await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 10 }, core);
|
||||
|
||||
claude-assess:
|
||||
needs: scan-candidates
|
||||
@@ -276,7 +267,6 @@ jobs:
|
||||
|
||||
- name: Claude merge gate assessment (read-only)
|
||||
id: assess
|
||||
continue-on-error: true
|
||||
uses: ./.github/actions/claude-code-action
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
@@ -318,97 +308,15 @@ jobs:
|
||||
Write
|
||||
Replace
|
||||
allowed_tools: |
|
||||
mcp__github__add_issue_comment
|
||||
mcp__github__get_pull_request
|
||||
mcp__github__get_pull_request_files
|
||||
mcp__github__get_pull_request_reviews
|
||||
mcp__github__get_pull_request_status
|
||||
mcp__github__get_issue
|
||||
mcp__github__get_issue_comments
|
||||
Glob
|
||||
Grep
|
||||
View
|
||||
GlobTool
|
||||
GrepTool
|
||||
Read
|
||||
timeout_minutes: 15
|
||||
|
||||
- name: Post Opus verdict from assess output if MCP missed
|
||||
id: relay_verdict
|
||||
if: always() && steps.assess.conclusion != 'skipped'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const mergeGate = require('/tmp/merge-gate.js');
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const existing = mergeGate.findMergeGateVerdict(
|
||||
ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (existing) {
|
||||
core.info(`Opus verdict already on PR: ${existing}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const outPath = `${process.env.GITHUB_WORKSPACE}/.claude-assess-output.jsonl`;
|
||||
if (!fs.existsSync(outPath)) {
|
||||
core.info('No assess output file to relay');
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = fs.readFileSync(outPath, 'utf8').split('\n').filter(Boolean);
|
||||
let verdict = null;
|
||||
let rationale = '';
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i];
|
||||
if (line.includes('MERGE_GATE_VERDICT: APPROVE')) {
|
||||
verdict = 'APPROVE';
|
||||
rationale = line;
|
||||
break;
|
||||
}
|
||||
if (line.includes('MERGE_GATE_VERDICT: BLOCK')) {
|
||||
verdict = 'BLOCK';
|
||||
rationale = line;
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const evt = JSON.parse(line);
|
||||
const text = JSON.stringify(evt);
|
||||
if (text.includes('MERGE_GATE_VERDICT: APPROVE')) {
|
||||
verdict = 'APPROVE';
|
||||
rationale = text;
|
||||
break;
|
||||
}
|
||||
if (text.includes('MERGE_GATE_VERDICT: BLOCK')) {
|
||||
verdict = 'BLOCK';
|
||||
rationale = text;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — keep scanning.
|
||||
}
|
||||
}
|
||||
if (!verdict) {
|
||||
core.info('Assess output had no MERGE_GATE_VERDICT to relay');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = [
|
||||
`MERGE_GATE_VERDICT: ${verdict}`,
|
||||
'',
|
||||
'Relayed from Opus merge gate assess output (GitHub MCP comment post was unavailable).',
|
||||
].join('\n');
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber, body,
|
||||
});
|
||||
core.info(`Relayed Opus verdict to PR comment: ${verdict}`);
|
||||
|
||||
- name: Post fallback verdict if Opus did not comment
|
||||
- name: Post fallback BLOCK if Opus did not comment
|
||||
id: fallback_verdict
|
||||
if: always() && steps.assess.conclusion != 'skipped'
|
||||
if: steps.assess.outcome != 'success'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -424,21 +332,20 @@ jobs:
|
||||
ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (existing) {
|
||||
core.info(`Verdict already posted: ${existing}`);
|
||||
core.info(`Opus verdict already posted: ${existing}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
const verdict = check.ready ? 'APPROVE' : 'BLOCK';
|
||||
const lines = [
|
||||
`MERGE_GATE_VERDICT: ${verdict}`,
|
||||
'MERGE_GATE_VERDICT: BLOCK',
|
||||
'',
|
||||
'Automated fallback — Opus merge gate did not post a PR comment.',
|
||||
'Automated fallback — Opus merge gate assessment did not complete.',
|
||||
];
|
||||
if (check.ready) {
|
||||
lines.push('All deterministic merge gates passed.');
|
||||
lines.push('Deterministic gates passed, but Opus MERGE_GATE_VERDICT is required before merge.');
|
||||
} else {
|
||||
lines.push('Blockers:', ...check.reasons.map((r) => `- ${r}`));
|
||||
}
|
||||
@@ -446,10 +353,9 @@ jobs:
|
||||
owner, repo, issue_number: prNumber,
|
||||
body: lines.join('\n'),
|
||||
});
|
||||
core.info(`Posted fallback verdict: ${verdict}`);
|
||||
core.info('Posted fallback BLOCK — Opus verdict required');
|
||||
|
||||
- name: Capture Opus MERGE_GATE_VERDICT
|
||||
id: capture_verdict
|
||||
- name: Require Opus MERGE_GATE_VERDICT
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -465,14 +371,14 @@ jobs:
|
||||
const verdict = mergeGate.findMergeGateVerdict(
|
||||
comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
core.setOutput('verdict', verdict || 'NONE');
|
||||
if (!verdict) {
|
||||
core.warning(
|
||||
'Opus MERGE_GATE_VERDICT not posted on PR (GitHub MCP tools may be blocked). Merge waits for APPROVE.'
|
||||
core.setFailed(
|
||||
'Opus merge gate assessment must post MERGE_GATE_VERDICT on this PR (automated fallback APPROVE is not accepted).'
|
||||
);
|
||||
} else {
|
||||
core.info(`Opus merge gate verdict: ${verdict}`);
|
||||
return;
|
||||
}
|
||||
core.info(`Opus merge gate verdict: ${verdict}`);
|
||||
core.setOutput('verdict', verdict);
|
||||
|
||||
- name: Remove merge gate active label
|
||||
if: always()
|
||||
@@ -493,6 +399,10 @@ jobs:
|
||||
core.info(`Label claude-merge-gate-active already removed or missing on PR #${prNumber}`);
|
||||
}
|
||||
|
||||
- name: Fail job if assessment failed
|
||||
if: steps.assess.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
merge-only:
|
||||
needs: [scan-candidates, claude-assess]
|
||||
if: |
|
||||
|
||||
@@ -140,32 +140,13 @@ jobs:
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }}
|
||||
run: |
|
||||
$expected = $env:RELEASE_TAG -replace '^v', ''
|
||||
$core = ($expected -split '[-+]')[0]
|
||||
$exe = "target/${{ matrix.target }}/release/praisonai-desktop.exe"
|
||||
$version = (Get-Item $exe).VersionInfo
|
||||
Write-Host "ProductVersion=$($version.ProductVersion) FileVersion=$($version.FileVersion)"
|
||||
|
||||
$numeric = ($expected -split '[-+]')[0] + '.0'
|
||||
$version = (Get-Item "target/${{ matrix.target }}/release/praisonai-desktop.exe").VersionInfo
|
||||
if ($version.ProductVersion -ne $expected) {
|
||||
throw "ProductVersion $($version.ProductVersion) does not match $expected"
|
||||
}
|
||||
|
||||
# Compare the three components that carry meaning, not the string.
|
||||
#
|
||||
# This gate blocked the v4.7.4 Windows build with
|
||||
# FileVersion 4.7.4 does not match 4.7.4.0
|
||||
# It appended ".0" and compared literally, but Windows reports what
|
||||
# was stamped -- three parts here. The stamping was correct and the
|
||||
# check was not, so the release shipped with no Windows installer at
|
||||
# all: the one platform it was cut to fix.
|
||||
#
|
||||
# [version] normalises a missing fourth component to -1, so both
|
||||
# "4.7.4" and "4.7.4.0" compare equal on major.minor.build.
|
||||
$got = [version]$version.FileVersion
|
||||
$want = [version]$core
|
||||
if ($got.Major -ne $want.Major -or
|
||||
$got.Minor -ne $want.Minor -or
|
||||
$got.Build -ne $want.Build) {
|
||||
throw "FileVersion $($version.FileVersion) does not match $core"
|
||||
if ($version.FileVersion -ne $numeric) {
|
||||
throw "FileVersion $($version.FileVersion) does not match $numeric"
|
||||
}
|
||||
|
||||
- name: The macOS bundle must carry a valid signature
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
# The mobile package's PLATFORM builds: a real iOS app and a real Android app.
|
||||
#
|
||||
# mobile.yml proves the crate type-checks for a phone (`cargo check` against
|
||||
# the two mobile targets). This proves the two generated platform projects,
|
||||
# src-tauri/gen/apple and src-tauri/gen/android, still turn that crate into an
|
||||
# installable app -- which is a different thing, and the thing that had never
|
||||
# happened: until #4673 neither project existed.
|
||||
#
|
||||
# What each job produces, and why that shape:
|
||||
#
|
||||
# ios-simulator -- an UNSIGNED arm64 simulator .app. A device build needs
|
||||
# an Apple Developer Team and a signing identity, which are
|
||||
# the maintainer's and not in this repo (bundle.iOS.
|
||||
# developmentTeam is unset in tauri.conf.json). A simulator
|
||||
# build needs neither, so it is the build that can run on
|
||||
# every PR. It is not decoration: it links the Rust
|
||||
# staticlib into the Xcode target, packages the webview
|
||||
# into the bundle and exercises Info.plist, i.e. every step
|
||||
# short of the certificate.
|
||||
# android-apk -- a debug APK, arm64-v8a. Debug because a release APK
|
||||
# needs a signing keystore, which is likewise the
|
||||
# maintainer's; the debug keystore is generated on the
|
||||
# runner. It installs on any arm64 Android 8.0+ device with
|
||||
# `adb install`.
|
||||
#
|
||||
# Both jobs fail if the thing they exist to produce is missing. The bundler
|
||||
# exits zero on a target it silently skipped (desktop-release.yml learned this
|
||||
# the hard way), so "the step passed" is not evidence; "the file exists and
|
||||
# contains the Rust library" is.
|
||||
#
|
||||
# The Xcode project is generated on the macOS runner the first time (this
|
||||
# laptop has no Xcode, only the Command Line Tools) and then committed, so
|
||||
# the init step is a no-op once gen/apple is in the tree. The Android project
|
||||
# was generated locally and is committed; the same guard keeps that job
|
||||
# honest if someone deletes it.
|
||||
name: mobile platforms
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths: &watched
|
||||
- 'src/praisonai-mobile/**'
|
||||
- 'src/praisonai-ts/src/**'
|
||||
- '.github/workflows/mobile-platforms.yml'
|
||||
# Without this, no platform build had ever run on main -- so a break
|
||||
# introduced by two PRs that were each green apart could sit on main
|
||||
# unnoticed. That is exactly what happened: #4737 branched from before
|
||||
# #4727 added the `file:` dependency, so the two were never installed
|
||||
# together until they had both landed.
|
||||
push:
|
||||
branches: [main]
|
||||
paths: *watched
|
||||
|
||||
concurrency:
|
||||
# Per-ref on a PR, per-SHA on main. This is the crux of the fix, not
|
||||
# cancel-in-progress alone: a concurrency group holds at most ONE
|
||||
# running run and ONE pending run, and a newer arrival always replaces
|
||||
# the pending one -- cancel-in-progress: false spares only the RUNNING
|
||||
# run, never the queued one. So with a single mobile-platforms-main
|
||||
# group, three merges in one ~3-minute window would still drop the
|
||||
# middle commit's run (it is only ever pending), which is precisely the
|
||||
# blindness the push trigger was added to remove. Giving each main SHA
|
||||
# its own group makes every commit a candidate release with its own
|
||||
# answer, with nothing to contend for and so nothing to cancel.
|
||||
group: mobile-platforms-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }}
|
||||
# On a PR, superseding an in-flight build is right: only the tip
|
||||
# matters and these jobs cost ~3 minutes on two runners. On main each
|
||||
# group has a single run, so this is a no-op there -- left true because
|
||||
# the condition that mattered is now expressed in the group name above,
|
||||
# and this keeps the PR behaviour explicit.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/praisonai-mobile
|
||||
|
||||
jobs:
|
||||
ios-simulator:
|
||||
name: ios simulator (.app, unsigned)
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.18'
|
||||
cache: npm
|
||||
cache-dependency-path: src/praisonai-mobile/package-lock.json
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
# Both: `tauri ios init` installs whatever it thinks is missing via
|
||||
# rustup, and having them already present keeps that step quiet and
|
||||
# deterministic. aarch64-apple-ios-sim is the one the build below
|
||||
# actually links against.
|
||||
targets: aarch64-apple-ios,aarch64-apple-ios-sim
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src/praisonai-mobile/src-tauri
|
||||
key: ios-sim
|
||||
- name: "Build praisonai-ts (the file: dependency)"
|
||||
# praisonai-mobile depends on praisonai by `file:`, so `npm ci`
|
||||
# here runs praisonai-ts's own `prepare` -> `rimraf dist`. On a
|
||||
# fresh runner praisonai-ts has no node_modules, so rimraf is not
|
||||
# installed and `npm ci` dies with code 127 before a single
|
||||
# platform step runs. mobile.yml gives all four of its jobs this
|
||||
# step; these two were written against the tree from before the
|
||||
# dependency existed and never got it.
|
||||
working-directory: src/praisonai-ts
|
||||
run: |
|
||||
npm install --no-audit --no-fund --legacy-peer-deps
|
||||
npm run build
|
||||
- run: npm ci
|
||||
|
||||
- name: Generate the Xcode project (only if gen/apple is not committed)
|
||||
# `tauri ios init` renders the template and then runs `xcodegen`, a
|
||||
# brew package the CLI would otherwise try to install itself, mid-run,
|
||||
# with a prompt. Installing it up front is the same outcome without
|
||||
# the prompt. cocoapods is only invoked when the config declares pods;
|
||||
# it declares none.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -d src-tauri/gen/apple ]; then
|
||||
echo "src-tauri/gen/apple is committed; not regenerating it"
|
||||
exit 0
|
||||
fi
|
||||
brew list xcodegen >/dev/null 2>&1 || brew install xcodegen
|
||||
npx tauri ios init --ci --skip-targets-install
|
||||
|
||||
- name: Put the real AppIcon set into the Xcode project
|
||||
# `tauri ios init` renders the CLI's OWN placeholder AppIcon PNGs into
|
||||
# gen/apple/Assets.xcassets, and nothing in the CLI ever replaces them:
|
||||
# `tauri icon` writes to src-tauri/icons/ios/ and stops there (it wires
|
||||
# up gen/android, but there is no iOS equivalent). So the copy is done
|
||||
# here, and every filename matches one-for-one -- both sides are named
|
||||
# by the same Contents.json.
|
||||
#
|
||||
# Idempotent, and it runs whether or not init just ran, so a
|
||||
# regenerated project cannot silently ship Tauri's icon as ours.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dest=src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset
|
||||
test -d "$dest" || { echo "::error::$dest does not exist"; find src-tauri/gen/apple -maxdepth 3 -print; exit 1; }
|
||||
cp src-tauri/icons/ios/*.png "$dest/"
|
||||
ls "$dest" | wc -l
|
||||
|
||||
- name: Build the simulator app, unsigned
|
||||
# --target aarch64-sim: the iphonesimulator SDK, arm64 (the runner is
|
||||
# Apple silicon, and so is every Mac that will run the simulator).
|
||||
# --no-sign: xcodebuild with CODE_SIGNING_ALLOWED=NO. For the
|
||||
# simulator SDK the CLI then lifts PraisonAI.app straight out of the
|
||||
# xcarchive instead of exporting an IPA (crates/tauri-cli/src/mobile/
|
||||
# ios/build.rs, the `target.sdk == "iphonesimulator"` branch), which
|
||||
# is exactly the artifact a simulator installs.
|
||||
# --debug: the dev profile links in seconds rather than minutes, and a
|
||||
# simulator build is never the one that ships.
|
||||
run: npx tauri ios build --debug --target aarch64-sim --no-sign --ci
|
||||
|
||||
- name: Is there an app, and is the Rust inside it?
|
||||
id: app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root=src-tauri/gen/apple/build
|
||||
# Printed before anything is asserted, so a failure here is
|
||||
# diagnosable from the log alone rather than costing another run.
|
||||
echo "--- what the build left behind"
|
||||
find "$root" -maxdepth 3 -print 2>/dev/null | head -40 || echo "(no build directory)"
|
||||
|
||||
# Prefer the simulator export dir (build/arm64-sim, the `arch` of the
|
||||
# aarch64-sim target); fall back to any .app so the error message can
|
||||
# say what WAS built. The name is not hardcoded: it comes from
|
||||
# productName via the CLI, and guessing it wrong is what failed here
|
||||
# the first time.
|
||||
app="$(find "$root" -type d -name '*.app' -path '*arm64-sim*' -print -quit 2>/dev/null || true)"
|
||||
[ -n "$app" ] || app="$(find "$root" -type d -name '*.app' -print -quit 2>/dev/null || true)"
|
||||
if [ -z "$app" ]; then
|
||||
echo "::error::no .app was produced under $root"
|
||||
exit 1
|
||||
fi
|
||||
echo "app: $app"
|
||||
|
||||
# The executable's name comes from the bundle itself for the same
|
||||
# reason.
|
||||
exe="$app/$(plutil -extract CFBundleExecutable raw "$app/Info.plist")"
|
||||
test -f "$exe" || { echo "::error::$app has no executable"; ls -la "$app"; exit 1; }
|
||||
file "$exe"
|
||||
file "$exe" | grep -q 'arm64' || { echo "::error::the executable is not arm64"; exit 1; }
|
||||
|
||||
# iOS links the Rust in as a staticlib, so unlike Android there is no
|
||||
# .so to look for -- the crate is INSIDE this binary, and its symbols
|
||||
# are the only real evidence it got there. An .app that built without
|
||||
# it would be the Swift template alone.
|
||||
#
|
||||
# Dumped to files rather than piped into `grep -q`: grep exits on the
|
||||
# FIRST match, the dumper then dies of SIGPIPE, and `set -o pipefail`
|
||||
# reports that as a failed pipeline -- so the check failed precisely
|
||||
# BECAUSE it found what it was looking for. It cost a CI run.
|
||||
nm -a "$exe" > "$RUNNER_TEMP/symbols.txt" 2>/dev/null || true
|
||||
strings -a "$exe" > "$RUNNER_TEMP/strings.txt" 2>/dev/null || true
|
||||
if grep -qi 'praisonai_mobile\|start_app' "$RUNNER_TEMP/symbols.txt" \
|
||||
|| grep -qi 'praisonai_mobile' "$RUNNER_TEMP/strings.txt"; then
|
||||
echo "the Rust crate is linked into the executable"
|
||||
else
|
||||
echo "::error::the Rust library is not linked into $exe"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The frontend is embedded in that same binary by generate_context!,
|
||||
# not copied into the bundle as a resource, so it is checked the same
|
||||
# way rather than by looking for an index.html that is never there.
|
||||
plutil -p "$app/Info.plist" | grep -E 'CFBundleIdentifier|CFBundleShortVersionString|MinimumOSVersion|UIDeviceFamily'
|
||||
test -f "$app/Assets.car" || echo "note: no compiled asset catalog in the bundle"
|
||||
du -sh "$app"
|
||||
|
||||
# ditto, not zip: it keeps the bundle's symlinks and modes, which is
|
||||
# the difference between an .app the simulator installs and one it
|
||||
# rejects as an invalid bundle.
|
||||
ditto -c -k --keepParent "$app" "$RUNNER_TEMP/PraisonAI-ios-simulator.app.zip"
|
||||
ls -la "$RUNNER_TEMP/PraisonAI-ios-simulator.app.zip"
|
||||
|
||||
- name: Upload the simulator app
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: PraisonAI-ios-simulator-app
|
||||
path: ${{ runner.temp }}/PraisonAI-ios-simulator.app.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Package gen/apple for committing
|
||||
# always(): the project is generated by `init`, so it is worth having
|
||||
# even on a run whose BUILD failed -- that is exactly the run where
|
||||
# someone needs to look at it.
|
||||
if: always()
|
||||
# Only what `git add src-tauri/gen/apple` would take: the template's
|
||||
# own .gitignore excludes build/, xcuserdata/ and Externals/, and this
|
||||
# respects it instead of re-listing them here. Nothing to upload when
|
||||
# the project was already committed -- the guard above skipped init.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
files="$(git ls-files --others --exclude-standard src-tauri/gen/apple || true)"
|
||||
if [ -z "$files" ]; then
|
||||
echo "gen/apple is tracked already; nothing to package"
|
||||
exit 0
|
||||
fi
|
||||
echo "$files" | tar -czf "$RUNNER_TEMP/gen-apple.tgz" -T -
|
||||
echo "$files" | wc -l
|
||||
ls -la "$RUNNER_TEMP/gen-apple.tgz"
|
||||
|
||||
- name: Upload gen/apple (only on the run that generated it)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gen-apple
|
||||
path: ${{ runner.temp }}/gen-apple.tgz
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
android-apk:
|
||||
name: android apk (debug, arm64)
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.18'
|
||||
cache: npm
|
||||
cache-dependency-path: src/praisonai-mobile/package-lock.json
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
# 17, not the runner's default 21 or a laptop's 25: Gradle 8.14.3's
|
||||
# Groovy front end rejects class files newer than it knows
|
||||
# ("Unsupported class file major version 69" on JDK 25), and 17 is
|
||||
# what AGP 8.11 documents.
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
- uses: android-actions/setup-android@v3
|
||||
with:
|
||||
# What gen/android/app/build.gradle.kts declares (compileSdk 36),
|
||||
# plus the build-tools AGP pairs with it. Anything else AGP needs it
|
||||
# downloads itself; the licences are accepted by this action.
|
||||
packages: 'platform-tools platforms;android-36 build-tools;36.0.0'
|
||||
- uses: nttld/setup-ndk@v1
|
||||
id: ndk
|
||||
with:
|
||||
# r28c = 28.2.13676358, the NDK gen/android was generated and first
|
||||
# built with.
|
||||
ndk-version: r28c
|
||||
add-to-path: false
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-linux-android
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src/praisonai-mobile/src-tauri
|
||||
key: android-apk
|
||||
- name: "Build praisonai-ts (the file: dependency)"
|
||||
# praisonai-mobile depends on praisonai by `file:`, so `npm ci`
|
||||
# here runs praisonai-ts's own `prepare` -> `rimraf dist`. On a
|
||||
# fresh runner praisonai-ts has no node_modules, so rimraf is not
|
||||
# installed and `npm ci` dies with code 127 before a single
|
||||
# platform step runs. mobile.yml gives all four of its jobs this
|
||||
# step; these two were written against the tree from before the
|
||||
# dependency existed and never got it.
|
||||
working-directory: src/praisonai-ts
|
||||
run: |
|
||||
npm install --no-audit --no-fund --legacy-peer-deps
|
||||
npm run build
|
||||
- run: npm ci
|
||||
|
||||
- name: Generate the Android project (only if gen/android is not committed)
|
||||
env:
|
||||
NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -d src-tauri/gen/android ]; then
|
||||
echo "src-tauri/gen/android is committed; not regenerating it"
|
||||
exit 0
|
||||
fi
|
||||
npx tauri android init --ci --skip-targets-install
|
||||
|
||||
- name: Build the debug APK
|
||||
env:
|
||||
NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
|
||||
# --target aarch64 only: every phone sold in the last several years is
|
||||
# arm64-v8a, and one target keeps the Rust build to one link. The
|
||||
# `universal` in the output path is Gradle's name for "the APK that is
|
||||
# not split per ABI", not a claim about what is inside it.
|
||||
run: npx tauri android build --debug --apk --target aarch64 --ci
|
||||
|
||||
- name: Is there an APK, and is the Rust inside it?
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root=src-tauri/gen/android/app/build/outputs/apk
|
||||
apk="$(find "$root" -name '*.apk' -print -quit || true)"
|
||||
if [ -z "$apk" ]; then
|
||||
echo "::error::no .apk was produced under $root"
|
||||
find "$root" -type f -print 2>/dev/null || echo "(the outputs directory does not exist)"
|
||||
exit 1
|
||||
fi
|
||||
echo "apk: $apk"
|
||||
ls -la "$apk"
|
||||
unzip -l "$apk" | grep -q 'lib/arm64-v8a/libpraisonai_mobile_lib.so' \
|
||||
|| { echo "::error::$apk does not contain lib/arm64-v8a/libpraisonai_mobile_lib.so"; unzip -l "$apk" | head -40; exit 1; }
|
||||
unzip -l "$apk" | grep -E 'lib/|AndroidManifest|classes.dex' | head
|
||||
cp "$apk" "$RUNNER_TEMP/PraisonAI-android-debug-arm64.apk"
|
||||
|
||||
- name: Upload the APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: PraisonAI-android-debug-arm64-apk
|
||||
path: ${{ runner.temp }}/PraisonAI-android-debug-arm64.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
@@ -1,164 +0,0 @@
|
||||
# The mobile package's WEB build: built, booted in a real browser, and
|
||||
# deployed to GitHub Pages.
|
||||
#
|
||||
# mobile.yml proves the package's tests pass. This workflow proves the thing
|
||||
# that ships to a browser actually ships: dist/ is built, served under the
|
||||
# repository subpath and driven through first paint, install, and an offline
|
||||
# reload by tools/web-boot.test.mjs, then published. A PR gets the build and
|
||||
# the proof; only main deploys.
|
||||
#
|
||||
# One setting is outside this file's reach: the repository's Pages source must
|
||||
# be "GitHub Actions" (Settings -> Pages -> Build and deployment -> Source).
|
||||
# Until a human turns that on, the deploy job SKIPS with a notice rather than
|
||||
# failing -- a red main every push because of a setting no workflow can set
|
||||
# would train people to ignore the mobile checks, which is how the last silent
|
||||
# breakage in this package survived.
|
||||
name: mobile web
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: &watched
|
||||
- 'src/praisonai-mobile/**'
|
||||
- '.github/workflows/mobile-web.yml'
|
||||
# `praisonai` is a `file:` link to ../praisonai-ts, and the web bundle is
|
||||
# built FROM it -- so drift on that side changes what ships to a browser
|
||||
# and must trigger this gate. mobile.yml watches these same paths for the
|
||||
# same reason; a web gate that watched only praisonai-mobile would go
|
||||
# quiet exactly when the dependency it bundles moved.
|
||||
- 'src/praisonai-ts/src/**'
|
||||
- 'src/praisonai-ts/scripts/**'
|
||||
- 'src/praisonai-ts/package.json'
|
||||
pull_request:
|
||||
paths: *watched
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/praisonai-mobile
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build and boot
|
||||
runs-on: ubuntu-22.04
|
||||
concurrency:
|
||||
group: mobile-web-build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.18'
|
||||
cache: npm
|
||||
cache-dependency-path: src/praisonai-mobile/package-lock.json
|
||||
# `praisonai` is a `file:` link to ../praisonai-ts, so its dist/ must
|
||||
# exist before this package can even INSTALL: npm runs the linked
|
||||
# package's prepare/prebuild, which fails without its own node_modules.
|
||||
# `npm install`, not `npm ci`: praisonai-ts gitignores its lockfile.
|
||||
# `--legacy-peer-deps` for the bedrock-agentcore -> @strands-agents/sdk
|
||||
# peer conflict, exactly as mobile.yml and praisonai-ts-webview.yml do.
|
||||
- name: "Build praisonai-ts (the file: dependency)"
|
||||
working-directory: src/praisonai-ts
|
||||
run: |
|
||||
npm install --no-audit --no-fund --legacy-peer-deps
|
||||
npm run build
|
||||
- run: npm ci
|
||||
- name: Cache the Playwright browser
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('src/praisonai-mobile/package-lock.json') }}
|
||||
- name: Install Chromium for the boot proof
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Boot proof
|
||||
# Serves dist/ under /PraisonAI/ in headless Chromium: composer and
|
||||
# Send render, manifest served as application/manifest+json, service
|
||||
# worker activates with the subpath as scope, an offline reload still
|
||||
# renders, and an injected inline script is blocked by the CSP.
|
||||
run: npm run test:web
|
||||
- name: dist contents
|
||||
run: ls -lR dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mobile-web-dist
|
||||
path: src/praisonai-mobile/dist
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
with:
|
||||
path: src/praisonai-mobile/dist
|
||||
|
||||
# The readiness check is its OWN job, and deliberately declares no
|
||||
# `environment:`. A job that names `environment: github-pages` cannot START
|
||||
# until that environment exists, so when Pages is off the runner fails the
|
||||
# job outright -- zero steps, about one second -- and every carefully
|
||||
# written skip inside it is unreachable. Measured: run 33730322658 on main,
|
||||
# "deploy to GitHub Pages" failed with steps: 0.
|
||||
#
|
||||
# Splitting the decision out means the check runs with no preconditions and
|
||||
# the environment-bearing job is never scheduled unless it can succeed.
|
||||
pages-ready:
|
||||
name: is GitHub Pages enabled?
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs: build
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
# `gh api repos/<owner>/<repo>/pages` needs Pages read. Without it the
|
||||
# request is rejected on a private repo, `2>/dev/null || echo '{}'`
|
||||
# swallows the error to `{}`, build_type falls to "none", and the deploy
|
||||
# SKIPS even though Pages is on -- the exact silent skip this file exists
|
||||
# to prevent. `contents: read` alone leaves this permission at `none`.
|
||||
pages: read
|
||||
outputs:
|
||||
ready: ${{ steps.check.outputs.ready }}
|
||||
steps:
|
||||
# Pages cannot be enabled from a workflow, so this reports the setting
|
||||
# rather than demanding it. `ready` false SKIPS the deploy with a
|
||||
# notice; it does not fail the run. deploy-pages would otherwise fail
|
||||
# with a bare 404 that says nothing about which setting is missing.
|
||||
- name: Is the Pages source "GitHub Actions"?
|
||||
id: check
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
working-directory: .
|
||||
run: |
|
||||
site=$(gh api "repos/${{ github.repository }}/pages" 2>/dev/null || echo '{}')
|
||||
build_type=$(echo "$site" | jq -r '.build_type // "none"')
|
||||
cname=$(echo "$site" | jq -r '.cname // ""')
|
||||
echo "build_type=$build_type cname=${cname:-<none>}"
|
||||
if [ "$build_type" != "workflow" ]; then
|
||||
echo "::notice::Skipping the Pages deploy: Pages is not set to build from GitHub Actions (build_type=$build_type). A repository admin must set it at https://github.com/${{ github.repository }}/settings/pages -> Build and deployment -> Source: GitHub Actions. The built site is attached to this run as the mobile-web-dist artifact."
|
||||
echo "ready=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "$cname" ]; then
|
||||
echo "::notice::Skipping the Pages deploy: a custom domain ($cname) is set, so the app would be served there rather than at the github.io URL."
|
||||
echo "ready=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "ready=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
deploy:
|
||||
name: deploy to GitHub Pages
|
||||
needs: pages-ready
|
||||
if: needs.pages-ready.outputs.ready == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
concurrency:
|
||||
# Never cancel a deployment in flight; queue behind it instead.
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/configure-pages@v5
|
||||
- uses: actions/deploy-pages@v4
|
||||
id: deployment
|
||||
@@ -26,22 +26,9 @@ name: mobile
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: &watched
|
||||
- 'src/praisonai-mobile/**'
|
||||
- '.github/workflows/mobile.yml'
|
||||
# The `upstream parity` job below typechecks the REAL praisonai-ts Agent
|
||||
# against the structural contract in engines/src/praisonai-ts/agent-api.ts.
|
||||
# Watching only praisonai-mobile made that gate fire when the CONSUMER
|
||||
# changed and stay silent when the thing it watches did -- so upstream
|
||||
# drift landed on main unchecked. Verified on 71197fa80, which edited
|
||||
# src/praisonai-ts/src/agent/simple.ts: no parity check ran on it.
|
||||
- 'src/praisonai-ts/src/**'
|
||||
# And the pieces of praisonai-ts that decide what `praisonai/mobile`
|
||||
# RESOLVES to through the file: link -- the esm shim shapes dist/esm.
|
||||
- 'src/praisonai-ts/scripts/**'
|
||||
- 'src/praisonai-ts/package.json'
|
||||
paths: ['src/praisonai-mobile/**', '.github/workflows/mobile.yml']
|
||||
pull_request:
|
||||
paths: *watched
|
||||
paths: ['src/praisonai-mobile/**', '.github/workflows/mobile.yml']
|
||||
|
||||
concurrency:
|
||||
group: mobile-${{ github.ref }}
|
||||
@@ -70,27 +57,7 @@ jobs:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
cache-dependency-path: src/praisonai-mobile/package-lock.json
|
||||
# `praisonai` is a `file:` link to ../praisonai-ts (see praisonai-mobile's
|
||||
# package.json for why), so its dist/ must exist before this package can
|
||||
# resolve `praisonai/mobile`. `npm install`, not `npm ci`: praisonai-ts
|
||||
# gitignores its lockfile. `--legacy-peer-deps` for the
|
||||
# bedrock-agentcore -> @strands-agents/sdk peer conflict, exactly as
|
||||
# praisonai-ts-webview.yml does.
|
||||
- name: "Build praisonai-ts (the file: dependency)"
|
||||
working-directory: src/praisonai-ts
|
||||
run: |
|
||||
npm install --no-audit --no-fund --legacy-peer-deps
|
||||
npm run build
|
||||
- run: npm ci
|
||||
- name: Cache the Playwright browser
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('src/praisonai-mobile/package-lock.json') }}
|
||||
- name: Install Chromium for the boot proof
|
||||
# `npm test` includes tools/web-boot.test.mjs, which loads the built
|
||||
# dist/ in a real browser. It fails rather than skips without one.
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
- name: Layer boundaries
|
||||
@@ -109,11 +76,11 @@ jobs:
|
||||
# rather than buried in another job's log.
|
||||
#
|
||||
# It needs only THIS package's install. praisonai-ts's own dependencies are
|
||||
# not needed here (the `check` job installs them, with --legacy-peer-deps,
|
||||
# to build the file: dependency): every member the check looks at is
|
||||
# declared locally in simple.ts. That is verified rather than assumed:
|
||||
# with praisonai-ts's node_modules moved aside, injected drift is still
|
||||
# caught as TS2741.
|
||||
# neither needed nor installable -- `npm install` there fails with an
|
||||
# ERESOLVE peer conflict (bedrock-agentcore -> @strands-agents/sdk) -- and
|
||||
# every member the check looks at is declared locally in simple.ts. That is
|
||||
# verified rather than assumed: with praisonai-ts's node_modules moved
|
||||
# aside, injected drift is still caught as TS2741.
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -122,142 +89,9 @@ jobs:
|
||||
node-version: '22.18'
|
||||
cache: npm
|
||||
cache-dependency-path: src/praisonai-mobile/package-lock.json
|
||||
- name: "Build praisonai-ts (the file: dependency)"
|
||||
working-directory: src/praisonai-ts
|
||||
run: |
|
||||
npm install --no-audit --no-fund --legacy-peer-deps
|
||||
npm run build
|
||||
- run: npm ci
|
||||
- name: Does the real Agent still satisfy PraisonAgent?
|
||||
# Skips loudly if the praisonai-ts checkout is missing, rather than
|
||||
# passing quietly -- a check that silently stops covering anything is
|
||||
# worse than no check.
|
||||
run: npm run check:upstream
|
||||
|
||||
shell:
|
||||
name: rust shell (${{ matrix.os }})
|
||||
# The Rust crate had 14 tests and no job running them -- the same failure
|
||||
# this workflow's header says it was created to prevent, one language over.
|
||||
#
|
||||
# Both platforms, because the crate is cfg-heavy: the back-gesture fallback
|
||||
# is #[cfg(target_os = "android")] / ios / not(mobile), and a cfg mistake
|
||||
# compiles perfectly on whichever host you happened to try.
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-22.04, macos-15]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/praisonai-mobile/src-tauri
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.18'
|
||||
cache: npm
|
||||
cache-dependency-path: src/praisonai-mobile/package-lock.json
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src/praisonai-mobile/src-tauri
|
||||
- name: Install the Linux build dependencies
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: .
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libwebkit2gtk-4.1-dev librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
# generate_context! expands frontendDist (../dist) at COMPILE time, and
|
||||
# dist/ is gitignored -- absent on a fresh checkout. Without this the
|
||||
# crate cannot compile and neither test nor clippy below ever runs.
|
||||
# `praisonai` is a `file:` link to ../praisonai-ts (see praisonai-mobile's
|
||||
# package.json for why), so its dist/ must exist before this package can
|
||||
# resolve `praisonai/mobile`. `npm install`, not `npm ci`: praisonai-ts
|
||||
# gitignores its lockfile. `--legacy-peer-deps` for the
|
||||
# bedrock-agentcore -> @strands-agents/sdk peer conflict, exactly as
|
||||
# praisonai-ts-webview.yml does.
|
||||
- name: "Build praisonai-ts (the file: dependency)"
|
||||
working-directory: src/praisonai-ts
|
||||
run: |
|
||||
npm install --no-audit --no-fund --legacy-peer-deps
|
||||
npm run build
|
||||
- name: Build the webview (creates ../dist for generate_context!)
|
||||
working-directory: src/praisonai-mobile
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
# The pure-logic tests: back-gesture arbitration, the lifecycle mapping,
|
||||
# and the event-name contract. None of them needs a phone.
|
||||
- name: Test
|
||||
run: cargo test
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# The mobile targets, which nothing compiled.
|
||||
#
|
||||
# `src/lib.rs` is `#[cfg_attr(mobile, tauri::mobile_entry_point)]`, and
|
||||
# `tauri_build::build()` emits `cargo:rustc-cfg=mobile` only for android and
|
||||
# ios targets. Both legs of `rust shell` above are HOST targets, so that
|
||||
# attribute -- the one that generates the Android JNI symbol and the iOS
|
||||
# start_app symbol -- expanded exactly zero times in CI, and neither did the
|
||||
# `#[cfg(target_os = "android")]` / `ios` arms of `commands.rs`. The job
|
||||
# above says it covers cfg mistakes; it covers the ones in `shell/`, not
|
||||
# these.
|
||||
#
|
||||
# `cargo check`, not `build`: it type-checks and macro-expands under the
|
||||
# mobile cfg, which is the thing that was never done, and needs no NDK and no
|
||||
# Xcode project. Verified at 18s for the android target.
|
||||
# ------------------------------------------------------------------
|
||||
cross-check:
|
||||
name: cross-check (${{ matrix.target }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-linux-android
|
||||
os: ubuntu-22.04
|
||||
- target: aarch64-apple-ios
|
||||
os: macos-15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/praisonai-mobile/src-tauri
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.18'
|
||||
cache: npm
|
||||
cache-dependency-path: src/praisonai-mobile/package-lock.json
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src/praisonai-mobile/src-tauri
|
||||
key: ${{ matrix.target }}
|
||||
# generate_context! expands frontendDist at COMPILE time and dist/ is
|
||||
# gitignored, so the crate cannot be checked without this -- the same
|
||||
# reason the job above needs it.
|
||||
# `praisonai` is a `file:` link to ../praisonai-ts (see praisonai-mobile's
|
||||
# package.json for why), so its dist/ must exist before this package can
|
||||
# resolve `praisonai/mobile`. `npm install`, not `npm ci`: praisonai-ts
|
||||
# gitignores its lockfile. `--legacy-peer-deps` for the
|
||||
# bedrock-agentcore -> @strands-agents/sdk peer conflict, exactly as
|
||||
# praisonai-ts-webview.yml does.
|
||||
- name: "Build praisonai-ts (the file: dependency)"
|
||||
working-directory: src/praisonai-ts
|
||||
run: |
|
||||
npm install --no-audit --no-fund --legacy-peer-deps
|
||||
npm run build
|
||||
- name: Build the webview (creates ../dist for generate_context!)
|
||||
working-directory: src/praisonai-mobile
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
- name: Does the crate still expand and type-check for a phone?
|
||||
run: cargo check --lib --target ${{ matrix.target }}
|
||||
@@ -1,106 +0,0 @@
|
||||
name: Parity Gate
|
||||
|
||||
# Validates Python <-> TypeScript SDK parity on pull requests and FAILS on drift.
|
||||
#
|
||||
# Two named checks, so each kind of drift is visible in the checks list:
|
||||
# names - the exported-name tracker (PARITY.md / FEATURE_PARITY_TRACKER.json)
|
||||
# must be regenerated in the PR when the public surface changes.
|
||||
# behaviour - options accepted for parity but not yet acted on; a ratchet,
|
||||
# so the count may fall and never rise.
|
||||
# signatures - constructor/method parameters of the curated surfaces in
|
||||
# src/praisonai/praisonai/_dev/parity/signatures/surface.yaml must
|
||||
# match (after camelCase/alias/flattening rules) or be waived with a
|
||||
# reason in waivers.yaml. Stale or expired waivers also fail.
|
||||
#
|
||||
# The regenerate-and-commit job stays in update-parity-tracker.yml (push to main).
|
||||
# This workflow never commits; it only reports.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/praisonai-agents/praisonaiagents/**/*.py'
|
||||
- 'src/praisonai-ts/src/**/*.ts'
|
||||
- 'src/praisonai-rust/praisonai/src/**/*.rs'
|
||||
- 'src/praisonai/praisonai/_dev/parity/**'
|
||||
- 'src/praisonai/praisonai/cli/features/**'
|
||||
# The generator the `names` job runs. Editing it changed what the gate
|
||||
# measured without the gate running.
|
||||
- 'src/praisonai/scripts/**'
|
||||
- 'src/praisonai-ts/PARITY.md'
|
||||
- 'src/praisonai-ts/FEATURE_PARITY_TRACKER.json'
|
||||
- 'src/praisonai-ts/SIGNATURE_PARITY.md'
|
||||
- 'src/praisonai-ts/signature-parity.json'
|
||||
# The behaviour baseline itself. These two carry the ratchet floor, so a
|
||||
# pull request that edited only them raised the floor with no job running.
|
||||
- 'src/praisonai-ts/BEHAVIOUR_PARITY.md'
|
||||
- 'src/praisonai-ts/behaviour-parity.json'
|
||||
- 'src/praisonai-rust/PARITY.md'
|
||||
- 'src/praisonai-rust/FEATURE_PARITY_TRACKER.json'
|
||||
- '.github/workflows/parity-gate.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
names:
|
||||
name: names (exported-symbol tracker up to date)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: pip install pyyaml
|
||||
- name: Tracker files match the sources
|
||||
run: |
|
||||
if ! python3 src/praisonai/scripts/generate_parity_tracker.py --check; then
|
||||
echo "::error::Parity tracker is out of date. Run: python3 src/praisonai/scripts/generate_parity_tracker.py and commit src/praisonai-ts/PARITY.md, FEATURE_PARITY_TRACKER.json and the Rust pair."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
signatures:
|
||||
name: signatures (constructor and method parameters)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- run: pip install pyyaml
|
||||
- name: Install the TypeScript compiler only
|
||||
# praisonai-ts's full dependency tree is not needed (and does not
|
||||
# install cleanly); the extractor needs only the `typescript` package.
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/parity-ts"
|
||||
npm install --prefix "$RUNNER_TEMP/parity-ts" --no-audit --no-fund [email protected]
|
||||
echo "PARITY_TS_NODE_MODULES=$RUNNER_TEMP/parity-ts/node_modules" >> "$GITHUB_ENV"
|
||||
- name: Signatures match, or are waived with a reason
|
||||
env:
|
||||
PYTHONPATH: src/praisonai
|
||||
run: |
|
||||
if ! python3 -m praisonai._dev.parity.signatures --check; then
|
||||
echo "::error::Signature parity failed. See the output above for the surface and parameter. Fix the TypeScript side, or add a waiver with a reason to src/praisonai/praisonai/_dev/parity/signatures/waivers.yaml, then run: PYTHONPATH=src/praisonai python3 -m praisonai._dev.parity.signatures --write"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
behaviour:
|
||||
name: behaviour (options accepted but not acted on)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: pip install pyyaml
|
||||
- name: The count of ignored options has not grown
|
||||
env:
|
||||
PYTHONPATH: src/praisonai
|
||||
run: |
|
||||
if ! python3 -m praisonai._dev.parity.behaviour --check; then
|
||||
echo "::error::An option accepted for Python parity is not acted on, and the total grew. The name and signature gates cannot see this: they check that a parameter EXISTS. Implement the behaviour and delete its entry from src/praisonai-ts/src/utils/parity-notice.ts, then run: PYTHONPATH=src/praisonai python3 -m praisonai._dev.parity.behaviour --write"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,70 +0,0 @@
|
||||
# Does praisonai-ts still pass its own tests?
|
||||
#
|
||||
# It has a suite -- 2,000+ tests -- and until now nothing ran it. The only
|
||||
# workflow with `working-directory: src/praisonai-ts` builds the package and
|
||||
# checks the webview bundle; the two other mentions of `npm test` in this
|
||||
# directory are a comment and a line of prose telling a reviewer to run it by
|
||||
# hand. So the suite was green the way an unread report is green.
|
||||
#
|
||||
# That mattered the moment the suite grew: PR #4755 added ~880 tests for the
|
||||
# Python-parity port and not one of them would have run here. A test nobody
|
||||
# executes is worse than no test, because it is counted as cover.
|
||||
#
|
||||
# Deliberately narrow, like its webview sibling: it runs only when
|
||||
# praisonai-ts changes, installs only what it needs, and asserts two things --
|
||||
# the package typechecks, and its tests pass.
|
||||
name: praisonai-ts tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/praisonai-ts/src/**'
|
||||
- 'src/praisonai-ts/tests/**'
|
||||
- 'src/praisonai-ts/package.json'
|
||||
- 'src/praisonai-ts/jest.config.ts'
|
||||
- 'src/praisonai-ts/tsconfig*.json'
|
||||
- '.github/workflows/praisonai-ts-tests.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/praisonai-ts/src/**'
|
||||
- 'src/praisonai-ts/tests/**'
|
||||
- 'src/praisonai-ts/package.json'
|
||||
- 'src/praisonai-ts/jest.config.ts'
|
||||
- 'src/praisonai-ts/tsconfig*.json'
|
||||
- '.github/workflows/praisonai-ts-tests.yml'
|
||||
|
||||
concurrency:
|
||||
group: praisonai-ts-tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: unit tests
|
||||
runs-on: ubuntu-22.04
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/praisonai-ts
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.18'
|
||||
# `npm install --legacy-peer-deps`, matching praisonai-ts-webview.yml:
|
||||
# the lockfile is gitignored so `npm ci` has nothing to read, and the
|
||||
# dependency tree does not resolve without the flag (bedrock-agentcore
|
||||
# wants a peer of @strands-agents/sdk that is not installed).
|
||||
- name: Install
|
||||
run: npm install --no-audit --no-fund --legacy-peer-deps
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit -p .
|
||||
# PRAISONAI_PARITY_SILENT quiets the one-line notices that options
|
||||
# accepted for Python parity print when they are not yet honoured; they
|
||||
# are deliberate output, not failures, and they drown the test log.
|
||||
- name: Tests
|
||||
run: npm test
|
||||
env:
|
||||
PRAISONAI_PARITY_SILENT: '1'
|
||||
@@ -759,126 +759,6 @@ jobs:
|
||||
if: steps.check-key.outputs.has_key == 'false'
|
||||
run: echo "✅ Cohere tests skipped (no API key available)"
|
||||
|
||||
# ============================================
|
||||
# OLLAMA LIVE TESTS - Real local model, no API key
|
||||
# Nightly / extended only. Installs Ollama on the runner and pulls a small
|
||||
# tools-capable model. Unlike the other live jobs this one needs no secret,
|
||||
# so its required step has no "|| echo" fallback: a failure here is a real
|
||||
# regression in local-model tool calling, not a missing credential.
|
||||
# ============================================
|
||||
extended-ollama:
|
||||
if: github.event_name == 'schedule' || github.event.inputs.tier == 'extended' || github.event.inputs.tier == 'nightly'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
# Bump deliberately: this is the version everything in this job was
|
||||
# verified against. Latest at time of writing is v0.33.3.
|
||||
OLLAMA_VERSION: v0.33.2
|
||||
# 522 MB, capabilities: completion, tools, thinking.
|
||||
OLLAMA_TEST_MODEL: qwen3:0.6b
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Cache pip packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-ollama-${{ hashFiles('**/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Cache Ollama model blobs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
# No restore-keys on purpose: a partial hit from a different model would
|
||||
# silently test something other than OLLAMA_TEST_MODEL.
|
||||
key: ${{ runner.os }}-ollama-models-${{ env.OLLAMA_TEST_MODEL }}
|
||||
|
||||
- name: Install Ollama
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The official installer, but VERSION-PINNED via OLLAMA_VERSION (which it
|
||||
# honours) so this job cannot silently change what it tests. Fetching the
|
||||
# release asset directly is not a workable alternative: since v0.33 the
|
||||
# linux bundle ships as a 1.4 GB .tar.zst with GPU runtimes bundled in,
|
||||
# and the installer already handles the zst/tgz split across versions.
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq zstd
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
ollama --version
|
||||
|
||||
- name: Start Ollama and pull model
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ollama serve > /tmp/ollama-serve.log 2>&1 &
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:11434/api/tags > /dev/null; then
|
||||
echo "Ollama is up after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
curl -sf http://127.0.0.1:11434/api/tags > /dev/null
|
||||
ollama pull "$OLLAMA_TEST_MODEL"
|
||||
ollama list
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install pytest pytest-asyncio pytest-timeout
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
with:
|
||||
agents-extras: ".[all]"
|
||||
|
||||
- name: Run Ollama minimum contract (required)
|
||||
env:
|
||||
PRAISONAI_TEST_TIER: extended
|
||||
PRAISONAI_ALLOW_NETWORK: '1'
|
||||
PRAISONAI_LIVE_TESTS: '1'
|
||||
PRAISONAI_TEST_PROVIDERS: 'ollama'
|
||||
PRAISONAI_TEST_OLLAMA: '1'
|
||||
PRAISONAI_OLLAMA_TEST_MODEL: ${{ env.OLLAMA_TEST_MODEL }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src/praisonai
|
||||
# No "|| echo" here: no secret is involved, so a failure is a real
|
||||
# regression rather than a missing credential.
|
||||
python -m pytest tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
|
||||
-v --tb=short \
|
||||
--timeout=120
|
||||
|
||||
- name: Run remaining Ollama live tests (informational)
|
||||
if: always()
|
||||
env:
|
||||
PRAISONAI_TEST_TIER: extended
|
||||
PRAISONAI_ALLOW_NETWORK: '1'
|
||||
PRAISONAI_LIVE_TESTS: '1'
|
||||
PRAISONAI_TEST_PROVIDERS: 'ollama'
|
||||
PRAISONAI_TEST_OLLAMA: '1'
|
||||
PRAISONAI_OLLAMA_TEST_MODEL: ${{ env.OLLAMA_TEST_MODEL }}
|
||||
run: |
|
||||
cd src/praisonai
|
||||
python -m pytest tests/integration/test_ollama_tool_calling_live.py \
|
||||
--deselect tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
|
||||
-v --tb=short \
|
||||
--timeout=180 \
|
||||
|| echo "Ollama exploratory tests completed (small models are non-deterministic)"
|
||||
|
||||
- name: Ollama server log
|
||||
if: failure()
|
||||
run: tail -100 /tmp/ollama-serve.log
|
||||
|
||||
# ============================================
|
||||
# LEGACY UNIT TESTS - Post-C6 re-enabled suite
|
||||
# Nightly / extended tier only (~401 tests)
|
||||
@@ -956,7 +836,7 @@ jobs:
|
||||
# TEST SUMMARY
|
||||
# ============================================
|
||||
test-summary:
|
||||
needs: [smoke, main, extended-ollama]
|
||||
needs: [smoke, main]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
@@ -6,11 +6,7 @@ on:
|
||||
paths:
|
||||
- 'src/praisonai-agents/praisonaiagents/**/*.py'
|
||||
- 'src/praisonai-ts/src/**/*.ts'
|
||||
# The crate lives at src/praisonai-rust/praisonai/src -- the old
|
||||
# 'src/praisonai-rust/src/**' path does not exist, so a Rust change
|
||||
# never triggered either tracker and the Rust numbers only ever
|
||||
# refreshed incidentally, on an unrelated push.
|
||||
- 'src/praisonai-rust/praisonai/src/**/*.rs'
|
||||
- 'src/praisonai-rust/src/**/*.rs'
|
||||
- 'src/praisonai/praisonai/_dev/parity/**/*.py'
|
||||
- '.github/workflows/update-docs-parity.yml'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
@@ -1,35 +1,21 @@
|
||||
name: Update Feature Parity Tracker
|
||||
|
||||
# Regenerates the exported-name parity trackers on push to main and commits them.
|
||||
# Validation on pull requests (fail on drift) lives in parity-gate.yml.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/praisonai-agents/praisonaiagents/**/*.py'
|
||||
- 'src/praisonai-ts/src/**/*.ts'
|
||||
# The crate lives at src/praisonai-rust/praisonai/src -- the old
|
||||
# 'src/praisonai-rust/src/**' path does not exist, so a Rust change
|
||||
# never triggered either tracker and the Rust numbers only ever
|
||||
# refreshed incidentally, on an unrelated push.
|
||||
- 'src/praisonai-rust/praisonai/src/**/*.rs'
|
||||
# The generator's own code and the wrapper features it counts. Change
|
||||
# either and nothing regenerated until an unrelated push landed.
|
||||
- 'src/praisonai/praisonai/_dev/parity/**/*.py'
|
||||
- 'src/praisonai/praisonai/cli/features/**/*.py'
|
||||
- 'src/praisonai-rust/src/**/*.rs'
|
||||
- '.github/workflows/update-parity-tracker.yml'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-parity-tracker:
|
||||
# Push only. On a PR the parity-gate.yml `names` job CHECKS instead --
|
||||
# committing a regenerated tracker onto a contributor's branch is not this
|
||||
# workflow's business, and would fail on a fork besides.
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -46,45 +32,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install the TypeScript compiler only
|
||||
# The signature extractor needs `typescript` and nothing else;
|
||||
# praisonai-ts's own dependency tree does not install cleanly.
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/parity-ts"
|
||||
npm install --prefix "$RUNNER_TEMP/parity-ts" --no-audit --no-fund [email protected]
|
||||
echo "PARITY_TS_NODE_MODULES=$RUNNER_TEMP/parity-ts/node_modules" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate parity trackers
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE
|
||||
python3 src/praisonai/scripts/generate_parity_tracker.py
|
||||
|
||||
- name: Regenerate the signature report
|
||||
# Source locations in the report drift whenever a covered file is edited.
|
||||
# The pull-request gate ignores line-number-only drift so it never reddens
|
||||
# a build over it; refreshing here keeps the committed line numbers true.
|
||||
env:
|
||||
PYTHONPATH: src/praisonai
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE
|
||||
python3 -m praisonai._dev.parity.signatures --write
|
||||
|
||||
- name: Regenerate the behaviour report
|
||||
env:
|
||||
PYTHONPATH: src/praisonai
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE
|
||||
python3 -m praisonai._dev.parity.behaviour --write
|
||||
|
||||
|
||||
- name: Check for changes
|
||||
id: check_changes
|
||||
run: |
|
||||
if git diff --quiet src/praisonai-ts/FEATURE_PARITY_TRACKER.json src/praisonai-ts/PARITY.md src/praisonai-rust/FEATURE_PARITY_TRACKER.json src/praisonai-rust/PARITY.md src/praisonai-ts/SIGNATURE_PARITY.md src/praisonai-ts/signature-parity.json src/praisonai-ts/BEHAVIOUR_PARITY.md src/praisonai-ts/behaviour-parity.json 2>/dev/null; then
|
||||
if git diff --quiet src/praisonai-ts/FEATURE_PARITY_TRACKER.json src/praisonai-ts/PARITY.md src/praisonai-rust/FEATURE_PARITY_TRACKER.json src/praisonai-rust/PARITY.md 2>/dev/null; then
|
||||
echo "changes=false" >> $GITHUB_OUTPUT
|
||||
echo "No changes to parity trackers"
|
||||
else
|
||||
@@ -98,7 +54,8 @@ jobs:
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: "docs: auto-update feature parity trackers [skip ci]"
|
||||
file_pattern: 'src/praisonai-ts/FEATURE_PARITY_TRACKER.json src/praisonai-ts/PARITY.md src/praisonai-rust/FEATURE_PARITY_TRACKER.json src/praisonai-rust/PARITY.md src/praisonai-ts/SIGNATURE_PARITY.md src/praisonai-ts/signature-parity.json src/praisonai-ts/BEHAVIOUR_PARITY.md src/praisonai-ts/behaviour-parity.json'
|
||||
file_pattern: 'src/praisonai-ts/FEATURE_PARITY_TRACKER.json src/praisonai-ts/PARITY.md src/praisonai-rust/FEATURE_PARITY_TRACKER.json src/praisonai-rust/PARITY.md'
|
||||
commit_user_name: "MervinPraison"
|
||||
commit_user_email: "[email protected]"
|
||||
commit_author: "MervinPraison <[email protected]>"
|
||||
|
||||
+1
-4
@@ -115,7 +115,4 @@ Cargo.lock
|
||||
src/praisonai/build*.egg-info/
|
||||
src/praisonai/PraisonAI.egg-info/
|
||||
jobs/*
|
||||
*.egg-info
|
||||
# Local Android SDK/NDK/JDK for building praisonai-mobile on a laptop without
|
||||
# Android Studio (how: .github/workflows/mobile-platforms.yml). Several GB.
|
||||
.android-sdk/
|
||||
*.egg-info
|
||||
+12
-12
@@ -222,15 +222,15 @@ The Python SDK is organised as three publishable tiers (see [§2](#2-python-thre
|
||||
|
||||
Core execution modules live in `praisonaiagents`:
|
||||
|
||||
- `src/praisonai-agents/praisonaiagents/agent/` — Agent class, handoff, autonomy
|
||||
- `src/praisonai-agents/praisonaiagents/llm/` — Model runtime with provider routing, rate limiting, failover
|
||||
- `src/praisonai-agents/praisonaiagents/tools/` — Tool runtime with sandbox, approval, retry
|
||||
- `src/praisonai-agents/praisonaiagents/memory/` — Memory runtime (in-memory, SQLite, MongoDB, Mem0 adapters)
|
||||
- `src/praisonai-agents/praisonaiagents/knowledge/` — Knowledge management (indexing, retrieval, chunking)
|
||||
- `src/praisonai-agents/praisonaiagents/workflows/` — Workflow engine (YAML/SDK-based orchestration)
|
||||
- `src/praisonai-agents/praisonaiagents/hooks/`, `src/praisonai-agents/praisonaiagents/bus/` — Hook system and event bus
|
||||
- `src/praisonai-agents/praisonaiagents/mcp/` — MCP protocol support
|
||||
- `src/praisonai-agents/praisonaiagents/ui/a2a/`, `src/praisonai-agents/praisonaiagents/ui/a2ui/` — Agent-to-agent and agent-to-UI protocols
|
||||
- `agent/` — Agent class, handoff, autonomy
|
||||
- `llm/` — Model runtime with provider routing, rate limiting, failover
|
||||
- `tools/` — Tool runtime with sandbox, approval, retry
|
||||
- `memory/` — Memory runtime (in-memory, SQLite, MongoDB, Mem0 adapters)
|
||||
- `knowledge/` — Knowledge management (indexing, retrieval, chunking)
|
||||
- `workflows/` — Workflow engine (YAML/SDK-based orchestration)
|
||||
- `hooks/`, `bus/` — Hook system and event bus
|
||||
- `mcp/` — MCP protocol support
|
||||
- `a2a/`, `a2ui/` — Agent-to-agent and agent-to-UI protocols (wrapper/SDK)
|
||||
|
||||
Wrapper-only surfaces remain in `src/praisonai/`:
|
||||
|
||||
@@ -243,9 +243,9 @@ Wrapper-only surfaces remain in `src/praisonai/`:
|
||||
- **Python Core SDK** — `src/praisonai-agents/` (`praisonaiagents`)
|
||||
- **Python Terminal CLI** — `src/praisonai-code/` (`praisonai-code`)
|
||||
- **Python Wrapper** — `src/praisonai/` (`praisonai`)
|
||||
- **TypeScript SDK** — JS/TS runtime (`src/praisonai-ts/`)
|
||||
- **Rust SDK** — High-performance Rust runtime (`src/praisonai-rust/`)
|
||||
- **UI** — Web UI applications (`src/praisonai/praisonai/ui_chat/`, `src/praisonai/praisonai/ui_agents/`, `src/praisonai/praisonai/ui_bot/`, `src/praisonai/praisonai/ui_realtime/`); shared support code lives in `src/praisonai/praisonai/ui/`.
|
||||
- **TypeScript SDK** — JS/TS runtime (`ts-sdk/`)
|
||||
- **Rust SDK** — High-performance Rust runtime (`rust-sdk/`)
|
||||
- **UI** — Web UI (`ui/`)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -35,19 +35,6 @@ curl -fsSL https://praison.ai/install.sh | bash
|
||||
<img src="https://img.shields.io/badge/Highlighted_by_Elon_Musk-000000?style=for-the-badge&logo=x&logoColor=white" alt="Highlighted by Elon Musk" />
|
||||
</a>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://github.com/MervinPraison/PraisonAI/releases/latest">
|
||||
<img src="https://img.shields.io/badge/Download_for_macOS-000000?style=for-the-badge&logo=apple&logoColor=white" alt="Download for macOS" />
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/MervinPraison/PraisonAI/releases/latest">
|
||||
<img src="https://img.shields.io/badge/Download_for_Windows-0078D6?style=for-the-badge&logo=windows&logoColor=white" alt="Download for Windows" />
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/MervinPraison/PraisonAI/releases/latest">
|
||||
<img src="https://img.shields.io/badge/Download_for_Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download for Linux" />
|
||||
</a>
|
||||
<br>
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
@@ -1112,39 +1099,13 @@ For other providers, see [Models docs](https://docs.praison.ai/docs/models).
|
||||
<summary><strong>How do I use a local model (Ollama)?</strong></summary>
|
||||
|
||||
```bash
|
||||
# Start Ollama and pull a model
|
||||
# Start Ollama server first
|
||||
ollama serve
|
||||
ollama pull llama3.2
|
||||
|
||||
# Set environment variable
|
||||
export OPENAI_BASE_URL=http://localhost:11434/v1
|
||||
```
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(instructions="You are a helpful assistant", llm="ollama/llama3.2")
|
||||
agent.start("Why is the sky blue?")
|
||||
```
|
||||
|
||||
The `ollama/` prefix is what selects Ollama's handling — tool-call repair,
|
||||
tool-result formatting and the streaming rules small local models need. Or set
|
||||
it by environment instead:
|
||||
|
||||
```bash
|
||||
export OPENAI_MODEL_NAME=ollama/llama3.2
|
||||
```
|
||||
|
||||
Setting only `OPENAI_BASE_URL` is not enough: with no model named, the OpenAI
|
||||
default (`gpt-4o-mini`) is sent to Ollama, which answers
|
||||
`404 model 'gpt-4o-mini' not found`. Always name the model.
|
||||
|
||||
Point at a non-default host with `base_url=` or `OLLAMA_HOST`:
|
||||
|
||||
```python
|
||||
agent = Agent(instructions="...", llm="ollama/llama3.2", base_url="http://192.168.1.10:11434")
|
||||
```
|
||||
|
||||
The same shape works for other local runtimes — `lm_studio/`, `vllm/` and
|
||||
`hosted_vllm/` with their server's `base_url`.
|
||||
|
||||
See [Models docs](https://docs.praison.ai/docs/models) for more details.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -309,7 +309,7 @@ Methods:
|
||||
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">normalize_content</a>(content)</code>
|
||||
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">reset</a>()</code>
|
||||
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">search</a>(query, user_id = None, agent_id = None, run_id = None, rerank = None, **kwargs)</code>
|
||||
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">store</a>(content, user_id = None, agent_id = None, run_id = None, metadata = None, is_content = False)</code>
|
||||
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">store</a>(content, user_id = None, agent_id = None, run_id = None, metadata = None)</code>
|
||||
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">update</a>(memory_id, data)</code>
|
||||
* <code title="class KnowledgeConfig">KnowledgeConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
|
||||
|
||||
@@ -387,7 +387,6 @@ Methods:
|
||||
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">validate_input</a>(content: str, **kwargs) -> Tuple[bool, str]</code>
|
||||
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">validate_output</a>(content: str, **kwargs) -> Tuple[bool, str]</code>
|
||||
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">validate_tool_call</a>(tool_name: str, arguments: Dict[str, Any], **kwargs) -> Tuple[bool, Dict[str, Any]]</code>
|
||||
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">validate_tool_result</a>(tool_name: str, result: Any, **kwargs) -> Tuple[bool, Any]</code>
|
||||
|
||||
# Planning
|
||||
|
||||
@@ -782,7 +781,7 @@ Methods:
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run
|
||||
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, DB, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run
|
||||
```
|
||||
|
||||
# CLI
|
||||
@@ -926,23 +925,17 @@ Methods:
|
||||
|
||||
Types/Exports:
|
||||
```ts
|
||||
export { Agent, AgentTeam, Agents, CodeAgent, EmbeddingAgent, OCRAgent, PraisonAIAgents, RealtimeAgent, Router, TASK_STATUS, VideoAgent, VisionAgent, createCodeAgent, createEmbeddingAgent, createOCRAgent, createRealtimeAgent, createVideoAgent, createVisionAgent } from "./agent";
|
||||
export type { AgentChatCallOptions, AgentChatOptions, AgentEvent, AgentExecuteTask, AgentGuardrailEntry, AgentGuardrailFunction, AgentGuardrailInput, AgentHooksInput, AgentMemoryStore, AgentMessage, AgentRetryConfig, AgentStreamOptions, AgentTaskLike, AgentTeamConfig, AgentTeamProcess, AgentTeamStartDictOptions, AgentTeamStartOptions, AgentTeamStartOptionsInput, AgentWebConfig, PraisonAIAgentsConfig, SimpleAgentConfig, SimpleRouteConfig, SimpleRouterConfig, StopReason, TaskCallback, TaskGuardrail, TaskOnError } from "./agent";
|
||||
export { Agent, AgentTeam, Agents, PraisonAIAgents, Router } from "./agent";
|
||||
export type { AgentEvent, AgentMessage, AgentStreamOptions, AgentTeamConfig, PraisonAIAgentsConfig, SimpleAgentConfig, SimpleRouteConfig, SimpleRouterConfig, StopReason } from "./agent";
|
||||
export { AudioAgent, createAudioAgent } from "./agent/audio";
|
||||
export type { AudioAgentConfig, AudioConfig, AudioProvider, AudioSpeakOptions, AudioSpeakResult, AudioTranscribeOptions, AudioTranscribeResult } from "./agent/audio";
|
||||
export { ContextAgent, createContextAgent, create_context_agent } from "./agent/context";
|
||||
export { ContextPolicy, DEFAULT_HANDOFF_TOOL_POLICY, Handoff, HandoffCycleError, HandoffDepthError, HandoffError, HandoffTimeoutError, HandoffToolPolicyMode, RECOMMENDED_PROMPT_PREFIX, handoff, handoffFilters, handoffToolName, handoff_filters, listAgentTools, parallelHandoffs, parallel_handoffs, promptWithHandoffInstructions, prompt_with_handoff_instructions, resolveHandoffToolPolicy } from "./agent/handoff";
|
||||
export { Heartbeat, HeartbeatConfig, parseHeartbeatSchedule } from "./agent/heartbeat";
|
||||
export type { HeartbeatAgent, HeartbeatOnError, HeartbeatOptions } from "./agent/heartbeat";
|
||||
export type { AudioAgentConfig, AudioProvider, AudioSpeakOptions, AudioSpeakResult, AudioTranscribeOptions, AudioTranscribeResult } from "./agent/audio";
|
||||
export { ContextAgent, createContextAgent } from "./agent/context";
|
||||
export { ContextPolicy, Handoff, HandoffCycleError, HandoffDepthError, HandoffError, HandoffTimeoutError, RECOMMENDED_PROMPT_PREFIX, handoff, handoffFilters, promptWithHandoffInstructions } from "./agent/handoff";
|
||||
export { ImageAgent, createImageAgent } from "./agent/image";
|
||||
export { PromptExpanderAgent, createPromptExpanderAgent } from "./agent/prompt-expander";
|
||||
export { QueryRewriterAgent, createQueryRewriterAgent } from "./agent/query-rewriter";
|
||||
export { DeepResearchAgent, Provider, createDeepResearchAgent } from "./agent/research";
|
||||
export { RetryBackoffConfig, interruptibleSleep, jitteredBackoff } from "./agent/retry-utils";
|
||||
export type { JitteredBackoffOptions, RetryBackoffConfigOptions } from "./agent/retry-utils";
|
||||
export { DeepResearchAgent, createDeepResearchAgent } from "./agent/research";
|
||||
export { RouterAgent, createRouter, routeConditions } from "./agent/router";
|
||||
export { AGENT_RUN_STATUSES, AgentRunOutcome, PROVIDER_BLOCK_REASONS, RunOutcome, TERMINAL_REASON_PRECEDENCE, TERMINATION_TO_RUN_STATUS, TerminationReason, classifyFinishReason, terminationToRunStatus, termination_to_run_status, validateDecisionString, validate_decision_string } from "./agent/run-outcome";
|
||||
export type { AgentRunOutcomeFailureOptions, AgentRunOutcomeInit, AgentRunOutcomeOptions, AgentRunStatus, RunOutcomeInit, TerminalReason } from "./agent/run-outcome";
|
||||
export { // Agent loop
|
||||
createAgentLoop, // DevTools
|
||||
enableDevTools, // MCP
|
||||
@@ -959,7 +952,6 @@ export { // Agent loop
|
||||
defineTool, // UI Message (AI SDK v6 parity)
|
||||
convertToModelMessages, AIAgentStep, AIEmbedManyResult, AIEmbedOptions, AIEmbedResult, AIFilePart, AIGenerateImageOptions, AIGenerateImageResult, AIGenerateObjectOptions, AIGenerateObjectResult, AIGenerateTextOptions, AIGenerateTextResult, AIImagePart, AIMiddleware, AIMiddlewareConfig, AIModelMessage, AISpan, AISpanKind, AISpanOptions, AISpanStatus, AIStreamObjectOptions, AIStreamObjectResult, AIStreamTextOptions, AIStreamTextResult, AITelemetryEvent, AITelemetrySettings, AITextPart, AIToolDefinition, AITracer, AgentLoop, DANGEROUS_PATTERNS, MCPClientType, MODEL_ALIASES, SPEECH_MODELS, TRANSCRIPTION_MODELS, ToolApprovalDeniedError, ToolApprovalTimeoutError, aiEmbed, aiEmbedMany, aiGenerateImage, aiGenerateObject, aiGenerateText, aiStreamObject, aiStreamText, applyMiddleware, autoEnableDevTools, base64ToUint8Array, clearAICache, clearEvents, closeAllMCPClients, closeMCPClient, convertToUIMessages, createAILoggingMiddleware, createAISpan, createApprovalResponse, createDangerousPatternChecker, createDevToolsMiddleware, createExpressHandler, createFastifyHandler, createFilePart, createHonoHandler, createMultimodalMessage, createNestHandler, createPagesHandler, createPdfPart, createSystemMessage, createTelemetryMiddleware, createTelemetrySettings, createTextMessage, createTextPart, createToolSet, disableAITelemetry, disableDevTools, enableAITelemetry, functionToTool, getAICacheStats, getApprovalManager, getDevToolsState, getDevToolsUrl, getEvents, getMCPClient, getModel, getTelemetrySettings, getToolsNeedingApproval, getTracer, hasModelAlias, hasPendingApprovals, initOpenTelemetry, isDangerous, isDataUrl, isDevToolsEnabled, isTelemetryEnabled, isUrl, listModelAliases, mcpToolsToAITools, parseModel, pipeUIMessageStreamToResponse, recordEvent, resolveModelAlias, safeValidateUIMessages, setApprovalManager, stopAfterSteps, stopWhen, stopWhenNoToolCalls, toMessageContent, toUIMessageStreamResponse, transcribe, uint8ArrayToBase64, validateUIMessages, withApproval, withSpan, wrapModel } from "./ai";
|
||||
export { AutoAgents, AutoTaskConfig, createAutoAgents } from "./auto";
|
||||
export { BotRunStatus } from "./bots/protocols";
|
||||
export { BaseCache, FileCache, MemoryCache, createFileCache, createMemoryCache } from "./cache";
|
||||
export { CLI_SPEC_VERSION, executeCommand, parseArgs } from "./cli";
|
||||
export { // Autonomy Mode
|
||||
@@ -981,27 +973,23 @@ export { // Autonomy Mode
|
||||
export { // Classes
|
||||
DictCondition, // Functions
|
||||
evaluateCondition, // Types
|
||||
type ConditionProtocol, ExpressionCondition, FunctionCondition, andConditions, createCondition, evaluate_condition, notCondition, orConditions } from "./conditions";
|
||||
type ConditionProtocol, ExpressionCondition, FunctionCondition, andConditions, createCondition, notCondition, orConditions } from "./conditions";
|
||||
export { // Enums
|
||||
MemoryBackend, // Errors
|
||||
ConfigValidationError, // Parse utilities
|
||||
detect_url_scheme, // Presets
|
||||
MEMORY_PRESETS, // Resolver functions
|
||||
resolve, AUTONOMY_PRESETS, ArrayMode, CACHING_PRESETS, CONTEXT_PRESETS, ChunkingStrategy, EXECUTION_PRESETS, ExecutionPreset, FeatureMemoryConfig, GUARDRAIL_PRESETS, GuardrailAction, KNOWLEDGE_PRESETS, LearnBackend, LearnMode, LearnScope, MEMORY_URL_SCHEMES, MULTI_AGENT_EXECUTION_PRESETS, MULTI_AGENT_OUTPUT_PRESETS, OUTPUT_PRESETS, OutputPreset, PLANNING_PRESETS, PreCompactionMemoryFlushConfig, REFLECTION_PRESETS, RulesConfig, ToolSearchConfig, WEB_PRESETS, WebSearchProvider, apply_config_defaults, clean_triple_backticks, get_config, get_config_path, get_default, get_defaults_config, get_plugins_config, is_path_like, is_policy_string, parse_policy_string, resolve_autonomy, resolve_caching, resolve_context, resolve_execution, resolve_guardrails, resolve_hooks, resolve_knowledge, resolve_memory, resolve_output, resolve_planning, resolve_reflection, resolve_routing, resolve_skills, resolve_web, suggest_similar, validate_config } from "./config";
|
||||
resolve, AUTONOMY_PRESETS, ArrayMode, CACHING_PRESETS, CONTEXT_PRESETS, ChunkingStrategy, EXECUTION_PRESETS, ExecutionPreset, FeatureMemoryConfig, GUARDRAIL_PRESETS, GuardrailAction, KNOWLEDGE_PRESETS, MEMORY_URL_SCHEMES, MULTI_AGENT_EXECUTION_PRESETS, MULTI_AGENT_OUTPUT_PRESETS, OUTPUT_PRESETS, OutputPreset, PLANNING_PRESETS, REFLECTION_PRESETS, WEB_PRESETS, WebSearchProvider, apply_config_defaults, clean_triple_backticks, get_config, get_config_path, get_default, get_defaults_config, get_plugins_config, is_path_like, is_policy_string, parse_policy_string, resolve_autonomy, resolve_caching, resolve_context, resolve_execution, resolve_guardrails, resolve_hooks, resolve_knowledge, resolve_memory, resolve_output, resolve_planning, resolve_reflection, resolve_routing, resolve_skills, resolve_web, suggest_similar, validate_config } from "./config";
|
||||
export { createDbAdapter, db, getDefaultDbAdapter, setDefaultDbAdapter } from "./db";
|
||||
export type { DbAdapter, DbConfig, DbMessage, DbRun, DbTrace } from "./db";
|
||||
export { MemoryPostgresAdapter, NeonPostgresAdapter, PostgresSessionStorage, createMemoryPostgres, createNeonPostgres, createPostgresSessionStorage } from "./db/postgres";
|
||||
export { MemoryRedisAdapter, UpstashRedisAdapter, createMemoryRedis, createUpstashRedis } from "./db/redis";
|
||||
export { SQLiteAdapter, createSQLiteAdapter } from "./db/sqlite";
|
||||
export { // Functions
|
||||
// NB: the unaliased registerDisplayCallback name belongs to the per-type
|
||||
// callback registry (callbacks module, // Types
|
||||
type DisplayCallback, DisplayFlow, DisplayFlowConfig, asyncDisplayCallbacks, async_display_callbacks, clearDisplayCallbacks, clearErrorLogs, displayError, displayGenerating, displayInstruction, displayInteraction, displaySelfReflection, displayToolCall, display_error, display_generating, display_instruction, display_interaction, display_self_reflection, display_tool_call, errorLogs, error_logs, logError, registerDisplay, register_display_callback, syncDisplayCallbacks, sync_display_callbacks } from "./display";
|
||||
export { // Types
|
||||
type DisplayCallback, DisplayFlow, DisplayFlowConfig, asyncDisplayCallbacks, clearDisplayCallbacks, clearErrorLogs, displayError, displayGenerating, displayInstruction, displayInteraction, displaySelfReflection, displayToolCall, errorLogs, logError, registerDisplay, syncDisplayCallbacks } from "./display";
|
||||
export { // Functions
|
||||
embed, // Types
|
||||
type EmbeddingResult, aembed, aembedding, aembeddings, cosineSimilarity, embedding, embeddings, euclideanDistance, getDimensions, get_dimensions, normalizeEmbedding, setEmbeddingConfig } from "./embeddings";
|
||||
export { AGENT_ERROR_KINDS, FailoverDecision, IdleTimeoutBreaker, LEGACY_ERROR_CATEGORY_MAP, LLMError, NetworkError, PraisonAIConfigError, PraisonAIError, ToolExecutionError, ValidationError, isAgentErrorKind, isErrorContext, resolveErrorCategory } from "./errors";
|
||||
export type { AgentErrorKind, ErrorContextProtocol, FailoverAction, FailoverDecisionOptions, LLMErrorOptions, LegacyErrorCategory, NetworkErrorOptions, PraisonAIConfigErrorOptions, PraisonAIErrorOptions, ToolExecutionErrorOptions, ValidationErrorOptions } from "./errors";
|
||||
type EmbeddingResult, aembed, aembedding, aembeddings, cosineSimilarity, embedding, embeddings, euclideanDistance, getDimensions, normalizeEmbedding, setEmbeddingConfig } from "./embeddings";
|
||||
export { // LLM-as-Judge
|
||||
Judge, AccuracyJudge, CriteriaJudge, EvalResults, EvalSuite, Evaluator, RecipeJudge, accuracyEval, addJudge, addOptimizationRule, containsKeywordsCriterion, createDefaultEvaluator, createEvalResults, createEvaluator, getJudge, getOptimizationRule, lengthCriterion, listJudges, listOptimizationRules, noHarmfulContentCriterion, parseJudgeResponse, performanceEval, relevanceCriterion, reliabilityEval, removeJudge, removeOptimizationRule } from "./eval";
|
||||
export { AgentEventBus, AgentEvents, EventEmitterPubSub, PubSub, createEventBus, createPubSub } from "./events";
|
||||
@@ -1010,7 +998,7 @@ export { // Bot types
|
||||
FailoverManager, // Enums
|
||||
SandboxStatus, // Gateway types
|
||||
type GatewayConfig, // Other types
|
||||
type ProviderStatus, AutonomyLevel, GatewayEventType, RagRetrievalPolicy } from "./gateway";
|
||||
type ProviderStatus, AutonomyLevel, RagRetrievalPolicy } from "./gateway";
|
||||
export { LLMGuardrail, createLLMGuardrail } from "./guardrails/llm-guardrail";
|
||||
export { DisplayTypes, HooksManager, WorkflowHooksExecutor, clearAllCallbacks, clearApprovalCallback, createHooksManager, createLoggingOperationHooks, createLoggingWorkflowHooks, createTimingWorkflowHooks, createValidationOperationHooks, createWorkflowHooks, executeCallback, executeSyncCallback, getRegisteredDisplayTypes, hasApprovalCallback, registerApprovalCallback, registerDisplayCallback, requestApproval, unregisterDisplayCallback } from "./hooks";
|
||||
export { // Computer Use
|
||||
@@ -1022,11 +1010,7 @@ export { // Slack
|
||||
createSlackBot, SlackBot, parseSlackMessage, verifySlackSignature } from "./integrations/slack";
|
||||
export { BaseVectorStore, ChromaVectorStore, MemoryVectorStore, PineconeVectorStore, QdrantVectorStore, VectorQueryResult, WeaviateVectorStore, createChromaStore, createMemoryVectorStore, createPineconeStore, createQdrantStore, createWeaviateStore } from "./integrations/vector";
|
||||
export { BaseVoiceProvider, ElevenLabsVoiceProvider, OpenAIVoiceProvider, createElevenLabsVoice, createOpenAIVoice } from "./integrations/voice";
|
||||
export type { KnowledgeRecord } from "./knowledge";
|
||||
export { GraphRAG, GraphStore, createGraphRAG } from "./knowledge/graph-rag";
|
||||
export { Knowledge } from "./knowledge/knowledge";
|
||||
export type { KnowledgeIndexOptions, KnowledgeSearchCallOptions, KnowledgeStoreConfig, KnowledgeStoreOptions } from "./knowledge/knowledge";
|
||||
export { KnowledgeBase, KnowledgeDocument, KnowledgeSearchResult, createKnowledgeBase } from "./knowledge/rag";
|
||||
export { BaseReranker, CohereReranker, CrossEncoderReranker, LLMReranker, createCohereReranker, createCrossEncoderReranker, createLLMReranker } from "./knowledge/reranker";
|
||||
export { // Provider classes
|
||||
OpenAIProvider, // Provider factory and utilities
|
||||
@@ -1034,22 +1018,14 @@ export { // Provider classes
|
||||
ProviderRegistry, // Types
|
||||
type LLMProvider, AnthropicProvider, BaseProvider, GoogleProvider, ProviderMessage, ProviderToolDefinition, createProviderRegistry, getAvailableProviders, getDefaultProvider, getDefaultRegistry, hasProvider, isProviderAvailable, listProviders, parseModelString, registerBuiltinProviders, registerProvider, unregisterProvider } from "./llm/providers";
|
||||
export { ADAPTERS, AISDK_PROVIDERS, COMMUNITY_PROVIDERS, PROVIDER_ALIASES } from "./llm/providers/ai-sdk/types";
|
||||
export { AgentMessageEvent, CustomToolUseEvent, ManagedEvent, SessionErrorEvent, SessionIdleEvent, SessionRunningEvent, ToolConfirmationEvent, ToolUseEvent, UsageEvent, isManagedBackend } from "./managed";
|
||||
export type { AgentMessageEventInit, CustomToolUseEventInit, ManagedBackendKwargs, ManagedBackendProtocol, ManagedContentBlock, ManagedEventInit, ManagedEventType, ManagedStopReason, SessionErrorEventInit, SessionIdleEventInit, ToolConfirmationEventInit, ToolUseEventInit, UsageEventInit } from "./managed";
|
||||
export { MCPClient, MCPSecurity, MCPServer, MCPSessionManager, createApiKeyPolicy, createMCPClient, createMCPSecurity, createMCPServer, createMCPSession, createRateLimitPolicy, getMCPTools } from "./mcp";
|
||||
export { ChromaMemory, InMemoryAdapter, MEMORY_PROVIDER_ALIASES, MemoryAdapterRegistry, addMemoryAdapter, addMemoryFactory, add_memory_adapter, add_memory_factory, createChromaMemoryAdapter, createDakeraMemoryAdapter, createMem0MemoryAdapter, createMongodbMemoryAdapter, createSqliteMemoryAdapter, getDefaultMemoryRegistry, getFirstAvailableMemoryAdapter, getMemoryAdapter, get_memory_adapter, hasMemoryAdapter, has_memory_adapter, listMemoryAdapters, list_memory_adapters, registerMemoryAdapter, registerMemoryFactory, register_memory_adapter, register_memory_factory, resetDefaultMemoryRegistry, resolveMemoryAdapterName, sanitizeChromaMetadata } from "./memory/adapters";
|
||||
export type { ChromaMemoryConfig, MaybePromise, MemoryEmbedder, MemoryProtocol, MemoryRecord } from "./memory/adapters";
|
||||
export { AutoMemory, AutoMemoryKnowledgeBase, AutoMemoryVectorStore, DEFAULT_POLICIES, createAutoMemory, createLLMSummarizer } from "./memory/auto-memory";
|
||||
export { DocsManager, createDocsManager } from "./memory/docs-manager";
|
||||
export { FileMemory, createFileMemory } from "./memory/file-memory";
|
||||
export { MemoryHooks, createEncryptionHooks, createLoggingHooks, createMemoryHooks, createValidationHooks } from "./memory/hooks";
|
||||
export { BaseStore, DecisionStore, FeedbackStore, ImprovementStore, InsightStore, LearnBackendNotAvailableError, LearnEntry, LearnError, LearnManager, LearnManagerMode, MongoDBLearnBackend, PatternStore, PersonaStore, RedisLearnBackend, SQLiteLearnBackend, ThreadStore, getDataDir, getLearnDir, resolveLearnConfig, toLearnEntryDict } from "./memory/learn";
|
||||
export type { AsyncLearnProtocol, BaseStoreOptions, LearnEntryConvertible, LearnEntryData, LearnEntryInit, LearnEntryLike, LearnExtractor, LearnManagerConfig, LearnManagerProtocol, LearnMessage, LearnProtocol, LearnStorageBackend, LearnStore, ProcessConversationResult, ResolvedLearnConfig } from "./memory/learn";
|
||||
export { Memory, createMemory } from "./memory/memory";
|
||||
export type { MemoryConfig, MemoryEntry } from "./memory/memory";
|
||||
export { RulesManager, createRulesManager, createSafetyRules } from "./memory/rules-manager";
|
||||
export { DEFAULT_PROFILE, HarnessProfile, listHarnessProfiles, registerProfile, register_profile, resetHarnessRegistry, resolveHarness, resolve_harness } from "./model-harness";
|
||||
export type { HarnessProfileConfig, HarnessRegistryEntry, HarnessResolverProtocol } from "./model-harness";
|
||||
export { // Adapters
|
||||
NoopObservabilityAdapter, // Constants
|
||||
OBSERVABILITY_TOOLS, // Global adapter management
|
||||
@@ -1057,6 +1033,15 @@ export { // Adapters
|
||||
type SpanKind, ConsoleObservabilityAdapter, MemoryObservabilityAdapter, clearAdapterCache, createConsoleAdapter, createMemoryAdapter, createObservabilityAdapter, getObservabilityAdapter, getObservabilityToolInfo, hasObservabilityToolEnvVar, listObservabilityTools, noopAdapter, resetObservabilityAdapter, trace } from "./observability";
|
||||
export { AgentApp, AgentAppConfig, AgentAppProtocol, AgentOS, AgentOSConfig, AgentOSProtocol, DEFAULT_AGENTOS_CONFIG, mergeConfig } from "./os";
|
||||
export type { AgentAppOptions, AgentOSOptions } from "./os";
|
||||
export { // P0: Call Types (new)
|
||||
type MCPCall, // P0: Specialized Agent Configs (new)
|
||||
type AudioConfig, // P0: Specialized Agents (new)
|
||||
CodeAgent, // P1: Workflow Patterns (new)
|
||||
Chunking, // P2: Context Types (new - only items not already exported)
|
||||
ContextManager, // P3: Display callbacks (snake_case for Python parity)
|
||||
register_display_callback, // P3: Plugin functions
|
||||
get_plugin_manager, // P3: Trace & condition functions
|
||||
evaluate_condition, EmbeddingAgent, If, Knowledge, MCP, OCRAgent, Parallel, RealtimeAgent, Route, Session, VideoAgent, VisionAgent, async_display_callbacks, cleanup_telemetry_resources, create_context_agent, disable_performance_mode, disable_telemetry, discover_and_load_plugins, discover_plugins, display_error, display_generating, display_instruction, display_interaction, display_self_reflection, display_tool_call, enable_performance_mode, enable_telemetry, ensure_plugin_dir, error_logs, get_default_plugin_dirs, get_dimensions, get_plugin_template, get_telemetry, handoff_filters, load_plugin, parse_plugin_header, parse_plugin_header_from_file, prompt_with_handoff_instructions, resolve_guardrail_policies, sync_display_callbacks, trace_context, track_workflow, when } from "./parity";
|
||||
export { // Core classes
|
||||
Plan, // Python parity additions
|
||||
ApprovalCallback, PlanStep, PlanStorage, PlanningAgent, READ_ONLY_TOOLS, RESEARCH_TOOLS, RESTRICTED_TOOLS, TaskAgent, TodoItem, TodoList, createApprovalCallback, createPlan, createPlanStorage, createPlanningAgent, createTaskAgent, createTodoList } from "./planning";
|
||||
@@ -1064,51 +1049,36 @@ export { // Classes
|
||||
Plugin, // Enums
|
||||
PluginHook, // Functions
|
||||
getPluginManager, // Interfaces
|
||||
type PluginMetadata, FunctionPlugin, PluginManager, PluginParseError, PluginType, disablePlugins, discoverAndLoadPlugins, discoverPlugins, discover_and_load_plugins, discover_plugins, enablePlugins, ensurePluginDir, ensure_plugin_dir, getDefaultPluginDirs, getPluginTemplate, get_default_plugin_dirs, get_plugin_manager, get_plugin_template, isPluginEnabled, listPlugins, loadPlugin, load_plugin, parsePluginHeader, parsePluginHeaderFromFile, parse_plugin_header, parse_plugin_header_from_file } from "./plugins";
|
||||
type PluginMetadata, FunctionPlugin, PluginManager, PluginParseError, PluginType, disablePlugins, discoverAndLoadPlugins, discoverPlugins, enablePlugins, ensurePluginDir, getDefaultPluginDirs, getPluginTemplate, isPluginEnabled, listPlugins, loadPlugin, parsePluginHeader, parsePluginHeaderFromFile } from "./plugins";
|
||||
export { // A2A Protocol
|
||||
A2ATaskState, // AGUI Protocol
|
||||
AGUI, // AgentManager alias type
|
||||
type AgentManager, // Global singletons
|
||||
config, // Guardrail policies
|
||||
type GuardrailPolicy, // Tools class
|
||||
type ToolDefinition, A2A, A2ARole, AutoRagAgent, AutoRetrievalPolicy, DEFAULT_AUTO_KEYWORDS, GUARDRAIL_POLICY_PRESETS, Tools, memory, obs, resolveGuardrailPolicies, resolve_guardrail_policies, workflows } from "./protocols";
|
||||
type ToolDefinition, A2A, A2ARole, AutoRagAgent, AutoRetrievalPolicy, DEFAULT_AUTO_KEYWORDS, GUARDRAIL_POLICY_PRESETS, Tools, memory, obs, resolveGuardrailPolicies, workflows } from "./protocols";
|
||||
export { CitationsMode, DEFAULT_RAG_TEMPLATE, RAG, RAGCitation, RAGContextPack, RetrievalPolicy, RetrievalStrategy, createCitation, createContextPack, createRAG, createRAGConfig, createRAGResult, createRetrievalConfig, createSimpleRetrievalConfig, createSmartRetrievalConfig, formatAnswerWithCitations, formatCitation, formatContextPackForPrompt, hasCitations } from "./rag";
|
||||
export { PraisonAIRuntime, RuntimeRegistry, RuntimeRegistryEntry, RuntimeRegistryError, addRuntimeAlias, getRuntimeRegistry, isAgentRuntime, isRuntimeAvailable, listRuntimes, list_runtimes, registerRuntime, register_runtime, resolveRuntime, resolve_runtime, unregisterRuntime } from "./runtime";
|
||||
export type { AgentRuntimeProtocol, RunTurnOptions, RuntimeCapabilityMatrix, RuntimeConfig, RuntimeDelta, RuntimeDeltaType, RuntimeFactory, RuntimeMode, RuntimeRegistryEntryInit, RuntimeRegistryProtocol, RuntimeResult, TurnContextBuilderProtocol, TurnRuntimeProtocol } from "./runtime";
|
||||
export type { RunStatus } from "./session";
|
||||
export { Session } from "./session/session";
|
||||
export { // Python parity additions
|
||||
SkillLoader, EnforcementLevel, SkillManager, SkillParseError, SkillState, createSkillLoader, createSkillManager, createSkillProperties, discoverSkill, discoverSkills, discover_skills, findSkillMd, getDefaultSkillDirs, loadSkill, load_skill, parseFrontmatter, parseSkillFile, validate, validateMetadata, validateSkill, validate_metadata } from "./skills";
|
||||
export type { RemoteSkillSource } from "./skills";
|
||||
SkillLoader, SkillManager, createSkillLoader, createSkillManager, createSkillProperties, parseSkillFile } from "./skills";
|
||||
export { AgentTask, AgentTaskConfig, BaseTask, createTaskOutput } from "./task";
|
||||
export { // Python parity additions
|
||||
MinimalTelemetry, AgentTelemetry, PerformanceMonitor, TelemetryCollector, TelemetryIntegration, cleanupTelemetry, cleanupTelemetryResources, cleanup_telemetry_resources, createAgentTelemetry, createConsoleSink, createHTTPSink, createPerformanceMonitor, createTelemetryIntegration, disablePerformanceMode, disableTelemetry, disable_performance_mode, disable_telemetry, enablePerformanceMode, enableTelemetry, enable_performance_mode, enable_telemetry, getMinimalTelemetry, getTelemetry, get_telemetry } from "./telemetry";
|
||||
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, TOOL_TRUST_LEVELS, ToolRegistry, ToolResult, ToolValidationError, add_tool, createDelegator, createSubagentTool, createSubagentTools, createTool, getRegistry, getTool, getToolDefinitions, get_registry, get_tool, get_tool_definitions, hasTool, has_tool, listTools, list_tools, registerTool, register_tool, removeTool, remove_tool, tool, validateTool, validate_tool } from "./tools";
|
||||
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, 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 { MCP, MCPToolTransportType } from "./tools/mcp";
|
||||
export { BudgetExceededError, MissingDependencyError, MissingEnvVarError, ToolConstructionError, ToolNotRegisteredError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolInstance, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getToolsRegistry, registerToolFactory, resetToolsRegistry, tryCreateToolInstance, validateToolInstall } from "./tools/registry";
|
||||
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";
|
||||
export { TOOLSET_TOOL_ID_MAP, ToolsetRegistry, ToolsetSpec, getToolset, getToolsetRegistry, get_toolset, get_toolset_registry, hasToolset, has_toolset, listToolsets, list_toolsets, registerToolset, register_toolset, resolveToolset, resolveToolsetBuiltinIds, resolveToolsets, resolveToolsetsForModel, resolve_toolset, resolve_toolsets, resolve_toolsets_for_model, toolsetToolId, unregisterToolset, unregister_toolset } from "./toolsets";
|
||||
export type { ToolsetSpecConfig } from "./toolsets";
|
||||
export { // Classes
|
||||
TraceSink, // Enums
|
||||
ContextEventType, // Functions
|
||||
createContextEvent, // Types
|
||||
type ContextEvent, ContextListSink, ContextNoOpSink, ContextTraceEmitter, ContextTraceSink, EventType, MessageType, TraceCtx, traceContext, trace_context, trackWorkflow, track_workflow } from "./trace";
|
||||
export { A2UI, A2UINotInstalledError, A2UI_MIME_TYPE, createA2uiPart, getSchemaManager, isA2uiPart, parseA2uiResponse } from "./ui/a2ui";
|
||||
export type { A2UIAdapter, A2UISystemPromptOptions, A2UIToolResultProtocol } from "./ui/a2ui";
|
||||
export { AdapterBackendNotAvailableError, AdapterCreationError, AdapterRegistry, isBackendUnavailableError } from "./utils/adapter-registry";
|
||||
export type { AdapterClass, AdapterFactory, AdapterKwargs } from "./utils/adapter-registry";
|
||||
export { Logger, PraisonLogger, ROOT_LOGGER_NAME, StructuredFormatter, configureStructuredLogging, configure_structured_logging, getLogger, get_logger, normalizeLoggerName } from "./utils/logger";
|
||||
export type { GetLoggerOptions, LogFormatter, LogLevelName, LogRecord, StructuredFormatterOptions } from "./utils/logger";
|
||||
export { VERSION, __version__, getVersion } from "./version";
|
||||
type ContextEvent, ContextListSink, ContextNoOpSink, ContextTraceEmitter, ContextTraceSink, EventType, MessageType, TraceCtx, traceContext, trackWorkflow } from "./trace";
|
||||
export { // New: Python-parity Loop and Repeat classes
|
||||
Loop, // Task class
|
||||
Task, AgentFlow, DEFAULT_MAX_PARALLEL_WORKERS, If, Include, MAX_NESTING_DEPTH, NestingDepthError, PARALLEL_ON_FAILURE_MODES, Parallel, Pipeline, Repeat, Route, Workflow, WorkflowStepError, evaluateWorkflowCondition, getRecipeResolver, ifStep, if_, include, isControlFlowPattern, loop, loopPattern, parallel, parallelPattern, repeat, repeatPattern, route, routePattern, setRecipeResolver, substituteWorkflowVariables, when } from "./workflows";
|
||||
export type { AgentLikeStep, FlowStep, IncludableWorkflow, LoopConfig, LoopResult, ParallelBranchError, ParallelOnFailure, RecipeResolver, RepeatConfig, RepeatContext, RepeatResult, StepContextConfig, StepExecutionConfig, StepOutputConfig, StepResult, StepRoutingConfig, TaskConfig, WorkflowContext } from "./workflows";
|
||||
export { YAMLWorkflowParser, createWorkflowFromYAML, loadWorkflowFromFile, parseYAMLWorkflow, validateWorkflowDefinition } from "./workflows/yaml-parser";
|
||||
Task, AgentFlow, Pipeline, Repeat, Workflow, loop, loopPattern, parallel, repeat, repeatPattern, route } from "./workflows";
|
||||
export type { LoopConfig, LoopResult, RepeatConfig, RepeatContext, RepeatResult, StepContextConfig, StepExecutionConfig, StepOutputConfig, StepResult, StepRoutingConfig, TaskConfig, WorkflowContext } from "./workflows";
|
||||
export { createWorkflowFromYAML, loadWorkflowFromFile, parseYAMLWorkflow, validateWorkflowDefinition } from "./workflows/yaml-parser";
|
||||
```
|
||||
|
||||
# Optional Plugins
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.7.6" \
|
||||
"praisonai>=4.7.1" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.7.6" \
|
||||
"praisonai>=4.7.1" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.7.6" \
|
||||
"praisonai>=4.7.1" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""
|
||||
Meeting Notes Agent
|
||||
|
||||
An AI meeting-notes pipeline composed entirely from existing PraisonAI
|
||||
primitives — no new SDK surface required:
|
||||
|
||||
1. AudioAgent.transcribe() -> Whisper speech-to-text
|
||||
2. Agent (summary) -> structured summary + action items
|
||||
3. Agent (knowledge=[...]) -> index transcript for cross-meeting Q&A
|
||||
|
||||
Usage:
|
||||
python meeting-notes-agent.py path/to/recording.mp3
|
||||
|
||||
Requires: OPENAI_API_KEY (or any LiteLLM-supported STT/LLM provider).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from praisonaiagents import Agent, AudioAgent
|
||||
|
||||
|
||||
def transcribe_meeting(audio_path: str) -> str:
|
||||
"""Speech-to-text using the built-in Whisper AudioAgent."""
|
||||
stt = AudioAgent(llm="openai/whisper-1")
|
||||
return stt.transcribe(audio_path)
|
||||
|
||||
|
||||
def summarize_meeting(transcript: str) -> str:
|
||||
"""Summarize decisions, topics, and action items."""
|
||||
notetaker = Agent(
|
||||
name="Meeting Notetaker",
|
||||
instructions=(
|
||||
"You are an expert meeting notetaker. Given a raw transcript, produce:\n"
|
||||
"1. A concise summary (3-5 sentences).\n"
|
||||
"2. Key decisions made.\n"
|
||||
"3. Action items as a checklist with owners when mentioned.\n"
|
||||
"Only include action items that were actually spoken."
|
||||
),
|
||||
)
|
||||
return notetaker.start(f"Transcript:\n\n{transcript}")
|
||||
|
||||
|
||||
def build_qa_agent(transcript_path: str) -> Agent:
|
||||
"""Index the transcript for semantic search and cross-meeting Q&A."""
|
||||
return Agent(
|
||||
name="Meeting Q&A",
|
||||
instructions="Answer questions using only the indexed meeting transcripts. Cite what was said.",
|
||||
knowledge=[transcript_path],
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python meeting-notes-agent.py <audio_file>")
|
||||
sys.exit(1)
|
||||
|
||||
audio_path = sys.argv[1]
|
||||
|
||||
transcript = transcribe_meeting(audio_path)
|
||||
transcript_path = "meeting_transcript.txt"
|
||||
with open(transcript_path, "w", encoding="utf-8") as f:
|
||||
f.write(transcript)
|
||||
|
||||
notes = summarize_meeting(transcript)
|
||||
print("\n=== Meeting Notes ===\n")
|
||||
print(notes)
|
||||
|
||||
qa = build_qa_agent(transcript_path)
|
||||
print("\n=== Q&A ===\n")
|
||||
print(qa.start("What were the main action items and who owns them?"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,4 +15,4 @@ if completed.status == "completed":
|
||||
# List all videos
|
||||
videos = agent.list()
|
||||
|
||||
# Models: openai/sora-2, gemini/veo-3.1-generate-preview, azure/sora-2
|
||||
# Models: openai/sora-2, gemini/veo-3.0-generate-preview, azure/sora-2
|
||||
@@ -2,13 +2,6 @@
|
||||
|
||||
> **For AI Agents and Developers**: This document provides the complete context needed to work with the PraisonAI ecosystem, including design principles, architecture, repository structure, and implementation guidelines.
|
||||
|
||||
> **Active workstream — local model runtimes.** If your task touches local inference
|
||||
> (Ollama, LM Studio, llama.cpp, vLLM, MLX, `transformers serve`), provider detection,
|
||||
> `llm/adapters/`, or the `praisonaiagents/local/` package, read
|
||||
> [`docs/local-model-layer/README.md`](docs/local-model-layer/README.md) **before editing**.
|
||||
> It carries the measured ground truth, the invariants, and a file-ownership table that
|
||||
> prevents concurrent work orders from corrupting each other.
|
||||
|
||||
---
|
||||
|
||||
## 1. What is PraisonAI?
|
||||
|
||||
@@ -18,77 +18,77 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 511 |
|
||||
| ✅ Agent | 22 | 57 | 18177 |
|
||||
| ✅ Agent | 22 | 54 | 17350 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 701 |
|
||||
| ✅ Approval | 1 | 7 | 3616 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
|
||||
| ✅ Approval | 1 | 6 | 3071 |
|
||||
| ✅ Audio | 2 | 12 | 1000 |
|
||||
| ✅ Auto Generation | 5 | 12 | 3879 |
|
||||
| ✅ Autonomy | 3 | 7 | 2794 |
|
||||
| ✅ Bots | 7 | 33 | 13928 |
|
||||
| ✅ Auto Generation | 5 | 12 | 3747 |
|
||||
| ✅ Autonomy | 3 | 7 | 2790 |
|
||||
| ✅ Bots | 7 | 32 | 13622 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 123 | 38201 |
|
||||
| ✅ Chunking | 2 | 2 | 424 |
|
||||
| ✅ CLI | 5 | 122 | 37626 |
|
||||
| ✅ Chunking | 2 | 2 | 420 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 2 | 14 | 4855 |
|
||||
| ✅ Conditions | 1 | 3 | 1386 |
|
||||
| ✅ Configuration | 3 | 8 | 3323 |
|
||||
| ✅ Context Management | 16 | 36 | 14104 |
|
||||
| ✅ Conditions | 1 | 3 | 1347 |
|
||||
| ✅ Configuration | 3 | 8 | 3292 |
|
||||
| ✅ Context Management | 16 | 36 | 14071 |
|
||||
| ✅ Deep Research | 8 | 2 | 587 |
|
||||
| ✅ Display | 6 | 3 | 844 |
|
||||
| ✅ Embeddings | 6 | 23 | 2051 |
|
||||
| ✅ Evaluation | 1 | 8 | 3220 |
|
||||
| ✅ Display | 6 | 3 | 741 |
|
||||
| ✅ Embeddings | 6 | 23 | 2049 |
|
||||
| ✅ Evaluation | 1 | 7 | 2920 |
|
||||
| ✅ Events | 1 | 2 | 767 |
|
||||
| ✅ Execution | 3 | 4 | 1631 |
|
||||
| ✅ Failover | 2 | 1 | 480 |
|
||||
| ✅ Files | 2 | 7 | 2376 |
|
||||
| ✅ Execution | 3 | 4 | 1606 |
|
||||
| ✅ Failover | 2 | 1 | 452 |
|
||||
| ✅ Files | 2 | 7 | 2275 |
|
||||
| ✅ Flow | 1 | 3 | 788 |
|
||||
| ✅ Gateway | 7 | 86 | 32228 |
|
||||
| ✅ Guardrails | 4 | 4 | 2388 |
|
||||
| ✅ Handoffs | 11 | 6 | 2718 |
|
||||
| ✅ Hooks | 2 | 9 | 4690 |
|
||||
| ✅ Gateway | 7 | 83 | 30723 |
|
||||
| ✅ Guardrails | 4 | 4 | 2253 |
|
||||
| ✅ Handoffs | 11 | 6 | 2626 |
|
||||
| ✅ Hooks | 2 | 9 | 4542 |
|
||||
| ✅ Image | 1 | 11 | 1255 |
|
||||
| ✅ Knowledge | 4 | 18 | 5562 |
|
||||
| ✅ LLM | 3 | 15 | 5917 |
|
||||
| ✅ Knowledge | 4 | 17 | 5226 |
|
||||
| ✅ LLM | 3 | 15 | 5395 |
|
||||
| ✅ Loops | 4 | 5 | 1475 |
|
||||
| ✅ MCP | 1 | 62 | 14489 |
|
||||
| ✅ Memory | 6 | 19 | 7588 |
|
||||
| ✅ MCP | 1 | 62 | 14418 |
|
||||
| ✅ Memory | 6 | 18 | 7237 |
|
||||
| ✅ OCR | 2 | 1 | 237 |
|
||||
| ✅ Observability | 2 | 23 | 2815 |
|
||||
| ✅ Observability | 2 | 23 | 2811 |
|
||||
| ✅ Optimizer | 1 | 2 | 792 |
|
||||
| ✅ Output | 3 | 5 | 1273 |
|
||||
| ✅ Parallel Execution | 3 | 2 | 497 |
|
||||
| ✅ Planning | 6 | 7 | 1825 |
|
||||
| ✅ Plugins | 8 | 5 | 3124 |
|
||||
| ✅ Prompts | 2 | 9 | 2018 |
|
||||
| ✅ Providers | 1 | 57 | 8636 |
|
||||
| ✅ Plugins | 8 | 5 | 3120 |
|
||||
| ✅ Prompts | 2 | 9 | 1971 |
|
||||
| ✅ Providers | 1 | 56 | 7793 |
|
||||
| ✅ Query | 1 | 2 | 736 |
|
||||
| ✅ RAG | 5 | 15 | 3122 |
|
||||
| ✅ RAG | 5 | 15 | 3118 |
|
||||
| ✅ Realtime | 2 | 5 | 668 |
|
||||
| ✅ Reflection | 3 | 3 | 732 |
|
||||
| ✅ Retrieval | 2 | 5 | 1042 |
|
||||
| ✅ Routing | 1 | 2 | 430 |
|
||||
| ✅ Sandbox | 5 | 7 | 3670 |
|
||||
| ✅ Security | 1 | 3 | 3558 |
|
||||
| ✅ Sessions | 4 | 20 | 7898 |
|
||||
| ✅ Skills | 6 | 16 | 5984 |
|
||||
| ✅ Tasks | 2 | 8 | 3231 |
|
||||
| ✅ Telemetry | 1 | 2 | 660 |
|
||||
| ✅ Templates | 1 | 8 | 1667 |
|
||||
| ✅ Tools | 12 | 145 | 44258 |
|
||||
| ✅ Sandbox | 5 | 7 | 3556 |
|
||||
| ✅ Security | 1 | 3 | 3529 |
|
||||
| ✅ Sessions | 4 | 19 | 7644 |
|
||||
| ✅ Skills | 6 | 16 | 5903 |
|
||||
| ✅ Tasks | 2 | 6 | 2897 |
|
||||
| ✅ Telemetry | 1 | 2 | 648 |
|
||||
| ✅ Templates | 1 | 8 | 1663 |
|
||||
| ✅ Tools | 12 | 144 | 43629 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Vector Store | 1 | 12 | 1219 |
|
||||
| ✅ Video | 2 | 6 | 728 |
|
||||
| ✅ Video | 2 | 6 | 649 |
|
||||
| ✅ Vision | 2 | 1 | 329 |
|
||||
| ✅ Web | 3 | 10 | 2791 |
|
||||
| ✅ Workflows | 5 | 18 | 7446 |
|
||||
| ✅ Web | 3 | 9 | 2487 |
|
||||
| ✅ Workflows | 5 | 18 | 7406 |
|
||||
|
||||
## Documentation Without Features
|
||||
|
||||
These docs exist but don't match any implemented feature category:
|
||||
|
||||
- ℹ️ Database (43 docs, 7573 lines)
|
||||
- ℹ️ Documents (1 docs, 810 lines)
|
||||
- ℹ️ Database (42 docs, 6973 lines)
|
||||
- ℹ️ Documents (1 docs, 798 lines)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
# Ground truth
|
||||
|
||||
Every claim here was measured, not estimated. Each carries **how** it was obtained so it can
|
||||
be re-checked cheaply. Measured 2026-09-03 against `origin/main` at `2591aa405`, with a live
|
||||
Ollama 0.33.2 (`b79067b0`) on `127.0.0.1:11434`.
|
||||
|
||||
If a work order contradicts something here, re-measure before acting. Numbers drift.
|
||||
|
||||
> **Capabilities are per-model, not per-server.** An early draft of this file attributed the
|
||||
> 9B model's capability list to `qwen3:0.6b`. Re-verified 2026-09-03: `qwen3:0.6b` is
|
||||
> `["completion","tools","thinking"]` — **no vision**. Always name the model when quoting a
|
||||
> capability set.
|
||||
|
||||
---
|
||||
|
||||
## 1. Runtime facts, verified live
|
||||
|
||||
| Fact | Value | How to re-verify |
|
||||
|---|---|---|
|
||||
| Ollama liveness probe | `GET /` returns `200` with body `Ollama is running` | `curl -sI 127.0.0.1:11434/` |
|
||||
| Ollama version probe | `{"version":"0.33.2"}` | `curl -s 127.0.0.1:11434/api/version` |
|
||||
| Capability probe works | `qwen3:0.6b` → `["completion","tools","thinking"]` (the 9B tamil model, separately, → `[...,"vision"]`) | `curl -s :11434/api/show -d '{"model":"qwen3:0.6b"}' \| jq .capabilities` |
|
||||
| Unknown `options` keys are silently dropped | `{"totally_bogus_key":123}` → HTTP 200, no warning | send it to `/api/chat`; response is a normal completion |
|
||||
| Tool-param schema keywords are stripped | `minLength`, `format`, `default`, `additionalProperties` all absent from the rendered prompt | `/api/chat` with `_debug_render_only: true` and those keys set |
|
||||
| `parameters.type` must be a bare string | `"type": ["object","null"]` → **HTTP 400** `cannot unmarshal array into Go struct field ToolFunctionParameters...type of type string` | send that tool definition to `/api/chat` |
|
||||
| **`format` + `tools` are mutually destructive** | Tool call silently suppressed; model fabricates. Same request without `format` calls the tool correctly. | see §2 below |
|
||||
|
||||
### Installed on this machine
|
||||
|
||||
`ollama` (0.33.2), `llama-server` (b7620, Homebrew), `mlx_lm.server`, `hf` CLI.
|
||||
**Not** installed: `lms` (LM Studio), `vllm`.
|
||||
Ports open at time of measurement: `11434` only.
|
||||
|
||||
---
|
||||
|
||||
## 2. The reproduction that matters most
|
||||
|
||||
`format` (JSON schema) plus `tools`, sent to Ollama, produces a **confident fabrication** with
|
||||
an HTTP 200 and no warning. The JSON grammar makes the tool-call tag unemittable, so the
|
||||
model cannot call the tool and answers from nothing instead.
|
||||
|
||||
```
|
||||
POST /api/chat model=qwen3:0.6b think=false
|
||||
prompt: "What is the weather in Paris? Use the tool."
|
||||
tools: [get_weather(city: string)]
|
||||
|
||||
WITH format + tools -> HTTP 200
|
||||
message.tool_calls : null
|
||||
message.content : {"answer": "The weather in Paris is sunny with a temperature of 15C."}
|
||||
|
||||
TOOLS ONLY (format removed) -> HTTP 200
|
||||
message.tool_calls : [{"function": {"name": "get_weather",
|
||||
"arguments": {"city": "Paris"}}}]
|
||||
message.content : ""
|
||||
```
|
||||
|
||||
Consequence for design: this combination must **raise**, not be silently repaired. A
|
||||
fabricated answer that looks correct is worse than an error.
|
||||
|
||||
---
|
||||
|
||||
## 3. Codebase measurements
|
||||
|
||||
All obtained by script over `praisonaiagents/` at `2591aa405`.
|
||||
|
||||
### Scale
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Python files / lines | 595 / 238,936 |
|
||||
| Files over 2,000 lines | 13 |
|
||||
| `agent/agent.py` | 7,701 |
|
||||
| `llm/llm.py` | 7,160 |
|
||||
| `agent/chat_mixin.py` | 6,151 |
|
||||
| `llm/openai_client.py` | 2,691 |
|
||||
| `llm/adapters/__init__.py` | 340 |
|
||||
|
||||
### `LLM` class cohesion
|
||||
|
||||
95 methods, 6,779 lines. Three largest methods total 3,087 lines — 43% of the file.
|
||||
|
||||
| Lines | Location | Method | Branch/loop/try nodes |
|
||||
|---|---|---|---|
|
||||
| 1,675 | `llm.py:2557` | `get_response` | **194** |
|
||||
| 969 | `llm.py:4706` | `get_response_async` | 115 |
|
||||
| 443 | `llm.py:4233` | `get_response_stream` | 56 |
|
||||
| 249 | `llm.py:5966` | `_build_completion_params` | 52 |
|
||||
| 199 | `llm.py:413` | `__init__` | 10 |
|
||||
|
||||
### Adapter call sites — the A1 finding
|
||||
|
||||
`DefaultAdapter` declares **17 methods** (an earlier draft said 18; the 18th item,
|
||||
`add_provider_adapter`, is a module-level function). Eleven methods have **zero** call sites
|
||||
anywhere, and with the three module functions that is 12 dead items. Note also that
|
||||
`LLMProviderAdapterProtocol` declares only **16** — it omits `get_streaming_adapter`, so that
|
||||
method is not part of the protocol it claims to implement.
|
||||
|
||||
```
|
||||
supports_streaming 2
|
||||
format_tool_result_message 1 (llm.py:1712)
|
||||
get_default_settings 1 (llm.py:690)
|
||||
should_summarize_tools 1
|
||||
supports_prompt_caching 1
|
||||
supports_streaming_with_tools 1
|
||||
--- zero call sites ---
|
||||
recover_tool_calls_from_text 0 (inline copy at llm.py:3468-3500)
|
||||
parse_tool_calls 0
|
||||
handle_empty_response_with_tools 0
|
||||
post_tool_iteration 0
|
||||
format_tools 0
|
||||
get_streaming_adapter 0
|
||||
get_max_iteration_threshold 0
|
||||
should_skip_streaming_with_tools 0
|
||||
supports_structured_output 0
|
||||
inject_cache_control 0
|
||||
extract_reasoning_tokens 0
|
||||
add_provider_adapter 0 (public registration hook)
|
||||
```
|
||||
|
||||
Ollama references (case-insensitive): `llm/llm.py` **145**, `llm/adapters/__init__.py` 19,
|
||||
`llm/streaming_protocol.py` 11, `tools/call_executor.py` 6.
|
||||
|
||||
### Compensation coverage across the three response paths — the A2 finding
|
||||
|
||||
```
|
||||
get_response _async _stream
|
||||
1675 L 969 L 443 L
|
||||
tool_result_mapping (value fix-up) 8 0 0
|
||||
max_tool_repairs (repair loop) 2 0 0
|
||||
force_tool_usage (prompt inject) 1 0 0
|
||||
_validate_and_filter_ollama_arguments 1 1 0
|
||||
_generate_ollama_tool_summary 1 1 0
|
||||
_handle_ollama_sequential_logic 1 1 0
|
||||
_format_ollama_tool_result_message 1 1 1* (indirect)
|
||||
```
|
||||
|
||||
`get_response_stream` has none of the seven — except `_format_ollama_tool_result_message`,
|
||||
which it reaches indirectly via `_create_tool_message` (`llm.py:6791`), so that row overstated
|
||||
the gap. A fuller re-measurement found **16** divergent compensations, not 7; three of them run
|
||||
*against* the sync path. The full matrix is in `06-adapter-revival.md` §2.6. `difflib` similarity of `get_response` against
|
||||
`get_response_async` is **22.3%**, while the file's other four sync/async pairs are 76–91% —
|
||||
so those two have diverged, not merely duplicated.
|
||||
|
||||
### Import graph — the I6 constraint
|
||||
|
||||
65 subpackages, 212 directed edges, **26 mutually-importing pairs**. Heaviest:
|
||||
|
||||
```
|
||||
agent <-> tools 53 + 1 llm/ imports: tools 10, streaming 7,
|
||||
agent <-> approval 28 + 1 agent 4, telemetry 2,
|
||||
agent <-> config 25 + 1 auth 2, compaction 2,
|
||||
agent <-> llm 19 + 4 context 1, thinking 1
|
||||
llm <-> tools 10 + 1
|
||||
context <-> llm 2 + 1 15 subpackages import llm/
|
||||
```
|
||||
|
||||
### Reachability — the A3 finding
|
||||
|
||||
21 modules call a provider SDK directly, bypassing both clients: 23 `litellm.completion/acompletion`,
|
||||
7 `litellm.embedding/aembedding`, 16 `chat.completions.create`, 3 bare `OpenAI()`.
|
||||
|
||||
These nine contain **zero** occurrences of `api_base` or `base_url`, so there is no parameter
|
||||
to set — "run everything locally" is currently unachievable, not merely unconfigured:
|
||||
|
||||
```
|
||||
memory/memory.py workflows/workflows.py eval/judge.py
|
||||
eval/grader.py eval/accuracy.py context/compressor.py
|
||||
context/optimizer.py lite/__init__.py telemetry/performance_monitor.py
|
||||
```
|
||||
|
||||
Worst single case, `memory/memory.py:2186-2192`:
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
client = OpenAI() # no base_url, no api_key
|
||||
response = client.chat.completions.create(
|
||||
model=llm or "gpt-4o-mini", ...
|
||||
```
|
||||
|
||||
Hardcoded defaults in code (excluding docstrings): **105** `"gpt-4o-mini"`, **17**
|
||||
`"text-embedding-3-small"`. Sixteen of the 105 are the identical line
|
||||
`self.llm if hasattr(self,'llm') else "gpt-4o-mini"` in `agent/context_agent.py`.
|
||||
|
||||
### Stringly-typed provider identity — the A6 finding
|
||||
|
||||
**77 occurrences of 23 distinct string predicates** across `src/`. The same provider is
|
||||
tested inconsistently, which is precisely how D3 and D4 arose:
|
||||
|
||||
```
|
||||
9 startswith("anthropic/") gemini tested 3 ways: gpt tested 4 ways:
|
||||
8 startswith("ollama/") startswith("gemini") startswith('gpt-')
|
||||
7 "ollama" in startswith("gemini/") startswith("gpt-")
|
||||
6 startswith("gemini") startswith('gemini-') startswith("gpt")
|
||||
5 startswith("openai/") "gpt" in
|
||||
5 startswith("claude")
|
||||
3 ":11434" in
|
||||
```
|
||||
|
||||
### Accretion history — why boundaries matter here
|
||||
|
||||
| File | First | Now | Commits | Ever shrank |
|
||||
|---|---|---|---|---|
|
||||
| `llm/llm.py` | 1,212 (Jan 2025) | 7,160 | 251 | never, in 18 sampled months |
|
||||
| `agent/agent.py` | 1,251 (Jan 2025) | 7,701 | 472 | once — the split below |
|
||||
| `agent/chat_mixin.py` | created Apr 2026 | 6,151 | 150 | — |
|
||||
|
||||
On **2026-04-01** `agent.py` was split 8,915 → 5,030 by extracting `chat_mixin.py`. Five
|
||||
months later the two halves total **13,852 lines — 55% above the pre-split file**, growing
|
||||
~33 lines/day. Moving code did not create a boundary. This is the argument for I6 being a
|
||||
*test* rather than a convention.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dependency facts
|
||||
|
||||
| Package | Version present | Status |
|
||||
|---|---|---|
|
||||
| `httpx` | 0.28.1 | **hard transitive dep** of required `openai>=2.0.0` (`httpx<1,>=0.23.0`) |
|
||||
| `aiohttp` | 3.14.1 | direct required core dep |
|
||||
| `openai` | 2.44.0 | direct required core dep |
|
||||
| `litellm` | 1.90.2 | optional (`llm`, `memory` extras) |
|
||||
|
||||
`pyproject.toml` declares **17 optional extras** — `a2ui, api, all, auth, autonomy, crawl,
|
||||
dakera, graph, knowledge, llm, mcp, memory, mongodb, os, sandbox, search, telemetry` — and
|
||||
**none of them is local**. `import praisonaiagents` measures 0.04 s, so imports are lazy and
|
||||
cheap; discovery latency is the only startup cost to manage.
|
||||
|
||||
**Conclusion (superseded).** This section originally concluded `local/` should use `httpx`.
|
||||
That was reversed by the normative decision in `07-local-package-spec.md` §3: `httpx` is present
|
||||
only as an **undeclared transitive** edge (`openai -> httpx`), so building the dependency sink on
|
||||
it means a future `openai` release dropping `httpx` would silently break `local/`. The client is
|
||||
therefore stdlib `urllib.request`, and the boundary test forbids `httpx`/`requests`/`aiohttp`.
|
||||
The measurement above stands; only the recommendation drawn from it was wrong.
|
||||
|
||||
---
|
||||
|
||||
## 5. litellm prefix resolution vs ours
|
||||
|
||||
litellm 1.90.2 resolves all of these; PraisonAI's `_detect_provider` recognises only `ollama/`:
|
||||
|
||||
```
|
||||
ollama/llama3.2 -> ollama OllamaAdapter repairs=2
|
||||
ollama_chat/llama3.2 -> openai DefaultAdapter repairs=0 <- litellm's RECOMMENDED prefix
|
||||
lm_studio/qwen -> openai DefaultAdapter repairs=0
|
||||
vllm/x -> openai DefaultAdapter repairs=0
|
||||
hosted_vllm/x -> openai DefaultAdapter repairs=0
|
||||
huggingface/x -> openai DefaultAdapter repairs=0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Known-bad environment note
|
||||
|
||||
`mervinpraison/praisonai-qwen3.5-9b-tamil-en2ta:latest` does **not load** on Ollama 0.33.2:
|
||||
`Failed to load CLIP model from ...sha256-11540ddc21b3...`, HTTP 500. The vision projector
|
||||
blob is the failing piece. Use `qwen3:0.6b` for local verification instead. Unrelated to this
|
||||
work, but it will waste an agent's time if not known.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 7. The three response paths disagree — measured, not inferred
|
||||
|
||||
Live Ollama, `temperature=0`, identical prompt and tool, two trials, reproducible
|
||||
(script in `06-adapter-revival.md` §2.7):
|
||||
|
||||
```
|
||||
sync calls=2 'Paris: 21C sunny. Paris: 21C sunny.'
|
||||
async calls=1 'Paris: 21C sunny'
|
||||
stream calls=10 ''
|
||||
```
|
||||
|
||||
> **Corrected 2026-09-03.** An earlier draft recorded the sync path returning
|
||||
> `'{\n\n\n}'` with one tool call. That did not reproduce; the figures above did,
|
||||
> across two trials. The finding is unchanged — the paths disagree and the stream
|
||||
> path is broken — but the `'{\n\n\n}'` sample should not be cited.
|
||||
|
||||
Three facts follow, and they reframe the work:
|
||||
|
||||
1. **The best-compensated path produces the worst answer.** `get_response` carries 16 of the 18
|
||||
compensations and calls the tool twice, returning a doubled answer;
|
||||
`get_response_async` carries 8 and returns the correct answer once. More compensation is
|
||||
not the goal — *the same* compensation is.
|
||||
2. **The stream path is not under-compensated, it is broken.** It burns all 10 iterations of
|
||||
`max_iter`, invokes the tool **ten times** for a one-tool question, and yields nothing. That
|
||||
is a cost and side-effect amplifier, not a formatting defect.
|
||||
3. Leading hypothesis for the sync/async split: `OLLAMA_FINAL_ANSWER_PROMPT` (`llm.py:5302`,
|
||||
async only). **Still a hypothesis, not a measurement** — it was not settled during order 06,
|
||||
because the corrected baseline changed what needs explaining.
|
||||
|
||||
## 8. Two test-infrastructure traps
|
||||
|
||||
- **`src/praisonai-agents/tests/` is almost entirely outside CI.** The only tests any workflow
|
||||
runs from it are `tests/smoke/test_subscription_auth.py` and
|
||||
`tests/unit/auth/test_agent_auth_wiring.py`. `tests/unit/test_adapter_registry.py` (38 tests)
|
||||
and all 13 files in `tests/unit/llm/` run **nowhere**. Put new tests in
|
||||
`src/praisonai/tests/unit/llm/`, which is run.
|
||||
- **`test_ollama_tool_reliability.py` tests a copy, not the product.** Its `MockLLM`
|
||||
re-implements `_is_ollama_provider`, `_apply_ollama_defaults`, `_validate_tool_call`,
|
||||
`_should_force_tool_usage` and both prompt templates. It cannot fail in response to any change
|
||||
in `llm.py`. Do not treat it as a safety net.
|
||||
|
||||
|
||||
## 9. Test-gating facts (order 04)
|
||||
|
||||
- **`not network`, not `provider_ollama`, is what deselects the mocked base_url suite.** The gating
|
||||
plugin implies `network` from *any* provider marker (`test_gating.py:235-238`) and every CI
|
||||
expression contains `not network`. Editing the workflows' `-m` strings accomplishes nothing.
|
||||
- `tests/unit/**` is **exempt** from provider auto-detection (`test_gating.py:215`,
|
||||
`test_type != 'unit'`). That is why `tests/unit/llm/test_ollama_tool_reliability.py` runs in CI
|
||||
while the integration file does not — and why live tests placed under `tests/unit/` need an
|
||||
**explicit** marker.
|
||||
- `-m provider_ollama` currently selects **32 tests across 5 files**; only 7 are Ollama tests.
|
||||
- **4 of the 7 tests in `test_base_url_api_base_fix.py` fail when actually executed** —
|
||||
`LLMResponseError: 'str' object has no attribute 'choices'`. The mock returns a dict; the
|
||||
tool-calling loop needs an object with `.choices`.
|
||||
- **`tests/test_double_api_fix.py` makes a real network call when forced to run.** `llm.py` routes
|
||||
`gpt-4o-mini` through `litellm.responses` (`llm.py:6434`), not `litellm.completion`, so its patch
|
||||
no longer intercepts (verified: 401 from `api.openai.com/v1/responses`). It is currently masked
|
||||
by an unconditional auto-skip — **anyone who "fixes" the skip without fixing the mock gets live
|
||||
billed calls.**
|
||||
- **`network_guard.py` disables itself** for anything under `/integration/`, `/e2e/`, `/live/`
|
||||
(lines 103-105) and for any `provider_*`-marked test (96-100). The suite's network blocker does
|
||||
not cover the tests most likely to make network calls.
|
||||
- **A live Ollama suite already exists** and has never run:
|
||||
`tests/integration/test_ollama_tool_calling_live.py`, 230 lines, 7 tests, gated on
|
||||
`PRAISONAI_TEST_OLLAMA`. It is broken against `main` — all 7 pass `verbose=True` to `Agent()`,
|
||||
and two pass `force_tool_usage=` / `max_tool_repairs=`; all three now raise `TypeError`.
|
||||
- **`qwen3:0.6b` is 522 MB** (not ~400 MB) and reports `completion, tools, thinking` — **no
|
||||
vision**. Reliability through `Agent`: default 3/4; `force_tool_usage="always"` + `temperature=0`
|
||||
**6/6**; multi-step arithmetic 0/2 (one hung past 90 s).
|
||||
@@ -1,372 +0,0 @@
|
||||
# 04 — D7: provider markers are assigned per file, so mocked tests are gated as if live
|
||||
|
||||
**Branch:** `fix/gating-per-test-provider-markers`
|
||||
**Measured:** clean worktree at `origin/main` (`2591aa405`), working tree clean.
|
||||
**Owns:** `src/praisonai/tests/_pytest_plugins/test_gating.py`, both `pytest.ini` files,
|
||||
`src/praisonai/tests/conftest.py`, `tests/integration/test_base_url_api_base_fix.py`,
|
||||
`tests/unit/cli/test_test_command.py`.
|
||||
**Must not edit:** any `praisonaiagents/` source; `.github/workflows/**` (order 05).
|
||||
|
||||
> **The audit's stated root cause was wrong, and the correction changes the fix.**
|
||||
> `provider_ollama` is a red herring. **`not network` deselects all 7 tests on its own**, because
|
||||
> the plugin implies `network` from *any* provider marker and the file also matches
|
||||
> `provider_openai`. **Removing `not provider_ollama` from the workflows would change nothing.**
|
||||
> The fix must be marker-side, not workflow-side. This is why order 04 touches no YAML.
|
||||
|
||||
---
|
||||
|
||||
## 1. Corrections to the audit
|
||||
|
||||
| # | Claim | Verdict |
|
||||
|---|---|---|
|
||||
| 1 | 4 CI marker expressions carry `not provider_ollama` | **True but incomplete** — there is a 5th, non-CI location: `src/praisonai-code/praisonai_code/cli/commands/test.py:67` (`praisonai test --tier main`). |
|
||||
| 2 | 7 tests, all deselected | **Exactly true** (`collected 7 items / 7 deselected / 0 selected`). **Stated cause is wrong** — see the callout above. |
|
||||
| 3 | The file never inspects `litellm.completion` kwargs | **True** (only `mock_completion.assert_called()`). **And worse: 4 of the 7 tests fail outright when executed.** They are not merely toothless, they are broken. |
|
||||
| 4 | `test_double_api_fix.py`: 0 asserts, 34 prints | **True. And worse:** all 3 tests are auto-`skip`ped in every configuration, and when forced to run its `patch('litellm.completion')` no longer intercepts — `llm.py` routes `gpt-4o-mini` through `litellm.responses`, so it makes a **real network call** (verified: 401 from `api.openai.com/v1/responses`). |
|
||||
| 5 | `test_ollama_tool_reliability.py` asserts against MockLLM | **True.** 541 lines, MockLLM at 23–197, 28 tests, all pass. **Quantified:** 2 of the 8 re-implemented methods (`_apply_ollama_defaults`, `_try_parse_tool_call_json`) **do not exist on the real `LLM` at all.** |
|
||||
| 6 | agents-side ollama tests gated only by `OLLAMA_HOST` | **True**, and `test_ollama_fix_openai` is gated only by `OPENAI_API_KEY` — a live **billed** call on a plain `pytest`. Mitigating: no CI workflow runs these two files. |
|
||||
| 7 | No ollama live job | **True for CI**, but the audit **missed that the suite already exists**: `tests/integration/test_ollama_tool_calling_live.py`, 230 lines, 7 tests, gated on `PRAISONAI_TEST_OLLAMA=1`. It is **broken against current `main`**. See order 05. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Reproduce before the fix
|
||||
|
||||
```bash
|
||||
cd src/praisonai && export PYTHONPATH=$(cd ../praisonai-agents && pwd)
|
||||
CI_M="not slow and not network and not local_service and not provider_anthropic and not provider_google and not provider_ollama and not provider_grok_xai and not provider_groq and not provider_cohere"
|
||||
```
|
||||
|
||||
**R1 — 100% deselected:**
|
||||
```bash
|
||||
python -m pytest tests/integration/test_base_url_api_base_fix.py -m "$CI_M" --collect-only -q --disable-warnings
|
||||
# collected 7 items / 7 deselected / 0 selected
|
||||
```
|
||||
|
||||
**R2 — `provider_ollama` is a red herring.** Rerun R1 with `and not provider_ollama` **removed**:
|
||||
still `7 deselected / 0 selected`. `not network` does it alone.
|
||||
|
||||
**R3 — the whole file is tagged.** With a marker-dump plugin, all seven items carry the identical
|
||||
set `['integration', 'network', 'provider_ollama', 'provider_openai', 'skip']` — including the six
|
||||
that never mention Ollama.
|
||||
|
||||
**R4 — 4 of 7 fail when actually executed:**
|
||||
```bash
|
||||
PRAISONAI_ALLOW_NETWORK=1 PRAISONAI_TEST_PROVIDERS=all OPENAI_API_KEY=sk-fake-offline \
|
||||
python -m pytest tests/integration/test_base_url_api_base_fix.py -o addopts="" -q --tb=no -rA
|
||||
# 4 failed, 3 passed
|
||||
```
|
||||
All four fail with `LLMResponseError: ... 'str' object has no attribute 'choices'`. The three that
|
||||
pass are the three that never call `get_response()`.
|
||||
|
||||
**R5 — `-m provider_ollama` selects 4× more than it should:**
|
||||
```
|
||||
7 tests/integration/test_base_url_api_base_fix.py
|
||||
7 tests/integration/test_ollama_tool_calling_live.py
|
||||
11 tests/integration/test_rag_integration.py
|
||||
4 tests/integration/test_setup_integration.py
|
||||
3 tests/test_double_api_fix.py
|
||||
```
|
||||
**32 selected; only 7 are Ollama tests.** This is why order 04 is a hard prerequisite for order 05 —
|
||||
`-m provider_ollama` is currently not a usable selector.
|
||||
|
||||
---
|
||||
|
||||
## 3. Root cause
|
||||
|
||||
`test_gating.py:153-166` scans the **entire file**:
|
||||
|
||||
```python
|
||||
153 def _detect_providers_in_file(filepath: Path) -> Set[str]:
|
||||
154 """Detect which providers are referenced in a test file."""
|
||||
...
|
||||
161 content = _get_file_content(filepath)
|
||||
162 detected = set()
|
||||
163 for marker, pattern in PROVIDER_PATTERNS.items():
|
||||
164 if pattern.search(content):
|
||||
165 detected.add(marker)
|
||||
166 return detected
|
||||
```
|
||||
|
||||
Line 28 is `'provider_ollama': re.compile(r'\b(ollama)\b', re.IGNORECASE)`. One occurrence inside
|
||||
`test_ollama_environment_variable_compatibility` tags all seven items (applied at lines 213–230).
|
||||
|
||||
Note line 215: `if item.fspath and test_type != 'unit'` — **`tests/unit/**` is exempt**, which is
|
||||
why `tests/unit/llm/test_ollama_tool_reliability.py` runs in CI while the integration file does not.
|
||||
|
||||
**The second mechanism, which the audit missed**, is lines 235–238:
|
||||
|
||||
```python
|
||||
235 # 3. Add network marker if any provider marker is present
|
||||
236 provider_markers = {m for m in existing_markers if m.startswith('provider_')}
|
||||
237 if provider_markers and 'network' not in existing_markers:
|
||||
238 item.add_marker(pytest.mark.network)
|
||||
```
|
||||
|
||||
A mocked test that merely *mentions* `openai` gets `network`, and every CI expression contains
|
||||
`not network`. Lines 262–267 then convert it into a runtime skip.
|
||||
|
||||
**Sub-defect 3.** Lines 59, 145, 177, 234 are all bare `mock_completion.assert_called()`. Nothing
|
||||
reads `call_args`. Measured: `LLM(model='openai/mistral', base_url='http://localhost:4000',
|
||||
api_key='sk-test').get_response("test", stream=False)` calls `litellm.completion` with
|
||||
`base_url='http://localhost:4000'` and `api_base=None`. **So the file's title is stale** — the real
|
||||
invariant is *`base_url` survives into the call*, not "maps to `api_base`".
|
||||
|
||||
---
|
||||
|
||||
## 4. The change
|
||||
|
||||
**Design decision: AST per-test marking, not file splitting.**
|
||||
|
||||
- *Split the file* — cost cheap, **benefit zero** (measured, R2): the six non-Ollama tests still
|
||||
match `provider_openai`, still get `network`, still get deselected. It also leaves `-m
|
||||
provider_ollama` selecting 32 tests and fixes nothing for the other 14 mixed-provider files.
|
||||
**Rejected.**
|
||||
- *AST per-test marking* — one change in one plugin, fixes the class of problem, and is the
|
||||
prerequisite for order 05's selector to mean anything. **Recommended.**
|
||||
|
||||
### Sub-change 1 — per-test provider detection via AST
|
||||
|
||||
Keep `_detect_providers_in_file` (it now serves only the `network` marker) and add:
|
||||
|
||||
```python
|
||||
def _detect_providers_in_text(text: str) -> Set[str]:
|
||||
"""Return every provider marker whose pattern appears in ``text``."""
|
||||
detected = set()
|
||||
for marker, pattern in PROVIDER_PATTERNS.items():
|
||||
if pattern.search(text):
|
||||
detected.add(marker)
|
||||
return detected
|
||||
|
||||
|
||||
def _build_per_test_provider_map(filepath: Path) -> Optional[Dict[tuple, Set[str]]]:
|
||||
"""Map ``(class_name, func_name)`` -> provider markers for one test file.
|
||||
|
||||
The text considered for a test is that test's own source segment plus its
|
||||
decorators -- not the whole file. Returns ``None`` if the file cannot be
|
||||
parsed, so callers fall back to whole-file rather than under-marking.
|
||||
"""
|
||||
key = str(filepath)
|
||||
if key in _file_provider_cache:
|
||||
return _file_provider_cache[key]
|
||||
result: Optional[Dict[tuple, Set[str]]] = None
|
||||
try:
|
||||
source = _get_file_content(filepath)
|
||||
tree = ast.parse(source)
|
||||
result = {}
|
||||
|
||||
def _visit(node, class_name=None):
|
||||
for child in getattr(node, 'body', ()):
|
||||
if isinstance(child, ast.ClassDef):
|
||||
_visit(child, child.name)
|
||||
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
segment = ast.get_source_segment(source, child) or ""
|
||||
for decorator in child.decorator_list:
|
||||
segment += "\n" + (ast.get_source_segment(source, decorator) or "")
|
||||
result[(class_name, child.name)] = _detect_providers_in_text(segment)
|
||||
|
||||
_visit(tree)
|
||||
except (SyntaxError, ValueError, RecursionError):
|
||||
result = None
|
||||
_file_provider_cache[key] = result
|
||||
return result
|
||||
|
||||
|
||||
def _detect_providers_for_item(item) -> Set[str]:
|
||||
"""Provider markers for a single collected item (per test, not per file)."""
|
||||
filepath = Path(item.fspath)
|
||||
if _is_excluded_path(str(filepath)):
|
||||
return set()
|
||||
per_test = _build_per_test_provider_map(filepath)
|
||||
if per_test is None:
|
||||
return _detect_providers_in_file(filepath)
|
||||
parts = item.nodeid.split("::")[1:]
|
||||
if not parts:
|
||||
return _detect_providers_in_file(filepath)
|
||||
func = parts[-1].split("[")[0]
|
||||
cls = parts[-2] if len(parts) >= 2 else None
|
||||
if (cls, func) in per_test:
|
||||
return set(per_test[(cls, func)])
|
||||
if (None, func) in per_test:
|
||||
return set(per_test[(None, func)])
|
||||
# Dynamically generated item we cannot locate in the AST: stay conservative.
|
||||
return _detect_providers_in_file(filepath)
|
||||
```
|
||||
|
||||
Add `import ast`, a `_file_provider_cache: Dict[str, Optional[Dict[tuple, Set[str]]]] = {}` next to
|
||||
`_file_content_cache` (line 46), and clear it in `pytest_configure` (186) and
|
||||
`pytest_sessionfinish` (294).
|
||||
|
||||
### Sub-change 2 — `offline` opt-out, and pin `network` to the file
|
||||
|
||||
Replace lines 213–230 with a version that (a) honours an explicit `offline` marker, (b) calls
|
||||
`_detect_providers_for_item`, and (c) **keeps `network` whole-file derived**:
|
||||
|
||||
```python
|
||||
offline = 'offline' in existing_markers
|
||||
if not offline:
|
||||
detected_providers = _detect_providers_for_item(item)
|
||||
for marker, pattern in PROVIDER_PATTERNS.items():
|
||||
if pattern.search(item.nodeid):
|
||||
detected_providers.add(marker)
|
||||
for provider in detected_providers:
|
||||
if provider not in existing_markers:
|
||||
item.add_marker(getattr(pytest.mark, provider))
|
||||
# The `network` marker stays WHOLE-FILE derived. Narrowing
|
||||
# the provider markers must not, by itself, un-deselect a
|
||||
# test that is really live -- only `offline` does that.
|
||||
if _detect_providers_in_file(filepath) and 'network' not in existing_markers:
|
||||
item.add_marker(pytest.mark.network)
|
||||
```
|
||||
|
||||
**Why `network` stays file-derived — this is the whole backward-compatibility story and is not
|
||||
optional.** Measured: if per-test narrowing were also allowed to narrow `network`, **48 currently-
|
||||
deselected integration tests would newly select, 25 of them with no `skipif` — they would actually
|
||||
execute**, across nine files including `test_rag_live.py` and `test_model_routing_real.py`, in a
|
||||
suite where the network guard is disabled for everything under `/integration/`
|
||||
(`network_guard.py:103-105`). Pinning `network` to the file makes sub-change 1 provably
|
||||
**selection-neutral** under every existing `-m` expression, while still making `-m provider_ollama`
|
||||
mean what it says.
|
||||
|
||||
Register the marker in both `pytest.ini` files and `tests/conftest.py`:
|
||||
```
|
||||
offline: Fully mocked - opt out of provider/network auto-marking
|
||||
```
|
||||
|
||||
### Sub-change 3 — opt the file in, and make it guard its bug
|
||||
|
||||
Add `pytestmark = pytest.mark.offline` plus a response factory:
|
||||
|
||||
```python
|
||||
def make_completion_response(text="Test response"):
|
||||
"""A litellm.completion stand-in the LLM tool-calling loop can consume.
|
||||
|
||||
A bare dict is NOT sufficient: the loop reads ``.choices[0].message`` and a
|
||||
dict raises ``'str' object has no attribute 'choices'``.
|
||||
"""
|
||||
resp = MagicMock()
|
||||
resp.choices = [MagicMock()]
|
||||
resp.choices[0].message.content = text
|
||||
resp.choices[0].message.tool_calls = None
|
||||
resp.choices[0].finish_reason = "stop"
|
||||
return resp
|
||||
```
|
||||
|
||||
For each of the four broken tests: replace the dict `return_value` with
|
||||
`make_completion_response(...)`, pass `stream=False` to `get_response`, and replace
|
||||
`mock_completion.assert_called()` with a real kwarg assertion:
|
||||
|
||||
```python
|
||||
kwargs = mock_completion.call_args.kwargs
|
||||
assert kwargs['base_url'] == 'http://localhost:4000'
|
||||
assert kwargs['api_key'] == 'sk-test'
|
||||
assert kwargs['model'] == 'openai/mistral'
|
||||
```
|
||||
|
||||
- `test_koboldcpp_specific_scenario` → `kwargs['base_url'] == "http://127.0.0.1:5001/v1"`
|
||||
- `test_litellm_documentation_example_compatibility` → `kwargs['base_url'] == "http://0.0.0.0:4000"`
|
||||
- `test_ollama_environment_variable_compatibility` → `kwargs['model'] == 'ollama/llama2'` **and**
|
||||
`'base_url' not in kwargs` (none was configured; the env path must not inject one)
|
||||
|
||||
All four were run standalone against `main` and pass. Leave the three currently-passing tests alone.
|
||||
|
||||
Update the stale module docstring ("maps base_url to api_base" → "base_url must reach litellm") but
|
||||
**do not rename the test functions** — the nodeids are referenced in issue discussion, and renaming
|
||||
would change what the nodeid scan at plugin lines 224–226 sees.
|
||||
|
||||
---
|
||||
|
||||
## 5. Backward-compatibility contract
|
||||
|
||||
Baseline, measured (`tests/integration/`, CI ignores applied): **290 collected, 187 selected,
|
||||
103 deselected** under the CI marker expression.
|
||||
|
||||
> Caveat for the executing agent: a cold first invocation on a fresh interpreter has been observed
|
||||
> to report `157/260`, apparently an optional-dependency import warming effect. **Run it twice**;
|
||||
> `187/290/103` is stable from the second run on. Take your own baseline before touching anything.
|
||||
|
||||
**Tests whose selection status changes: exactly 7**, all in `test_base_url_api_base_fix.py`, all
|
||||
deselected → selected. Post-fix expectation: **194/290 (96 deselected)**. Nothing else moves in
|
||||
either direction — that is what the file-derived `network` guarantees.
|
||||
|
||||
Intended change under `-m provider_ollama` (32 → 9):
|
||||
|
||||
| file | before | after |
|
||||
|---|---|---|
|
||||
| `test_ollama_tool_calling_live.py` | 7 | 7 |
|
||||
| `test_base_url_api_base_fix.py` | 7 | 0 (`offline`) |
|
||||
| `test_rag_integration.py` | 11 | 1 |
|
||||
| `test_setup_integration.py` | 4 | 0 |
|
||||
| `test_double_api_fix.py` | 3 | 1 |
|
||||
|
||||
**The risk, stated plainly.** Un-deselecting tests turns CI red if any fail. **They do: 4 of the 7
|
||||
fail today** (R4). This is not hypothetical.
|
||||
|
||||
**Mitigation — mandatory, not optional: land the gating fix and the four repairs in the same PR.**
|
||||
Do not merge sub-changes 1+2 with the `pytestmark` but without the repairs. **Quarantining with
|
||||
`xfail` is explicitly rejected** — the repairs are ~10 lines each, verified working, and an `xfail`
|
||||
here would recreate the exact defect being fixed (a test that names a bug and does not guard it).
|
||||
|
||||
**Escape hatch.** If the 7 prove flaky in CI for a reason not reproducible locally, revert
|
||||
**sub-change 3 only**. Selection returns to 187/290/103 and the plugin improvements stay.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tests to add
|
||||
|
||||
In `tests/unit/cli/test_test_command.py` (the existing home of gating-plugin tests, and already in
|
||||
`EXCLUDED_PATHS`, so these cannot be auto-marked by the thing they test), add
|
||||
`class TestProviderMarkerGranularity` with:
|
||||
|
||||
- `test_ollama_does_not_leak_to_sibling_tests` — **THE meta-test.** If this fails, D7 has regressed.
|
||||
- `test_openai_does_not_leak_to_sibling_tests`
|
||||
- `test_plain_test_gets_no_provider_markers`
|
||||
- `test_decorators_are_scanned` — a `@pytest.mark.parametrize("m", ["ollama/llama2"])` must mark it
|
||||
- `test_unparseable_file_falls_back_to_whole_file` — conservative over-marking on a parse failure
|
||||
- `test_network_marker_remains_whole_file_derived` — pins the decision that keeps this change
|
||||
selection-neutral for the 96 still-deselected tests
|
||||
- `test_offline_marker_is_registered` — both `pytest.ini` files, or every use warns
|
||||
|
||||
No new dependency: `ast`, `tmp_path` and `configparser` are stdlib/pytest builtins.
|
||||
|
||||
---
|
||||
|
||||
## 7. Verification and acceptance
|
||||
|
||||
```bash
|
||||
# 1. The 7 are now selected
|
||||
python -m pytest tests/integration/test_base_url_api_base_fix.py -m "$CI_M" --collect-only -q
|
||||
# expect: 7 selected, 0 deselected
|
||||
|
||||
# 2. They pass offline, with no keys and no services
|
||||
env -u OPENAI_API_KEY -u ANTHROPIC_API_KEY -u OLLAMA_HOST \
|
||||
python -m pytest tests/integration/test_base_url_api_base_fix.py -m "$CI_M" -o addopts="" -q
|
||||
# expect: 7 passed
|
||||
|
||||
# 3. Selection neutral everywhere else
|
||||
python -m pytest tests/integration/ $IG -o addopts="" -q --collect-only -m "$CI_M" | tail -1
|
||||
# expect: 194/290 tests collected (96 deselected)
|
||||
|
||||
# 4. `-m provider_ollama` now means Ollama -> 7 + 1 + 1, not 32
|
||||
# 5. Meta-tests pass
|
||||
# 6. The three CI shards reproduced verbatim do not regress
|
||||
# 7. The two pytest.ini marker tables stay in sync
|
||||
```
|
||||
|
||||
**Acceptance gate:** `tests/integration/` collects **194/290 (96 deselected)** — exactly seven more
|
||||
than baseline, all seven from `test_base_url_api_base_fix.py` — **and passes with
|
||||
`OPENAI_API_KEY`, `ANTHROPIC_API_KEY` and `OLLAMA_HOST` all unset and no Ollama server running.**
|
||||
|
||||
**PR title:** `fix(tests): mark provider tests per test function, not per file`
|
||||
|
||||
---
|
||||
|
||||
## 8. Filed for separate PRs
|
||||
|
||||
- **`tests/test_double_api_fix.py`'s mock no longer intercepts.** `llm.py` routes `gpt-4o-mini`
|
||||
through `litellm.responses` (`llm.py:6434`), not `litellm.completion`. Forced to run, it makes a
|
||||
**real OpenAI request** (verified: 401 for `https://api.openai.com/v1/responses`). Today it is
|
||||
masked by an unconditional auto-`skip` — **anyone who "fixes" the skip without fixing the mock
|
||||
gets live billed calls.**
|
||||
- **`network_guard.py` disables itself for `/integration/`, `/e2e/`, `/live/`** (lines 103–105) and
|
||||
for any `provider_*`-marked test (96–100). The suite's network blocker does not cover the tests
|
||||
most likely to make network calls.
|
||||
@@ -1,298 +0,0 @@
|
||||
# 05 — Live local-model CI job
|
||||
|
||||
**Branch:** `feat/ci-ollama-live-job`
|
||||
**Depends on:** order `04` must merge first. Before it, `-m provider_ollama` selects 32 tests
|
||||
across 5 files (25 of which are not Ollama tests) and this job is unbuildable.
|
||||
**Owns:** `.github/workflows/test-optimized.yml`,
|
||||
`src/praisonai/tests/integration/test_ollama_tool_calling_live.py`.
|
||||
**Must not edit:** `test_gating.py` (order 04); the `smoke` / `main` / `openai-live` jobs; anything
|
||||
in `test-core.yml`; `src/praisonai-agents/tests/test_ollama_{fix,async_fix}.py`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The suite already exists — and is broken
|
||||
|
||||
The audit concluded "no ollama live job, and no live suite". The first half is right; **the second
|
||||
is wrong**. `src/praisonai/tests/integration/test_ollama_tool_calling_live.py` is 230 lines,
|
||||
7 tests, gated at lines 19–22 on `PRAISONAI_TEST_OLLAMA`. It has never run anywhere.
|
||||
|
||||
Measured against `main`, **it cannot run** — three `Agent()` kwargs are now rejected:
|
||||
|
||||
```
|
||||
$ python -c "from praisonaiagents import Agent; Agent(name='C', llm='ollama/qwen3:0.6b', verbose=True)"
|
||||
TypeError: Agent.__init__() got unexpected keyword argument(s): verbose
|
||||
verbose: verbosity moved into output=; use output='verbose' (or output=OutputConfig(verbose=True)).
|
||||
|
||||
$ ... Agent(..., force_tool_usage="always") -> TypeError: unexpected keyword argument(s): force_tool_usage
|
||||
$ ... Agent(..., max_tool_repairs=3) -> TypeError: unexpected keyword argument(s): max_tool_repairs
|
||||
```
|
||||
|
||||
All 7 tests use `verbose=True`; two also use the other two. **Every test errors on construction.**
|
||||
The modern form, verified working:
|
||||
|
||||
```python
|
||||
Agent(name="C",
|
||||
llm={"model": "ollama/qwen3:0.6b", "force_tool_usage": "always", "max_tool_repairs": 3},
|
||||
tools=[calculator])
|
||||
# -> llm_instance.force_tool_usage == 'always', llm_instance.max_tool_repairs == 3
|
||||
```
|
||||
|
||||
So this order is **"fix a dead test file and wire it up"**, not "write a live suite".
|
||||
|
||||
---
|
||||
|
||||
## 2. Model selection
|
||||
|
||||
Measured on a real Ollama 0.33.2 host:
|
||||
|
||||
```
|
||||
$ ollama list
|
||||
qwen3:0.6b 7df6b6e09427 522 MB
|
||||
$ ollama show qwen3:0.6b
|
||||
parameters 751.63M quantization Q4_K_M context length 40960
|
||||
Capabilities: completion, tools, thinking
|
||||
```
|
||||
|
||||
**Two corrections:** the blob is **522 MB**, not ~400 MB; and it is **not vision capable** —
|
||||
`completion, tools, thinking` only. Tools + thinking is all this job needs.
|
||||
|
||||
**Reliability, measured end-to-end through `praisonaiagents.Agent`:**
|
||||
|
||||
| configuration | trials | tool invoked + correct answer | latency |
|
||||
|---|---|---|---|
|
||||
| default | 4 | **3/4** — one run returned `{}` with no tool call | 0.5–5.4 s |
|
||||
| `force_tool_usage="always"`, `temperature=0` | 6 | **6/6** | 0.4–4.0 s |
|
||||
| indirect prompt ("I know it's easy, but use the tool") | 1 | 0/1 — emitted `{"type": "calculator", ...}`, wrong key | 0.6 s |
|
||||
| multi-step `(17+25)+(8+9)` | 2 | 1 hung past 90 s; 1 answered 42 instead of 59 | — |
|
||||
|
||||
**Conclusion: `qwen3:0.6b` is the right model, but only the forced, zero-temperature, single-call
|
||||
prompt is CI-deterministic.**
|
||||
|
||||
Rejected alternatives: `qwen2.5:0.5b` (~400 MB, older tool calling, unmeasured — no reason to
|
||||
prefer it over a verified model); `llama3.2:1b` (~1.3 GB, 2.5× the cache for no measured gain);
|
||||
`smollm2:135m` (**no `tools` capability — disqualifying**); `olmo-3` (the file's current default,
|
||||
~7B, far too large per run). Keep `olmo-3` as the *local* default so developer workflow is
|
||||
unchanged; let CI override via env var.
|
||||
|
||||
**Cache:** `~/.ollama/models`, keyed `${{ runner.os }}-ollama-models-${{ env.OLLAMA_TEST_MODEL }}`.
|
||||
**No `restore-keys`** — a partial restore of a *different* model's blobs is worse than a clean
|
||||
pull, and a stale key would silently test the wrong model.
|
||||
|
||||
**Install:** pinned release tarball, not `curl https://ollama.com/install.sh | sh`. The install
|
||||
script tracks latest, so the job would silently change what it tests, and it pulls GPU runtimes a
|
||||
CPU-only runner never uses. Expect ~20–60 s tarball + ~30–90 s cold model pull, near-zero on a
|
||||
cache hit — **the executing agent must confirm real timings on the first green run and write them
|
||||
into a YAML comment.**
|
||||
|
||||
**Gating:** `if: github.event_name == 'schedule' || github.event.inputs.tier == 'extended' ||
|
||||
github.event.inputs.tier == 'nightly'` — byte-identical to `extended-anthropic` (line 467),
|
||||
`extended-google` (525), `extended-groq` (585), `extended-xai` (644), `extended-cohere` (703).
|
||||
**It never runs on a pull request.**
|
||||
|
||||
---
|
||||
|
||||
## 3. One deliberate departure from the sibling jobs
|
||||
|
||||
Every existing live job ends `|| echo "... tests completed"`, so **it can never fail**. That is
|
||||
defensible for key-dependent jobs — a rotated secret should not page anyone — but it is exactly
|
||||
the "test that cannot fail" pattern this whole effort exists to remove, and **Ollama needs no
|
||||
secret**: availability is fully under the job's control.
|
||||
|
||||
So the job has **two run steps**:
|
||||
1. a **required** minimum-contract step with **no `|| echo`**;
|
||||
2. an **informational** step for the measured-flaky remainder, keeping the sibling style.
|
||||
|
||||
**Do not merge them.**
|
||||
|
||||
---
|
||||
|
||||
## 4. Test-file changes
|
||||
|
||||
1. After `pytestmark`, add the model indirection:
|
||||
```python
|
||||
# CI overrides this with a small, fast model; local runs keep olmo-3.
|
||||
OLLAMA_MODEL = "ollama/" + os.getenv("PRAISONAI_OLLAMA_TEST_MODEL", "olmo-3")
|
||||
```
|
||||
2. Replace all 7 hard-coded `llm="ollama/olmo-3"` with `llm=OLLAMA_MODEL`.
|
||||
3. Remove `verbose=True` from all 7 `Agent(...)` calls. Where verbosity is wanted: `output="verbose"`.
|
||||
4. Move `force_tool_usage="always"` and `max_tool_repairs=3` into an `llm` dict.
|
||||
5. Add the CI-minimum test as a new first class — **the only assertion in the file with a measured
|
||||
6/6 pass rate**:
|
||||
|
||||
```python
|
||||
class TestOllamaCIMinimum:
|
||||
"""The minimum contract a local model must satisfy: a tool call round-trips.
|
||||
|
||||
Deliberately narrow. Measured on qwen3:0.6b: this exact configuration
|
||||
(forced tool usage, temperature 0, single arithmetic step) passed 6/6.
|
||||
The default configuration passed 3/4 and multi-step prompts were worse --
|
||||
those live in TestOllamaToolCallingLive, which CI runs informationally.
|
||||
"""
|
||||
|
||||
def test_tool_call_round_trips(self, ollama_available):
|
||||
from praisonaiagents import Agent
|
||||
|
||||
invocations = []
|
||||
|
||||
def add(a: int, b: int) -> int:
|
||||
"""Add two integers together.
|
||||
|
||||
Args:
|
||||
a: First number to add
|
||||
b: Second number to add
|
||||
"""
|
||||
invocations.append((a, b))
|
||||
return a + b
|
||||
|
||||
agent = Agent(
|
||||
name="CI Calculator",
|
||||
llm={"model": OLLAMA_MODEL, "force_tool_usage": "always", "temperature": 0},
|
||||
tools=[add],
|
||||
)
|
||||
result = agent.chat("Compute 17 + 25. You MUST use the calculator tool.")
|
||||
|
||||
# 1. the model actually called our function, with the right arguments
|
||||
assert invocations == [(17, 25)], f"tool was not invoked: {invocations!r}"
|
||||
# 2. the tool's return value made it back into the model's answer
|
||||
assert "42" in str(result), f"tool result did not round-trip: {result!r}"
|
||||
```
|
||||
|
||||
Reuse the existing `ollama_available` fixture (lines 48–53) — it already probes
|
||||
`http://localhost:11434/api/tags` and skips cleanly. Do not add a second probe.
|
||||
|
||||
### Why both assertions are needed
|
||||
|
||||
`qwen3:0.6b` answers `17 + 25 = 42` **correctly with no tools at all** (measured, 1.2 s,
|
||||
`tools=[]`). So a bare `assert "42" in result` is satisfied by a model that ignores tools entirely.
|
||||
The `invocations == [(17, 25)]` half is what proves the tool call was emitted, parsed and
|
||||
dispatched with the right arguments; the `"42"` half proves the return value was fed back and
|
||||
reached the final answer. Anything beyond this — multi-step chains, repair loops, resisting
|
||||
distraction — is measured-flaky on a 0.6B model and belongs in the informational step.
|
||||
|
||||
---
|
||||
|
||||
## 5. The YAML
|
||||
|
||||
Insert after `extended-cohere`'s `Skip notice` step (ends line 760), before the `legacy-unit`
|
||||
banner. Key fragments:
|
||||
|
||||
```yaml
|
||||
extended-ollama:
|
||||
if: github.event_name == 'schedule' || github.event.inputs.tier == 'extended' || github.event.inputs.tier == 'nightly'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
# Bump deliberately; the cache key and what we test both follow this.
|
||||
OLLAMA_VERSION: v0.33.2
|
||||
OLLAMA_TEST_MODEL: qwen3:0.6b
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Cache Ollama model blobs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
# No restore-keys: a partial hit from a different model would
|
||||
# silently test something other than OLLAMA_TEST_MODEL.
|
||||
key: ${{ runner.os }}-ollama-models-${{ env.OLLAMA_TEST_MODEL }}
|
||||
- name: Install Ollama
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o /tmp/ollama.tgz \
|
||||
"https://github.com/ollama/ollama/releases/download/${OLLAMA_VERSION}/ollama-linux-amd64.tgz"
|
||||
sudo tar -C /usr -xzf /tmp/ollama.tgz
|
||||
ollama --version
|
||||
- name: Start Ollama and pull model
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ollama serve > /tmp/ollama-serve.log 2>&1 &
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:11434/api/tags > /dev/null; then
|
||||
echo "Ollama is up after ${i}s"; break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
curl -sf http://127.0.0.1:11434/api/tags > /dev/null
|
||||
ollama pull "$OLLAMA_TEST_MODEL"
|
||||
ollama list
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install pytest pytest-asyncio pytest-timeout
|
||||
- uses: ./.github/actions/install-monorepo-packages
|
||||
with:
|
||||
agents-extras: ".[all]"
|
||||
- name: Run Ollama minimum contract (required)
|
||||
env:
|
||||
PRAISONAI_TEST_TIER: extended
|
||||
PRAISONAI_ALLOW_NETWORK: '1'
|
||||
PRAISONAI_LIVE_TESTS: '1'
|
||||
PRAISONAI_TEST_PROVIDERS: 'ollama'
|
||||
PRAISONAI_TEST_OLLAMA: '1'
|
||||
PRAISONAI_OLLAMA_TEST_MODEL: ${{ env.OLLAMA_TEST_MODEL }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src/praisonai
|
||||
# No "|| echo" here on purpose: no secret is involved, so a failure
|
||||
# here is a real regression in local-model tool calling.
|
||||
python -m pytest tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
|
||||
-m "provider_ollama" -v --tb=short --timeout=120
|
||||
- name: Run remaining Ollama live tests (informational)
|
||||
if: always()
|
||||
env: { ...same... }
|
||||
run: |
|
||||
cd src/praisonai
|
||||
python -m pytest tests/integration/test_ollama_tool_calling_live.py \
|
||||
-m "provider_ollama" \
|
||||
--deselect tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
|
||||
-v --tb=short --timeout=180 \
|
||||
|| echo "Ollama exploratory tests completed (small models are non-deterministic)"
|
||||
- name: Ollama server log
|
||||
if: failure()
|
||||
run: tail -100 /tmp/ollama-serve.log
|
||||
```
|
||||
|
||||
And in `test-summary` (line 838): extend `needs: [smoke, main, extended-ollama]` and add the
|
||||
summary rows. `test-summary` is already `if: always()`, so a skipped `extended-ollama` on PRs
|
||||
reports `skipped` rather than blocking.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verification and acceptance
|
||||
|
||||
```bash
|
||||
ollama pull qwen3:0.6b
|
||||
cd src/praisonai && export PYTHONPATH=$(cd ../praisonai-agents && pwd)
|
||||
|
||||
# Must be 6/6 before this ships.
|
||||
for i in $(seq 1 6); do
|
||||
PRAISONAI_TEST_OLLAMA=1 PRAISONAI_ALLOW_NETWORK=1 PRAISONAI_LIVE_TESTS=1 \
|
||||
PRAISONAI_TEST_PROVIDERS=ollama PRAISONAI_OLLAMA_TEST_MODEL=qwen3:0.6b \
|
||||
python -m pytest tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
|
||||
-o addopts="" -q --timeout=120 2>&1 | tail -1
|
||||
done
|
||||
|
||||
# The gate still holds with the env var absent:
|
||||
python -m pytest tests/integration/test_ollama_tool_calling_live.py -o addopts="" -q --collect-only
|
||||
# expect: collected, all skipped by the module pytestmark
|
||||
|
||||
# Nothing leaked onto the PR path:
|
||||
grep -n "extended-ollama" -A3 .github/workflows/test-optimized.yml | grep "if:"
|
||||
python -c "import yaml; yaml.safe_load(open('.github/workflows/test-optimized.yml')); print('yaml ok')"
|
||||
|
||||
# Then: gh workflow run "Optimized Test Suite" -f tier=extended
|
||||
```
|
||||
|
||||
**Acceptance gate:** a manual `workflow_dispatch` with `tier=extended` shows `extended-ollama`
|
||||
**green**, with "Run Ollama minimum contract (required)" reporting `1 passed` — **and** the same
|
||||
workflow triggered by a pull request shows `extended-ollama` **skipped**.
|
||||
|
||||
**PR title:** `ci: add nightly live-Ollama job exercising a real local model`
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,196 +0,0 @@
|
||||
# Local Model Layer — implementation ledger
|
||||
|
||||
**Status:** every order below has an open PR. See §4.
|
||||
**Owner workstream:** local model runtimes (Ollama, LM Studio, llama.cpp, vLLM, MLX, `transformers serve`)
|
||||
**Created:** 2026-09-03
|
||||
|
||||
---
|
||||
|
||||
## 0. How to use this document
|
||||
|
||||
This directory is a **work-order ledger**. It exists so that any agent — including one
|
||||
with no memory of the conversation that produced it — can pick up exactly one unit of
|
||||
work and execute it correctly, in isolation, without re-deriving the analysis.
|
||||
|
||||
**If you are an agent starting work here, do this in order:**
|
||||
|
||||
1. Read [`00-ground-truth.md`](00-ground-truth.md). It records what was measured, how, and
|
||||
when. Do not trust a claim in any work order that contradicts it; re-measure instead.
|
||||
2. Read §2 (Invariants) and §3 (File ownership) of this file. §3 is what stops two agents
|
||||
corrupting each other's work.
|
||||
3. Open the single work order you were assigned. Execute only that one.
|
||||
4. Update the status row in §4 of this file in the same commit as your change. That row is
|
||||
the only shared file content; keep the edit to one line to avoid conflicts.
|
||||
|
||||
**Do not** expand scope, batch two work orders into one PR, or "fix while you're in there".
|
||||
Each order is independently revertible by design and that property is worth more than
|
||||
convenience.
|
||||
|
||||
> **Two corrections this ledger has already had to make.** Both were caught by measuring rather
|
||||
> than reasoning, and both would have sent an agent down the wrong path.
|
||||
>
|
||||
> **The D7 root cause is not `provider_ollama`.** The audit blamed the per-file `provider_ollama`
|
||||
> tag. Measurement (`04-test-gating.md` §2) shows `not network` deselects all seven tests **on its
|
||||
> own**, because the plugin implies `network` from *any* provider marker and the file also matches
|
||||
> `provider_openai`. **Removing `not provider_ollama` from the workflows would change nothing.**
|
||||
> That is why order `04` touches no YAML at all — the fix is marker-side.
|
||||
> Its first draft told an agent to "wire
|
||||
> the twelve dead adapter methods". Measurement (`06-adapter-revival.md` §1) says the opposite:
|
||||
> **12 of 20 items should be deleted, only 3 wired.** Six of the dead methods have no inline
|
||||
> equivalent anywhere — they are speculative API, and an agent told to "wire them up" would
|
||||
> invent an implementation that no test could ever have failed against. Read the decision table
|
||||
> before touching `llm/adapters/`; do not act on the summary in §1 of this file.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this work exists
|
||||
|
||||
Every local runtime worth supporting already speaks `POST /v1/chat/completions`, and
|
||||
`litellm` already handles that transport. So the problem is not calling local models — the
|
||||
SDK can already do that. The problems are:
|
||||
|
||||
- **Discovery.** A user with a running daemon still hand-types a URL, a port and an exact
|
||||
model tag; getting any one wrong produces `OPENAI_API_KEY environment variable is required`.
|
||||
- **Capability.** "Does this model support tools?" is answerable by asking the server
|
||||
(`/api/show`, `/props`, `/server_info`). The SDK asks nothing and guesses from a name prefix.
|
||||
- **Quirks.** Local stacks fail silently with HTTP 200. The sharpest: sending `format` and
|
||||
`tools` together to Ollama suppresses the tool call and the model fabricates an answer.
|
||||
|
||||
The layer being built is therefore a **resolver, not a transport**: it returns data about
|
||||
what is running and what it will get wrong. Compensating behaviour stays in
|
||||
`llm/adapters/`, which already declares methods for exactly this purpose.
|
||||
|
||||
---
|
||||
|
||||
## 2. Invariants — never break these
|
||||
|
||||
These come from `src/praisonai-agents/AGENTS.md` §4.6 plus the structural review in
|
||||
`00-ground-truth.md`. Every work order inherits them; none may waive them.
|
||||
|
||||
| # | Invariant | Why it binds this work specifically |
|
||||
|---|-----------|--------------------------------------|
|
||||
| I1 | **Backward compatible.** Public API changes need a deprecation cycle. | Several fixes change which code path a given input takes. Each order carries an explicit list of inputs whose behaviour must not change. |
|
||||
| I2 | **Lazy imports.** Optional deps never imported at module level. | Discovery must not import `litellm` or any runtime SDK at import time. |
|
||||
| I3 | **Safe defaults.** New features are opt-in. | Discovery must not fire unless the model spec is `"local"` or unset. Never probe the network on a fully-specified config. |
|
||||
| I4 | **Deterministic tests.** No dependence on timing or external state. | Every test must pass with **no server running**. Live-server tests go behind an explicit marker and never run in the default suite. |
|
||||
| I5 | **Protocol-driven core.** No heavy implementations in core. | `local/` returns dataclasses. It performs no chat calls, no retry, no request mutation. |
|
||||
| I6 | **`local/` is a dependency sink.** | It may import the **standard library only** — nothing from `praisonaiagents`, and no third-party distribution (`httpx`/`requests`/`aiohttp` are forbidden; use `urllib.request`). Enforced by a test. See §5 and `07` §3. |
|
||||
| I7 | **No second abstraction.** | `llm/adapters/` is the extension point. Do not add a parallel quirk/adapter registry beside it. |
|
||||
| I8 | **No new public knobs without a live consumer.** | Root `AGENTS.md`: do not add params, modules or exports that merely duplicate existing behaviour. |
|
||||
|
||||
### I6 in detail — the import rule
|
||||
|
||||
An AST pass over `praisonaiagents/` (595 files, 238,936 lines) found **65 subpackages, 212
|
||||
directed edges and 26 mutually-importing subpackage pairs**. `llm/` already imports upward
|
||||
into `agent/`, and 15 subpackages import `llm/` back. Consequently a shared helper placed in
|
||||
`llm/` **cannot** be adopted by `memory/` or `eval/` without deepening the existing cycles.
|
||||
|
||||
`local/` is therefore specified as a leaf. That single property is what allows the 21
|
||||
modules which currently bypass the LLM layer to adopt it later. Treat any import of
|
||||
`praisonaiagents.*` from inside `local/` as a build break, not a style issue.
|
||||
|
||||
The HTTP client is stdlib `urllib.request`, **not** `httpx`. An earlier draft of this ledger
|
||||
permitted `httpx` on the grounds that it is a transitive dependency of `openai>=2.0.0`. That
|
||||
was reversed in `07` §3: the edge is undeclared in `pyproject.toml`, so a future `openai`
|
||||
release that drops `httpx` would silently break the sink — precisely the class of failure this
|
||||
package exists to catalogue. The boundary test in §5 hard-forbids `httpx`/`requests`/`aiohttp`
|
||||
so the decision cannot drift.
|
||||
|
||||
---
|
||||
|
||||
## 3. File ownership — collision avoidance
|
||||
|
||||
Two work orders that touch the same file **must not run concurrently**. This table is the
|
||||
authority. Before starting, check that no in-flight PR owns your files.
|
||||
|
||||
| Work order | Owns (may edit) | Must not edit |
|
||||
|---|---|---|
|
||||
| `01-d1-default-model` | `agent/agent.py` (branch chain only) | `llm/llm.py`, `llm/adapters/` |
|
||||
| `02-d3-provider-detection` | `llm/llm.py` (`_detect_provider`, `_is_ollama_provider` only) | `agent/agent.py`, `llm/adapters/` |
|
||||
| `03-d2-base-url-routing` | `agent/agent.py` (base_url branch), root `README.md` | `llm/llm.py`, `llm/adapters/` |
|
||||
| `04-test-gating` | `tests/_pytest_plugins/test_gating.py`, both `pytest.ini`, `tests/conftest.py`, `tests/integration/test_base_url_api_base_fix.py`, `tests/unit/cli/test_test_command.py` | any `praisonaiagents/` source; **`.github/workflows/**` — the `-m` expressions are not the bug** |
|
||||
| `05-live-ci-job` | `.github/workflows/test-optimized.yml`, `tests/integration/test_ollama_tool_calling_live.py` | any `praisonaiagents/` source; `test_gating.py` |
|
||||
| `06-adapter-revival` | `llm/adapters/__init__.py`, `llm/llm.py` (3 response paths) | `agent/agent.py` |
|
||||
| `07-local-package` | `praisonaiagents/local/**` (new), `src/praisonai/tests/unit/llm/local/**` (new), `pyproject.toml` extras | everything else |
|
||||
| `08-reachability` | `memory/`, `eval/`, `context/`, `lite/`, `telemetry/` | `llm/`, `agent/` |
|
||||
|
||||
### Enforced serialisation
|
||||
|
||||
- **`01` then `03`** — both edit `agent/agent.py`. Land `01` first.
|
||||
- **`02` then `06`** — both edit `llm/llm.py`. Land `02` first; it is far smaller.
|
||||
- **`04` then `05`** — before `04`, `-m provider_ollama` selects 32 tests across 5 files (only 7
|
||||
are Ollama tests), so `05` has no usable selector and is unbuildable.
|
||||
- **`04` and `07` are independent** of orders 01/02/03/06 and may run in parallel with them.
|
||||
- **`08` requires `07`** to have landed, because it consumes `local/`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Status
|
||||
|
||||
Update your row in the same commit as your change. One line only.
|
||||
|
||||
Additional PRs not in the original order list, found while executing it:
|
||||
D4 (local route prefixes) #4798, D5 (local embedding dimensions) #4802,
|
||||
D6 (registry accepts local providers) #4800.
|
||||
|
||||
**Three claims in this ledger were corrected by executing it.** Each is marked
|
||||
inline where it appears, but they are collected here because a reader who trusts
|
||||
the summary would otherwise carry the wrong version:
|
||||
|
||||
1. *"Wire the twelve dead adapter methods."* Measurement said the opposite:
|
||||
**12 of 20 items should be deleted, only 3 wired** (`06` §1). Six had no
|
||||
inline equivalent at all — an agent told to wire them would have invented
|
||||
behaviour no test could fail against.
|
||||
2. *"`provider_ollama` deselects the mocked base_url suite."* It does not.
|
||||
**`not network` deselects it on its own**, because the plugin implies
|
||||
`network` from any provider marker (`04` §1). Editing the workflow `-m`
|
||||
strings would have changed nothing.
|
||||
3. *"Running fully locally is unachievable, not merely unconfigured."* Not so:
|
||||
litellm and the openai SDK both honour `OPENAI_BASE_URL`, so the bypass sites
|
||||
**do** reach a local server. The blocker is the hardcoded model name they then
|
||||
ask for (`00` §5, `08`).
|
||||
|
||||
| Order | Title | Branch | PR | Status |
|
||||
|---|---|---|---|---|
|
||||
| 01 | D1 — provider-prefixed default model never reaches litellm | `fix/d1-default-provider-model` | #4795 | PR open |
|
||||
| 02 | D3 — provider over-detection from base_url | `fix/d3-provider-detection` | #4797 | PR open |
|
||||
| 03 | D2 — README recipe + local-endpoint diagnostic | `fix/d2-base-url-routing` | #4799 | PR open |
|
||||
| 04 | D7 — per-file provider marker deselects unrelated tests | `fix/d7-per-test-provider-markers` | #4801 | PR open |
|
||||
| 05 | Live local-model CI job | `feat/ci-ollama-live-job` | #4803 | PR open |
|
||||
| 06 | A1/A2 — make the provider adapter load-bearing (6 sub-steps) | 6 branches, stacked | #4804 #4806 #4808 #4809 #4810 #4811 | **complete — KNOWN_DEAD empty** |
|
||||
| 07 | `praisonaiagents/local/` — discovery and capability resolver | `feat/local-model-resolver` | #4807 | PR open |
|
||||
| 08 | A3 — auxiliary-model fallback configurable everywhere | `fix/reachability-local-endpoint` | #4812 | PR open (model half; parameter half deferred) |
|
||||
|
||||
---
|
||||
|
||||
## 5. The two tests that keep this work honest
|
||||
|
||||
Both are cheap, both are anti-regression, and both should land with their respective orders.
|
||||
|
||||
**Import boundary (`07`).** Asserts `local/` imports nothing from `praisonaiagents`. Without
|
||||
it, I6 decays silently and the leaf property — the whole reason the package can be adopted
|
||||
by `memory/` and `eval/` — is lost.
|
||||
|
||||
**Adapter reachability (`06`).** Asserts every method on `DefaultAdapter` has at least one
|
||||
call site. This is the test whose absence allowed 11 of 17 adapter methods to become dead code
|
||||
while `llm.py` grew 145 hand-written Ollama references. Two limits, both real: it cannot detect
|
||||
an *unreachable* call site (proven by `should_skip_streaming_with_tools`, which a caller would
|
||||
have satisfied while the branch stayed dead), and **one call site satisfies it — so it does not
|
||||
detect the three-path divergence at all.** A floor, not a ceiling.
|
||||
|
||||
---
|
||||
|
||||
## 6. Deliberately out of scope
|
||||
|
||||
Named here so that deferring them is a decision rather than an oversight. Do not let a work
|
||||
order quietly grow into one of these.
|
||||
|
||||
| Deferred | Measured size | Why not now |
|
||||
|---|---|---|
|
||||
| Consolidating `LLM` and `OpenAIClient` behind one protocol | 7,160 + 2,691 lines, selected by one boolean at 6 sites | Neither is a superset of the other; the merge is larger than every order in this ledger combined. |
|
||||
| Decomposing `LLM.get_response` | 1,675 lines, 194 branch/loop/try nodes | Highest-risk file in the package. Order `06` must edit it surgically without reflowing it. |
|
||||
| Removing the sync/async divergence | ~1,628 duplicated lines across 10 pairs; `get_response` vs `_async` only 22.3% similar | Requires the consolidation above to be worth doing. |
|
||||
|
||||
The one obligation this ledger accepts toward them: **do not make them worse.** No order may
|
||||
add a fifth copy of any compensation, and no order may add new logic inside `get_response`
|
||||
that is not a delegation to an adapter.
|
||||
@@ -133,13 +133,6 @@ _LAZY_IMPORTS = {
|
||||
'resolve_harness': ('praisonaiagents.model_harness.profiles', 'resolve_harness'),
|
||||
'register_profile': ('praisonaiagents.model_harness.profiles', 'register_profile'),
|
||||
|
||||
# Test doubles - drive an Agent offline and keep real requests out of a suite
|
||||
'ScriptedModel': ('praisonaiagents.model_harness.scripted', 'ScriptedModel'),
|
||||
'ScriptExhausted': ('praisonaiagents.model_harness.scripted', 'ScriptExhausted'),
|
||||
'allow_model_requests': ('praisonaiagents.model_harness.guard', 'allow_model_requests'),
|
||||
'no_model_requests': ('praisonaiagents.model_harness.guard', 'no_model_requests'),
|
||||
'ModelRequestBlocked': ('praisonaiagents.model_harness.guard', 'ModelRequestBlocked'),
|
||||
|
||||
# Run outcomes - typed validation and agent execution results
|
||||
'AgentRunOutcome': ('praisonaiagents.run_outcome', 'AgentRunOutcome'),
|
||||
'RunStatus': ('praisonaiagents.run_outcome', 'RunStatus'),
|
||||
@@ -227,7 +220,6 @@ _LAZY_IMPORTS = {
|
||||
# Guardrails
|
||||
'GuardrailResult': ('praisonaiagents.guardrails', 'GuardrailResult'),
|
||||
'LLMGuardrail': ('praisonaiagents.guardrails', 'LLMGuardrail'),
|
||||
'GuardrailRetry': ('praisonaiagents.guardrails', 'GuardrailRetry'),
|
||||
|
||||
# Approval (agent-centric approval backends)
|
||||
'AutoApproveBackend': ('praisonaiagents.approval.backends', 'AutoApproveBackend'),
|
||||
|
||||
@@ -215,14 +215,6 @@ def __getattr__(name):
|
||||
from .memory_mixin import MemoryMixin
|
||||
_lazy_cache[name] = MemoryMixin
|
||||
return MemoryMixin
|
||||
elif name == 'AttachmentError':
|
||||
from .attachments import AttachmentError
|
||||
_lazy_cache[name] = AttachmentError
|
||||
return AttachmentError
|
||||
elif name == 'build_attachment_parts':
|
||||
from .attachments import build_attachment_parts
|
||||
_lazy_cache[name] = build_attachment_parts
|
||||
return build_attachment_parts
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -305,7 +297,4 @@ __all__ = [
|
||||
'ChatMixin',
|
||||
'ExecutionMixin',
|
||||
'MemoryMixin',
|
||||
# Attachments
|
||||
'AttachmentError',
|
||||
'build_attachment_parts',
|
||||
]
|
||||
@@ -1,5 +1,4 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
@@ -9,7 +8,6 @@ import contextlib
|
||||
import threading
|
||||
import concurrent.futures
|
||||
import random
|
||||
import re
|
||||
from typing import List, Optional, Any, Dict, Union, Literal, TYPE_CHECKING, Callable, Generator
|
||||
from collections import OrderedDict
|
||||
import inspect
|
||||
@@ -222,28 +220,6 @@ from ..errors import BudgetExceededError
|
||||
# Import retry configuration
|
||||
from .retry_utils import RetryBackoffConfig
|
||||
|
||||
# Completion-signal detection patterns (compiled once at import — see
|
||||
# Agent._is_completion_signal). Negation patterns that should NOT be treated
|
||||
# as completion.
|
||||
_COMPLETION_NEGATION_RE = re.compile(
|
||||
r'\b(?:not|never|no longer|hardly|barely|isn\'t|aren\'t|wasn\'t|weren\'t|hasn\'t|haven\'t|hadn\'t|won\'t|wouldn\'t|can\'t|couldn\'t|shouldn\'t|don\'t|doesn\'t|didn\'t)\b'
|
||||
r'.{0,20}' # up to 20 chars between negation and keyword
|
||||
)
|
||||
# Word-boundary patterns to avoid substring false positives
|
||||
_COMPLETION_PATTERNS = (
|
||||
(re.compile(r'\btask\s+completed?\b'), False), # no negation check needed
|
||||
(re.compile(r'\bcompleted\s+successfully\b'), False),
|
||||
(re.compile(r'\ball\s+done\b'), False),
|
||||
(re.compile(r'\bdone\b'), True), # 'done' needs negation check
|
||||
(re.compile(r'\bfinished\b'), True), # 'finished' needs negation check
|
||||
)
|
||||
|
||||
# Emitted at most once per process: pointing at a non-OpenAI endpoint without
|
||||
# naming a model silently falls back to OpenAI's default, which that endpoint
|
||||
# almost certainly does not serve.
|
||||
_LOCAL_ENDPOINT_DEFAULT_MODEL_WARNED = False
|
||||
|
||||
|
||||
class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
|
||||
# Class-level counter for generating unique display names for nameless agents
|
||||
_agent_counter = 0
|
||||
@@ -308,15 +284,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
cls._env_output_checked = True
|
||||
return cls._env_output_mode
|
||||
|
||||
@staticmethod
|
||||
def _local_discovery(target):
|
||||
"""Re-read the discovery for a resolved local target (cached upstream)."""
|
||||
try:
|
||||
from ..local import probe_endpoint
|
||||
return probe_endpoint(target.base_url, expect=target.engine)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Ordered (credential env-var, provider-appropriate default model). The
|
||||
# first provider whose credential is present wins. OpenAI is first so
|
||||
# existing OpenAI users keep their default; any other single configured
|
||||
@@ -394,26 +361,9 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
self._llm_init_params = params
|
||||
self._using_custom_llm = True
|
||||
|
||||
def _wire_deferred_history_callback(self):
|
||||
"""Point the LLM instance's deferred re-injection at the Agent's own history.
|
||||
|
||||
The LLM's own ``chat_history`` list has no readers when driven by an
|
||||
Agent (Agent passes its separately-maintained history as a parameter),
|
||||
so a resolved ``defer(...)`` result appended there would be lost. Wiring
|
||||
the Agent's thread-safe append here lets background-completed deferred
|
||||
tool results reach the next turn's context.
|
||||
"""
|
||||
inst = self._llm_instance
|
||||
if inst is not None:
|
||||
try:
|
||||
inst._agent_history_append = self._append_to_chat_history
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _ensure_llm_instance(self):
|
||||
"""Lazy-create LLM instance from deferred init params (avoids import at Agent())."""
|
||||
if self._llm_instance is not None:
|
||||
self._wire_deferred_history_callback()
|
||||
return self._llm_instance
|
||||
if self._panel_descriptor is not None:
|
||||
try:
|
||||
@@ -430,7 +380,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
"LLM features requested but dependencies not installed. "
|
||||
"Please install with: pip install \"praisonaiagents[llm]\""
|
||||
) from e
|
||||
self._wire_deferred_history_callback()
|
||||
return self._llm_instance
|
||||
if self._llm_init_params:
|
||||
try:
|
||||
@@ -441,7 +390,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
"LLM features requested but dependencies not installed. "
|
||||
"Please install with: pip install \"praisonaiagents[llm]\""
|
||||
) from e
|
||||
self._wire_deferred_history_callback()
|
||||
return self._llm_instance
|
||||
|
||||
@property
|
||||
@@ -453,7 +401,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
self._llm_instance = value
|
||||
if value is not None:
|
||||
self._llm_init_params = None
|
||||
self._wire_deferred_history_callback()
|
||||
|
||||
def _ensure_loop_guard(self):
|
||||
"""Lazy-create loop guard on first tool execution."""
|
||||
@@ -741,12 +688,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
- bool: True enables with defaults
|
||||
- Callable: Validation function
|
||||
- GuardrailConfig: Custom configuration
|
||||
A validator rejects an answer by returning ``(False, "why")`` or
|
||||
by raising ``GuardrailRetry("why")``. Either way the reason is
|
||||
appended to the same conversation and the model gets another
|
||||
attempt in the same run, up to
|
||||
``GuardrailConfig(max_retries=...)`` (default 3); see
|
||||
``guardrail_retry_count``.
|
||||
web: Web search/fetch. Accepts:
|
||||
- bool: True enables with defaults
|
||||
- WebConfig: Custom configuration
|
||||
@@ -1465,16 +1406,7 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
elif memory_backend == "file":
|
||||
memory = True
|
||||
elif _memory_config.config:
|
||||
# Carry the backend into the dict. _init_memory reads the
|
||||
# provider as memory.get("provider", memory.get("backend",
|
||||
# "file")), so handing it the bare config lost the backend
|
||||
# entirely and silently fell back to FileMemory --
|
||||
# MemoryConfig(backend="sqlite", config={...}) built a
|
||||
# FileMemory while to_dict() went on reporting "sqlite".
|
||||
# An explicit provider/backend inside config still wins.
|
||||
memory = dict(_memory_config.config)
|
||||
if "provider" not in memory and "backend" not in memory:
|
||||
memory["provider"] = memory_backend
|
||||
memory = _memory_config.config
|
||||
else:
|
||||
memory = memory_backend
|
||||
elif hasattr(_memory_config, 'search') and hasattr(_memory_config, 'add'):
|
||||
@@ -1497,18 +1429,9 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
if learn is False:
|
||||
_learn_config = None
|
||||
elif learn is True:
|
||||
# learn=True is a coordinated posture (Issue #4864): enable
|
||||
# AGENTIC extraction AND a conversational-aware nudge cadence in
|
||||
# one switch, so an always-on companion bot actually learns.
|
||||
# nudge_min_tool_iters=0 lets a chat-only turn (no tool calls)
|
||||
# still nudge; auto_memory is turned on below so durable facts
|
||||
# are captured on every turn, not just tool-using ones.
|
||||
# learn=True shorthand enables AGENTIC mode (auto-learning)
|
||||
from ..memory.learn.protocols import LearnMode
|
||||
_learn_config = LearnConfig(
|
||||
mode=LearnMode.AGENTIC,
|
||||
nudge_interval=10,
|
||||
nudge_min_tool_iters=0,
|
||||
)
|
||||
_learn_config = LearnConfig(mode=LearnMode.AGENTIC)
|
||||
elif isinstance(learn, LearnConfig):
|
||||
_learn_config = learn
|
||||
elif isinstance(learn, dict):
|
||||
@@ -1555,14 +1478,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
elif _learn_config is not None and isinstance(memory, dict):
|
||||
# Merge learn config into memory dict
|
||||
memory["learn"] = _learn_config.to_dict() if hasattr(_learn_config, 'to_dict') else _learn_config
|
||||
|
||||
# Coordinated learn posture (Issue #4864): the ``learn=True`` shorthand
|
||||
# turns on conversational fact extraction too, so an always-on companion
|
||||
# accrues durable persona/preferences from plain chat — not just from
|
||||
# tool-using turns. Only defaults it on when the caller did not set
|
||||
# auto_memory explicitly (via MemoryConfig), so an explicit choice wins.
|
||||
if learn is True and auto_memory is None:
|
||||
auto_memory = True
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Resolve HISTORY from MemoryConfig - FAST PATH
|
||||
@@ -2192,58 +2107,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
if reasoning_effort is not None:
|
||||
self._llm_option_kwargs['reasoning_effort'] = reasoning_effort
|
||||
|
||||
# Local-runtime descriptor: llm="local" (or "local:<engine>/<model>").
|
||||
# Opt-in only -- discovery never fires for any other spec, so a fully
|
||||
# configured agent pays nothing. Resolved here into a concrete
|
||||
# provider/model so the normal LLM path handles it unchanged.
|
||||
if isinstance(llm, str) and (llm == "local" or llm.startswith("local:")):
|
||||
from ..local import LocalEngine as _LocalEngine
|
||||
from ..local import resolve as _resolve_local
|
||||
_spec = llm[len("local:"):] if llm.startswith("local:") else None
|
||||
_target = _resolve_local(_spec or None)
|
||||
llm = _target.litellm_model
|
||||
if base_url is None:
|
||||
# Ollama's litellm provider builds its own /api paths from the
|
||||
# server root, so it takes base_url. Every other local engine
|
||||
# speaks OpenAI and serves under /v1 -- handing it the bare root
|
||||
# sent chat to /chat/completions, which a real LM Studio, vLLM
|
||||
# or llama-server answers with 404.
|
||||
base_url = (_target.base_url
|
||||
if _target.engine is _LocalEngine.OLLAMA
|
||||
else _target.openai_base_url)
|
||||
if api_key is None:
|
||||
api_key = _target.api_key
|
||||
self._local_target = _target
|
||||
|
||||
# Keep a local agent local end to end. Without this, chat went to
|
||||
# the local server while knowledge/memory embedded against OpenAI --
|
||||
# sending the user's documents AND their queries off the machine
|
||||
# with no warning, which defeats the entire point of asking for a
|
||||
# local model. Only fills a gap: an explicit embedder always wins.
|
||||
_wants_embeddings = (retrieval_config is not None
|
||||
or embedder_config is not None
|
||||
or memory not in (None, False))
|
||||
if _wants_embeddings and not embedder_config and not (
|
||||
retrieval_config or {}).get('embedder_config'):
|
||||
from ..local import (local_embedder_config as _local_embedder,
|
||||
select_embedding_model as _select_embed)
|
||||
_embed_model = _select_embed(
|
||||
getattr(_d, 'models', ()) if (_d := Agent._local_discovery(_target)) else (),
|
||||
getattr(_d, 'model_meta', ()) if _d else ())
|
||||
if _embed_model:
|
||||
embedder_config = _local_embedder(
|
||||
_target.engine, _target.base_url, _embed_model)
|
||||
if retrieval_config is not None:
|
||||
retrieval_config.setdefault('embedder_config', embedder_config)
|
||||
else:
|
||||
logging.warning(
|
||||
"llm=%r resolved a local model, but %s serves no embedding "
|
||||
"model, so knowledge/memory would embed against a remote "
|
||||
"provider -- sending your documents off this machine. "
|
||||
"Pull one (e.g. `ollama pull nomic-embed-text`) or set an "
|
||||
"explicit embedder to silence this.",
|
||||
llm, _target.base_url)
|
||||
|
||||
# Panel (multi-model) descriptor: "panel:<name>" or {"provider": "panel"}.
|
||||
# Resolved lazily into a PanelLLM; composes with the normal tool loop.
|
||||
# Detected inline (no heavy import) to keep Agent() construction lazy.
|
||||
@@ -2251,33 +2114,7 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
(isinstance(llm, str) and llm.startswith("panel:"))
|
||||
or (isinstance(llm, dict) and llm.get("provider") == "panel")
|
||||
)
|
||||
# An already-built model object (an ``LLM``, a subclass, or a test double
|
||||
# such as ``model_harness.ScriptedModel``) is adopted as this agent's
|
||||
# backend. Without this branch such an object fell through to the plain
|
||||
# OpenAI path, where ``self.llm`` became the object itself and every turn
|
||||
# was sent to OpenAI under a model name that was really a repr -- the
|
||||
# opposite of what passing your own model means. Duck-typed on
|
||||
# ``get_response`` so any conforming backend works, not just ``LLM``.
|
||||
# Duck-typed across every backend surface this package documents, not
|
||||
# just `get_response`: llm/protocols.py defines LLMProviderProtocol via
|
||||
# `chat`/`achat` and UnifiedLLMProtocol via
|
||||
# `chat_completion`/`achat_completion`. Checking only `get_response`
|
||||
# left those backends falling through to the plain OpenAI branch, where
|
||||
# the object became the model identifier -- the same defect this branch
|
||||
# exists to fix, just for a different protocol.
|
||||
_MODEL_BACKEND_METHODS = (
|
||||
"get_response", "chat", "achat", "chat_completion", "achat_completion",
|
||||
)
|
||||
_is_model_instance = (
|
||||
llm is not None
|
||||
and not isinstance(llm, (str, dict))
|
||||
and any(callable(getattr(llm, m, None)) for m in _MODEL_BACKEND_METHODS)
|
||||
)
|
||||
if _is_model_instance:
|
||||
self._llm_instance = llm
|
||||
self._using_custom_llm = True
|
||||
self.llm = getattr(llm, "model", None) or type(llm).__name__
|
||||
elif _is_panel:
|
||||
if _is_panel:
|
||||
self._panel_descriptor = llm
|
||||
self._using_custom_llm = True
|
||||
self.llm = llm if isinstance(llm, str) else "panel"
|
||||
@@ -2371,57 +2208,14 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
# Otherwise, fall back to OpenAI environment/name (cached for performance)
|
||||
else:
|
||||
model_name = llm or Agent._get_default_model()
|
||||
# Pointing at a non-OpenAI endpoint without naming a model leaves the
|
||||
# OpenAI default in place, so the endpoint is asked for a model it
|
||||
# has never heard of -- Ollama answers 404 "model 'gpt-4o-mini' not
|
||||
# found", which reads like an SDK bug rather than a missing line of
|
||||
# config. Warn, don't raise: an OpenAI-compatible proxy may legitimately
|
||||
# serve that exact name.
|
||||
#
|
||||
# Only fire when the resolved default really is the OpenAI default:
|
||||
# a provider credential (e.g. OLLAMA_HOST) resolves an
|
||||
# already-correct provider-prefixed model, so there is nothing to
|
||||
# warn about. Read the endpoint in the order the OpenAI client uses
|
||||
# (OPENAI_API_BASE before OPENAI_BASE_URL, see openai_client.py) so
|
||||
# the message names the endpoint the request actually goes to.
|
||||
if (
|
||||
llm is None
|
||||
and model_name == 'gpt-4o-mini'
|
||||
and not os.getenv('OPENAI_MODEL_NAME')
|
||||
):
|
||||
_endpoint = os.getenv('OPENAI_API_BASE') or os.getenv('OPENAI_BASE_URL')
|
||||
if _endpoint and 'api.openai.com' not in _endpoint:
|
||||
global _LOCAL_ENDPOINT_DEFAULT_MODEL_WARNED
|
||||
if not _LOCAL_ENDPOINT_DEFAULT_MODEL_WARNED:
|
||||
with Agent._env_cache_lock:
|
||||
if not _LOCAL_ENDPOINT_DEFAULT_MODEL_WARNED:
|
||||
_LOCAL_ENDPOINT_DEFAULT_MODEL_WARNED = True
|
||||
logging.warning(
|
||||
"No model specified, so the OpenAI default %r will be sent to %s. "
|
||||
"That endpoint probably does not serve it. Set OPENAI_MODEL_NAME "
|
||||
"to a model it does serve (e.g. OPENAI_MODEL_NAME=ollama/llama3.2), "
|
||||
"or pass llm= explicitly.",
|
||||
model_name, _endpoint,
|
||||
)
|
||||
# A provider-prefixed model must be built as an LLM instance, which
|
||||
# owns provider routing and the per-provider adapters. The
|
||||
# `"/" in llm` branch above only inspects the explicit `llm=`
|
||||
# argument, so a prefixed model that arrived from a credential env
|
||||
# var (_PROVIDER_DEFAULT_MODELS) or from OPENAI_MODEL_NAME lands
|
||||
# here instead and would otherwise fall through to the plain OpenAI
|
||||
# client holding a model name OpenAI has never heard of. Re-check
|
||||
# the *resolved* name so `llm="ollama/x"` and OLLAMA_HOST agree.
|
||||
_has_provider_prefix = isinstance(model_name, str) and "/" in model_name
|
||||
if auth or _has_provider_prefix:
|
||||
if auth:
|
||||
# A subscription auth provider only takes effect inside LLM
|
||||
# (which injects the resolved OAuth credentials), so a bare
|
||||
# model name plus auth= must still build an LLM instance rather
|
||||
# than falling through to the plain OpenAI client.
|
||||
llm_params = {'model': model_name}
|
||||
llm_params = {'model': model_name, 'auth': auth}
|
||||
if api_key:
|
||||
llm_params['api_key'] = api_key
|
||||
if auth:
|
||||
llm_params['auth'] = auth
|
||||
llm_params['metrics'] = metrics
|
||||
llm_params['web_search'] = web_search
|
||||
llm_params['web_fetch'] = web_fetch
|
||||
@@ -2644,10 +2438,6 @@ Your Goal: {self.goal}
|
||||
# Initialize guardrail settings
|
||||
self.guardrail = guardrail
|
||||
self.max_guardrail_retries = max_guardrail_retries
|
||||
# Observability for in-run output-validation retries (see
|
||||
# _apply_guardrail_with_retry / the guardrail_retry_count property).
|
||||
self._guardrail_retry_count = 0
|
||||
self._last_guardrail_error = None
|
||||
self._guardrail_fn = None
|
||||
self._setup_guardrail()
|
||||
|
||||
@@ -2978,10 +2768,6 @@ Your Goal: {self.goal}
|
||||
# Learning configuration (top-level, peer to memory)
|
||||
# Stored for access via agent._learn_config
|
||||
self._learn_config = _learn_config
|
||||
# Raw learn= intent, so surfaces that default-enable learning (e.g. the
|
||||
# gateway/bot) can tell "unset" (None) apart from an explicit opt-out
|
||||
# (learn=False) — both of which leave _learn_config None (Issue #4864).
|
||||
self._learn_enabled = learn
|
||||
|
||||
# Agent-centric feature instances (lazy loaded for zero performance impact)
|
||||
self._auto_memory = auto_memory
|
||||
@@ -3400,21 +3186,9 @@ Your Goal: {self.goal}
|
||||
return self.__chat_history_state
|
||||
|
||||
def _append_to_chat_history(self, message: dict):
|
||||
"""Thread-safe append to chat history using proper locking.
|
||||
|
||||
Also records the appended message into the current turn's per-turn
|
||||
ownership tracker (when one is active) so a failing turn's rollback
|
||||
removes only its own messages by identity and never a concurrent
|
||||
turn's. This is the append path every real chat()/achat() turn uses,
|
||||
so without recording here _rollback_chat_history_to would always take
|
||||
the unsafe positional-delete branch.
|
||||
"""
|
||||
from .memory_mixin import _active_turn_owned
|
||||
"""Thread-safe append to chat history using proper locking."""
|
||||
with self._history_lock.lock():
|
||||
self._history_lock.value.append(message)
|
||||
owned = _active_turn_owned.get()
|
||||
if owned is not None:
|
||||
owned.append(message)
|
||||
|
||||
def _replace_chat_history(self, new_history: List[Dict[str, Any]]):
|
||||
"""Thread-safe replacement of entire chat history using proper locking."""
|
||||
@@ -4405,7 +4179,21 @@ Summary:"""
|
||||
Returns:
|
||||
True if a completion signal is detected
|
||||
"""
|
||||
import re
|
||||
response_lower = response_text.lower()
|
||||
# Negation patterns that should NOT be treated as completion
|
||||
_NEGATION_RE = re.compile(
|
||||
r'\b(?:not|never|no longer|hardly|barely|isn\'t|aren\'t|wasn\'t|weren\'t|hasn\'t|haven\'t|hadn\'t|won\'t|wouldn\'t|can\'t|couldn\'t|shouldn\'t|don\'t|doesn\'t|didn\'t)\b'
|
||||
r'.{0,20}' # up to 20 chars between negation and keyword
|
||||
)
|
||||
# Word-boundary patterns to avoid substring false positives
|
||||
_COMPLETION_PATTERNS = [
|
||||
(re.compile(r'\btask\s+completed?\b'), False), # no negation check needed
|
||||
(re.compile(r'\bcompleted\s+successfully\b'), False),
|
||||
(re.compile(r'\ball\s+done\b'), False),
|
||||
(re.compile(r'\bdone\b'), True), # 'done' needs negation check
|
||||
(re.compile(r'\bfinished\b'), True), # 'finished' needs negation check
|
||||
]
|
||||
for pattern, needs_negation_check in _COMPLETION_PATTERNS:
|
||||
match = pattern.search(response_lower)
|
||||
if match:
|
||||
@@ -4413,7 +4201,7 @@ Summary:"""
|
||||
# Check if a negation word precedes the match within 30 chars
|
||||
start = max(0, match.start() - 30)
|
||||
prefix = response_lower[start:match.start()]
|
||||
if _COMPLETION_NEGATION_RE.search(prefix):
|
||||
if _NEGATION_RE.search(prefix):
|
||||
continue # Skip — negated completion
|
||||
# Also check "not X yet" pattern
|
||||
end = min(len(response_lower), match.end() + 10)
|
||||
@@ -6756,9 +6544,6 @@ Answer:"""
|
||||
# ``self._tool_call_guardrails`` but nothing ever assigned it, so the
|
||||
# whole ``validate_tool_call`` surface was dead code. Always define it.
|
||||
self._tool_call_guardrails = []
|
||||
# ``tool_execution._apply_tool_result_guardrails`` reads this to gate a
|
||||
# tool's raw output before it re-enters the LLM context. Always define it.
|
||||
self._tool_result_guardrails = []
|
||||
if self.guardrail is None:
|
||||
self._guardrail_fn = None
|
||||
return
|
||||
@@ -6835,17 +6620,6 @@ Answer:"""
|
||||
):
|
||||
self._tool_call_guardrails = [_fn]
|
||||
|
||||
# Register the tool-result surface the same way. Output-only (string/LLM)
|
||||
# guardrails are excluded on purpose: running an LLM validation after
|
||||
# every tool result would be a hot-path regression, mirroring the
|
||||
# tool-call exclusion above.
|
||||
if (
|
||||
_fn is not None
|
||||
and callable(getattr(_fn, "validate_tool_result", None))
|
||||
and not getattr(_fn, "_praison_output_only", False)
|
||||
):
|
||||
self._tool_result_guardrails = [_fn]
|
||||
|
||||
def _process_handoffs(self):
|
||||
"""Process handoffs and convert them to tools that can be used by the agent."""
|
||||
# Import here to avoid circular imports
|
||||
@@ -6898,22 +6672,6 @@ Answer:"""
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to process handoff item {handoff_item}: {e}")
|
||||
|
||||
@property
|
||||
def guardrail_retry_count(self) -> int:
|
||||
"""How many times output validation has sent the model back for a retry.
|
||||
|
||||
Cumulative over the agent's lifetime, incremented once per rejected
|
||||
answer that was handed back to the model (see
|
||||
``_apply_guardrail_with_retry``). Read it to confirm a run converged in
|
||||
one correction rather than burning ``max_guardrail_retries``.
|
||||
"""
|
||||
return getattr(self, "_guardrail_retry_count", 0)
|
||||
|
||||
@property
|
||||
def last_guardrail_error(self):
|
||||
"""The most recent rejection message a guardrail sent back to the model."""
|
||||
return getattr(self, "_last_guardrail_error", None)
|
||||
|
||||
def _process_guardrail(self, task_output):
|
||||
"""Process the guardrail validation for a task output.
|
||||
|
||||
@@ -6923,26 +6681,18 @@ Answer:"""
|
||||
Returns:
|
||||
GuardrailResult: The result of the guardrail validation
|
||||
"""
|
||||
from ..guardrails import GuardrailResult, GuardrailRetry
|
||||
|
||||
from ..guardrails import GuardrailResult
|
||||
|
||||
if not self._guardrail_fn:
|
||||
return GuardrailResult(success=True, result=task_output)
|
||||
|
||||
|
||||
try:
|
||||
# Call the guardrail function
|
||||
result = self._guardrail_fn(task_output)
|
||||
|
||||
|
||||
# Convert the result to a GuardrailResult
|
||||
return GuardrailResult.from_tuple(result)
|
||||
|
||||
except GuardrailRetry as e:
|
||||
# Deliberate rejection with a message meant for the model. Keep the
|
||||
# author's wording verbatim - it is fed back into the conversation
|
||||
# by _apply_guardrail_with_retry - rather than wrapping it as an
|
||||
# internal validation *error*.
|
||||
logging.warning(f"Agent {self.name}: Guardrail asked for a retry: {e.feedback}")
|
||||
return GuardrailResult(success=False, result=None, error=e.feedback)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Error in guardrail validation: {e}")
|
||||
# On error, return failure
|
||||
@@ -7005,133 +6755,48 @@ Answer:"""
|
||||
else:
|
||||
return False, None, guardrail_result.error
|
||||
|
||||
def _guardrail_retry_feedback(self, error):
|
||||
"""The correction turn handed back to the model after a rejection.
|
||||
|
||||
Shared by the in-conversation retry and the legacy single-prompt
|
||||
fallback so the instruction reads the same whichever transport the
|
||||
configured provider uses.
|
||||
"""
|
||||
return (
|
||||
f"Previous response failed validation due to: {error}. "
|
||||
"Please provide an improved response."
|
||||
)
|
||||
|
||||
def _extend_guardrail_conversation(self, conversation, response_text, feedback):
|
||||
"""Append the rejected answer and the validator's reason to ``conversation``.
|
||||
|
||||
This is what makes the correction happen *inside* the same run: the
|
||||
model sees its own answer plus why it was refused, so it patches that
|
||||
answer instead of re-deriving one from the bare prompt. Extends (and
|
||||
returns) the list in place, so successive retries accumulate the way a
|
||||
real conversation does.
|
||||
|
||||
The assistant turn is skipped when the caller's conversation already
|
||||
ends with it, which is the case for callers that hand over live chat
|
||||
history rather than the pre-call message list.
|
||||
"""
|
||||
last = conversation[-1] if conversation else None
|
||||
already_present = (
|
||||
isinstance(last, dict)
|
||||
and last.get("role") == "assistant"
|
||||
and last.get("content") == response_text
|
||||
)
|
||||
if response_text and not already_present:
|
||||
conversation.append({"role": "assistant", "content": response_text})
|
||||
conversation.append({"role": "user", "content": feedback})
|
||||
return conversation
|
||||
|
||||
def _record_guardrail_retry(self, *, attempt, error, delay):
|
||||
"""Count and announce one guardrail retry.
|
||||
|
||||
``guardrail_retry_count`` makes the retries observable to callers and
|
||||
tests; the RETRY stream event puts them on the same channel as
|
||||
transient-failure retries, so a CLI/UI shows "retrying" rather than
|
||||
appearing to hang.
|
||||
"""
|
||||
self._guardrail_retry_count = getattr(self, "_guardrail_retry_count", 0) + 1
|
||||
self._last_guardrail_error = error
|
||||
emit = getattr(self, "_emit_retry_stream_event", None)
|
||||
if emit is not None:
|
||||
emit(
|
||||
attempt=attempt,
|
||||
max_attempts=self.max_guardrail_retries,
|
||||
delay=delay,
|
||||
reason=f"guardrail validation failed: {error}",
|
||||
)
|
||||
|
||||
def _guardrail_retry_delay(self, retry_count):
|
||||
"""Backoff before the next guardrail retry (agent execution policy)."""
|
||||
execution_config = getattr(self, '_execution_config', None)
|
||||
if execution_config is not None:
|
||||
return BackoffPolicy.delay(
|
||||
retry_count,
|
||||
execution_config.retry_initial_delay,
|
||||
execution_config.retry_backoff_factor,
|
||||
execution_config.retry_jitter
|
||||
)
|
||||
# Fall back to simple backoff if no execution config
|
||||
return 1.0 * (2.0 ** (retry_count - 1))
|
||||
|
||||
def _apply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None, cancel_token=None, messages=None):
|
||||
"""Apply guardrail validation with retry logic (sync version).
|
||||
|
||||
Args:
|
||||
response_text: The answer to validate.
|
||||
prompt: The originating prompt, used only by the fallback below.
|
||||
messages: The conversation that produced ``response_text`` (as sent
|
||||
to the model, without its reply). When supplied, a rejection is
|
||||
appended to *that* conversation - rejected answer plus the
|
||||
validator's reason - so the model corrects itself in the same
|
||||
run with its context intact. When omitted (providers whose
|
||||
message list the caller does not hold), the retry falls back to
|
||||
re-asking the original prompt with the reason appended.
|
||||
|
||||
Bounded by ``max_guardrail_retries``; a validator that never passes
|
||||
raises rather than looping.
|
||||
"""
|
||||
def _apply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None, cancel_token=None):
|
||||
"""Apply guardrail validation with retry logic (sync version)."""
|
||||
retry_count = 0
|
||||
current_response = response_text
|
||||
# Own copy: _chat_completion rewrites its messages list in place.
|
||||
conversation = list(messages) if messages else None
|
||||
|
||||
|
||||
while retry_count <= self.max_guardrail_retries:
|
||||
success, result, error = self._validate_with_guardrail(current_response)
|
||||
|
||||
|
||||
if success:
|
||||
logging.info(f"Agent {self.name}: Guardrail validation passed")
|
||||
return result
|
||||
|
||||
|
||||
# Guardrail failed
|
||||
if retry_count >= self.max_guardrail_retries:
|
||||
raise Exception(
|
||||
f"Agent {self.name} response failed guardrail validation after {self.max_guardrail_retries} retries. "
|
||||
f"Last error: {error}"
|
||||
)
|
||||
|
||||
|
||||
retry_count += 1
|
||||
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {error}")
|
||||
|
||||
|
||||
# Add exponential backoff delay to avoid hammering the LLM
|
||||
total_delay = self._guardrail_retry_delay(retry_count)
|
||||
self._record_guardrail_retry(attempt=retry_count, error=error, delay=total_delay)
|
||||
|
||||
execution_config = getattr(self, '_execution_config', None)
|
||||
if execution_config is not None:
|
||||
total_delay = BackoffPolicy.delay(
|
||||
retry_count,
|
||||
execution_config.retry_initial_delay,
|
||||
execution_config.retry_backoff_factor,
|
||||
execution_config.retry_jitter
|
||||
)
|
||||
else:
|
||||
# Fall back to simple backoff if no execution config
|
||||
total_delay = 1.0 * (2.0 ** (retry_count - 1))
|
||||
|
||||
logging.info(f"Agent {self.name}: Waiting {total_delay:.2f}s before guardrail retry")
|
||||
time.sleep(total_delay)
|
||||
|
||||
|
||||
# Regenerate response for retry
|
||||
try:
|
||||
feedback = self._guardrail_retry_feedback(error)
|
||||
if conversation is not None:
|
||||
retry_messages = self._extend_guardrail_conversation(
|
||||
conversation, current_response, feedback
|
||||
)
|
||||
else:
|
||||
retry_messages = [{"role": "user", "content": f"{prompt}\n\nNote: {feedback}"}]
|
||||
# stream=False: the retry reads a complete message and nothing
|
||||
# consumes deltas, so streaming here only costs a failed
|
||||
# sync-adapter attempt and an ERROR log before falling back.
|
||||
response = self._chat_completion(list(retry_messages), temperature, tools, stream=False, task_name=task_name, task_description=task_description, task_id=task_id, cancel_token=cancel_token)
|
||||
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {error}. Please provide an improved response."
|
||||
response = self._chat_completion([{"role": "user", "content": retry_prompt}], temperature, tools, task_name=task_name, task_description=task_description, task_id=task_id, cancel_token=cancel_token)
|
||||
if response and response.choices:
|
||||
content = response.choices[0].message.content
|
||||
current_response = content.strip() if content else ""
|
||||
@@ -7140,19 +6805,14 @@ Answer:"""
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Error during guardrail retry: {e}")
|
||||
raise Exception(f"Agent {self.name} guardrail retry failed: {e}")
|
||||
|
||||
|
||||
return current_response
|
||||
|
||||
async def _aapply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None, messages=None):
|
||||
"""Apply guardrail validation with retry logic (async version).
|
||||
|
||||
See ``_apply_guardrail_with_retry`` for the ``messages`` contract.
|
||||
"""
|
||||
async def _aapply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None):
|
||||
"""Apply guardrail validation with retry logic (async version)."""
|
||||
retry_count = 0
|
||||
current_response = response_text
|
||||
# Own copy: the completion path rewrites its messages list in place.
|
||||
conversation = list(messages) if messages else None
|
||||
|
||||
|
||||
while retry_count <= self.max_guardrail_retries:
|
||||
# A string/LLMGuardrail guardrail fires a *blocking* LLM call inside
|
||||
# _validate_with_guardrail. Offload it to a thread so it does not
|
||||
@@ -7171,42 +6831,41 @@ Answer:"""
|
||||
lambda: self._validate_with_guardrail(current_response)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if success:
|
||||
logging.info(f"Agent {self.name}: Guardrail validation passed")
|
||||
return result
|
||||
|
||||
|
||||
# Guardrail failed
|
||||
if retry_count >= self.max_guardrail_retries:
|
||||
raise Exception(
|
||||
f"Agent {self.name} response failed guardrail validation after {self.max_guardrail_retries} retries. "
|
||||
f"Last error: {error}"
|
||||
)
|
||||
|
||||
|
||||
retry_count += 1
|
||||
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {error}")
|
||||
|
||||
|
||||
# Add exponential backoff delay to avoid hammering the LLM
|
||||
total_delay = self._guardrail_retry_delay(retry_count)
|
||||
self._record_guardrail_retry(attempt=retry_count, error=error, delay=total_delay)
|
||||
|
||||
execution_config = getattr(self, '_execution_config', None)
|
||||
if execution_config is not None:
|
||||
total_delay = BackoffPolicy.delay(
|
||||
retry_count,
|
||||
execution_config.retry_initial_delay,
|
||||
execution_config.retry_backoff_factor,
|
||||
execution_config.retry_jitter
|
||||
)
|
||||
else:
|
||||
# Fall back to simple backoff if no execution config
|
||||
total_delay = 1.0 * (2.0 ** (retry_count - 1))
|
||||
|
||||
logging.info(f"Agent {self.name}: Waiting {total_delay:.2f}s before guardrail retry")
|
||||
await asyncio.sleep(total_delay)
|
||||
|
||||
|
||||
# Regenerate response for retry (async version)
|
||||
try:
|
||||
feedback = self._guardrail_retry_feedback(error)
|
||||
if conversation is not None:
|
||||
retry_messages = self._extend_guardrail_conversation(
|
||||
conversation, current_response, feedback
|
||||
)
|
||||
else:
|
||||
retry_messages = [{"role": "user", "content": f"{prompt}\n\nNote: {feedback}"}]
|
||||
# stream=False: the retry consumes a complete message, and the
|
||||
# helper's own default is stream=True regardless of the agent's
|
||||
# setting, which would make the retry stream when nothing reads
|
||||
# the deltas.
|
||||
response = await self._execute_unified_achat_completion(list(retry_messages), temperature, tools, stream=False, task_name=task_name, task_description=task_description, task_id=task_id)
|
||||
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {error}. Please provide an improved response."
|
||||
response = await self._execute_unified_achat_completion([{"role": "user", "content": retry_prompt}], temperature, tools, task_name=task_name, task_description=task_description, task_id=task_id)
|
||||
if response and hasattr(response, 'choices') and response.choices:
|
||||
content = response.choices[0].message.content
|
||||
current_response = content.strip() if content else ""
|
||||
@@ -7217,9 +6876,9 @@ Answer:"""
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Error during guardrail retry: {e}")
|
||||
raise Exception(f"Agent {self.name} guardrail retry failed: {e}")
|
||||
|
||||
|
||||
return current_response
|
||||
|
||||
|
||||
def _get_tools_cache_key(self, tools):
|
||||
"""Generate a cache key for tools list."""
|
||||
if tools is None:
|
||||
|
||||
@@ -1,443 +0,0 @@
|
||||
"""Attachment routing for multimodal prompts.
|
||||
|
||||
The one invariant this module exists to enforce: **an attachment is never
|
||||
dropped silently**. Every input either becomes a model-visible content part, or
|
||||
produces a loud, actionable signal that names the file and says why.
|
||||
|
||||
Two failure classes are treated differently on purpose:
|
||||
|
||||
``raise`` -- *addressing* errors: the path does not exist, is a directory, is
|
||||
unreadable, or the attachment is not a type this API accepts. These are
|
||||
always caller mistakes (a typo, a wrong working directory, a stale path).
|
||||
They are fully actionable at the call site, there is no useful degraded
|
||||
answer, and continuing would produce a confident answer about a file the
|
||||
model never saw. Raising is the only way the caller can tell.
|
||||
|
||||
``warn + visible degrade`` -- *capability* limits: the file is there and
|
||||
readable, but the target model cannot ingest that media type. The run can
|
||||
still be useful, so we log a warning **and** put a marker part in the
|
||||
prompt so the model itself knows a file was withheld or converted and can
|
||||
say so. This is environmental, not a bug in the caller's code, and hard
|
||||
failing would make a model swap a breaking change.
|
||||
|
||||
Escape hatches (both default to the behaviour described above):
|
||||
|
||||
- ``PRAISONAI_ATTACHMENTS_ON_MISSING=warn`` downgrades the missing/unreadable
|
||||
raise to a warning + visible marker part.
|
||||
- ``PRAISONAI_ATTACHMENTS_STRICT=1`` escalates capability degrades to raises,
|
||||
for pipelines that would rather fail than get a partial answer.
|
||||
|
||||
Part shapes emitted (OpenAI/LiteLLM chat-completions style):
|
||||
|
||||
- image -> ``{"type": "image_url", "image_url": {"url": "data:...;base64,..."}}``
|
||||
- pdf native -> ``{"type": "file", "file": {"filename": ..., "file_data": "data:application/pdf;base64,..."}}``
|
||||
- audio -> ``{"type": "input_audio", "input_audio": {"data": "<b64>", "format": "mp3"}}``
|
||||
- text/degraded/withheld -> ``{"type": "text", "text": "[Attachment: ...] ..."}``
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..tools.base import file_part, image_part, text_part
|
||||
|
||||
__all__ = [
|
||||
"AttachmentError",
|
||||
"build_attachment_parts",
|
||||
"resolve_attachment_model_name",
|
||||
"IMAGE_EXTENSIONS",
|
||||
"PDF_EXTENSIONS",
|
||||
"AUDIO_EXTENSIONS",
|
||||
"VIDEO_EXTENSIONS",
|
||||
"TEXT_EXTENSIONS",
|
||||
]
|
||||
|
||||
|
||||
class AttachmentError(ValueError):
|
||||
"""Raised when an attachment cannot be turned into a model content part.
|
||||
|
||||
Subclasses ``ValueError`` so existing ``except ValueError`` handlers around
|
||||
``chat()`` keep working.
|
||||
"""
|
||||
|
||||
|
||||
# --- Type tables -----------------------------------------------------------
|
||||
|
||||
# Unchanged from the original image-only implementation; images must keep
|
||||
# producing byte-identical parts.
|
||||
IMAGE_MEDIA_TYPES = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
IMAGE_EXTENSIONS = frozenset(IMAGE_MEDIA_TYPES)
|
||||
|
||||
PDF_EXTENSIONS = frozenset({".pdf"})
|
||||
|
||||
# Extension -> the ``format`` string sent in an ``input_audio`` part.
|
||||
AUDIO_FORMATS = {
|
||||
".mp3": "mp3",
|
||||
".wav": "wav",
|
||||
".m4a": "m4a",
|
||||
".ogg": "ogg",
|
||||
".oga": "ogg",
|
||||
".opus": "opus",
|
||||
".flac": "flac",
|
||||
".aac": "aac",
|
||||
}
|
||||
AUDIO_EXTENSIONS = frozenset(AUDIO_FORMATS)
|
||||
|
||||
VIDEO_EXTENSIONS = frozenset({".mp4", ".mov", ".avi", ".mkv", ".webm", ".mpeg", ".mpg", ".wmv"})
|
||||
|
||||
# Files we can safely inline as UTF-8 text. No model capability needed.
|
||||
TEXT_EXTENSIONS = frozenset({
|
||||
".txt", ".md", ".markdown", ".rst", ".log", ".csv", ".tsv", ".json",
|
||||
".jsonl", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".xml",
|
||||
".html", ".htm", ".sql", ".py", ".js", ".ts", ".tsx", ".jsx", ".java",
|
||||
".c", ".h", ".cpp", ".hpp", ".cs", ".go", ".rs", ".rb", ".php", ".swift",
|
||||
".kt", ".sh", ".bash", ".zsh", ".env", ".tex", ".srt", ".vtt",
|
||||
})
|
||||
|
||||
# Inlined text is truncated so one large file cannot blow the context window.
|
||||
# Truncation is announced in the part itself, never silent.
|
||||
MAX_INLINE_TEXT_CHARS = 100_000
|
||||
|
||||
|
||||
# --- Helpers ---------------------------------------------------------------
|
||||
|
||||
def _on_missing_raises() -> bool:
|
||||
return os.environ.get("PRAISONAI_ATTACHMENTS_ON_MISSING", "error").strip().lower() not in (
|
||||
"warn", "warning", "ignore", "0", "false",
|
||||
)
|
||||
|
||||
|
||||
def _strict_unsupported() -> bool:
|
||||
return os.environ.get("PRAISONAI_ATTACHMENTS_STRICT", "").strip().lower() in (
|
||||
"1", "true", "yes", "on", "error", "raise",
|
||||
)
|
||||
|
||||
|
||||
def _fail(message: str) -> List[Dict[str, Any]]:
|
||||
"""Addressing error: raise, unless explicitly downgraded to a warning."""
|
||||
if _on_missing_raises():
|
||||
raise AttachmentError(message)
|
||||
logging.warning("Attachment problem: %s", message)
|
||||
return [text_part(f"[Attachment could not be read: {message}]")]
|
||||
|
||||
|
||||
def _degrade(message: str, marker: str) -> List[Dict[str, Any]]:
|
||||
"""Capability limit: warn loudly and leave a marker the model can see."""
|
||||
if _strict_unsupported():
|
||||
raise AttachmentError(message + " (PRAISONAI_ATTACHMENTS_STRICT is set)")
|
||||
logging.warning("Attachment degraded: %s", message)
|
||||
return [text_part(marker)]
|
||||
|
||||
|
||||
def _read_bytes(path: str) -> bytes:
|
||||
with open(path, "rb") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def _b64(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode("utf-8")
|
||||
|
||||
|
||||
def _extract_pdf_text(path: str) -> Optional[str]:
|
||||
"""Return a PDF's text, or ``None`` if no extractor is installed/usable."""
|
||||
try:
|
||||
from pypdf import PdfReader # type: ignore
|
||||
except ImportError:
|
||||
try:
|
||||
from PyPDF2 import PdfReader # type: ignore
|
||||
except ImportError:
|
||||
return None
|
||||
try:
|
||||
reader = PdfReader(path)
|
||||
pages = []
|
||||
for index, page in enumerate(reader.pages, start=1):
|
||||
try:
|
||||
pages.append(f"--- page {index} ---\n{page.extract_text() or ''}")
|
||||
except Exception as exc: # noqa: BLE001 - one bad page must not kill the rest
|
||||
logging.debug("PDF page %d of %s failed to extract: %s", index, path, exc)
|
||||
text = "\n".join(pages).strip()
|
||||
return text or None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logging.debug("PDF text extraction failed for %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _truncate(text: str, label: str) -> str:
|
||||
if len(text) <= MAX_INLINE_TEXT_CHARS:
|
||||
return text
|
||||
logging.warning(
|
||||
"Attachment %s truncated to %d of %d characters", label, MAX_INLINE_TEXT_CHARS, len(text)
|
||||
)
|
||||
return (
|
||||
text[:MAX_INLINE_TEXT_CHARS]
|
||||
+ f"\n\n[... truncated: {len(text) - MAX_INLINE_TEXT_CHARS} more characters not shown ...]"
|
||||
)
|
||||
|
||||
|
||||
def resolve_attachment_model_name(agent: Any) -> str:
|
||||
"""Best-effort model id for capability routing.
|
||||
|
||||
Mirrors the resolution already used by the agent's other capability probes
|
||||
(``_model_supports_web_search`` and friends).
|
||||
"""
|
||||
instance = getattr(agent, "llm_instance", None)
|
||||
model = getattr(instance, "model", None) if instance else None
|
||||
if isinstance(model, str) and model:
|
||||
return model
|
||||
llm = getattr(agent, "llm", None)
|
||||
if isinstance(llm, str) and llm:
|
||||
return llm
|
||||
if isinstance(llm, dict):
|
||||
model = llm.get("model")
|
||||
if isinstance(model, str) and model:
|
||||
return model
|
||||
return ""
|
||||
|
||||
|
||||
# --- Per-type builders -----------------------------------------------------
|
||||
|
||||
def _image_message_part(path: str, ext: str) -> Dict[str, Any]:
|
||||
"""Encode an image exactly as the original implementation did."""
|
||||
media_type = IMAGE_MEDIA_TYPES.get(ext, "image/jpeg")
|
||||
encoded = _b64(_read_bytes(path))
|
||||
# Canonical part first (shared vocabulary with tools/base.py), then the
|
||||
# provider-facing shape, so there is one definition of "an image part".
|
||||
canonical = image_part(encoded, mime=media_type, name=os.path.basename(path))
|
||||
logging.debug(
|
||||
"Successfully encoded image attachment: %s (%d bytes base64)", path, len(encoded)
|
||||
)
|
||||
return {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:{canonical['mime']};base64,{canonical['data']}"},
|
||||
}
|
||||
|
||||
|
||||
def _pdf_parts(path: str, model_name: str) -> List[Dict[str, Any]]:
|
||||
name = os.path.basename(path)
|
||||
if _capability("pdf", model_name):
|
||||
canonical = file_part(
|
||||
_b64(_read_bytes(path)), mime="application/pdf", name=name
|
||||
)
|
||||
return [{
|
||||
"type": "file",
|
||||
"file": {
|
||||
"filename": canonical["name"],
|
||||
"file_data": f"data:{canonical['mime']};base64,{canonical['data']}",
|
||||
},
|
||||
}]
|
||||
|
||||
text = _extract_pdf_text(path)
|
||||
if text:
|
||||
logging.warning(
|
||||
"Model %r cannot accept PDF attachments natively; sending locally "
|
||||
"extracted text of %s instead. Use a PDF-capable model (e.g. an "
|
||||
"Anthropic Claude or OpenAI gpt-4o/4.1 model) to send the document itself.",
|
||||
model_name or "<unknown>", path,
|
||||
)
|
||||
body = _truncate(text, name)
|
||||
return [text_part(
|
||||
f"[Attachment: {name} - PDF text extracted locally because model "
|
||||
f"{model_name or '<unknown>'!r} cannot accept PDF files directly; "
|
||||
f"layout, images and tables are lost]\n{body}"
|
||||
)]
|
||||
|
||||
return _degrade(
|
||||
f"{path}: model {model_name or '<unknown>'!r} cannot accept PDFs natively and the "
|
||||
f"PDF's text could not be extracted (install 'pypdf', or use a PDF-capable model)",
|
||||
f"[Attachment omitted: {name} - the PDF could not be sent to model "
|
||||
f"{model_name or '<unknown>'!r} and its text could not be extracted locally. "
|
||||
f"Answer without it and say so.]",
|
||||
)
|
||||
|
||||
|
||||
def _audio_parts(path: str, ext: str, model_name: str) -> List[Dict[str, Any]]:
|
||||
name = os.path.basename(path)
|
||||
if _capability("audio", model_name):
|
||||
canonical = file_part(
|
||||
_b64(_read_bytes(path)),
|
||||
mime=f"audio/{AUDIO_FORMATS.get(ext, 'mpeg')}",
|
||||
name=name,
|
||||
)
|
||||
return [{
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": canonical["data"],
|
||||
"format": AUDIO_FORMATS.get(ext, "mp3"),
|
||||
},
|
||||
}]
|
||||
|
||||
return _degrade(
|
||||
f"{path}: model {model_name or '<unknown>'!r} cannot accept audio input "
|
||||
f"(use an audio-capable model such as gpt-4o-audio-preview or a Gemini model, "
|
||||
f"or transcribe the file first)",
|
||||
f"[Attachment omitted: {name} - audio was not sent because model "
|
||||
f"{model_name or '<unknown>'!r} cannot accept audio input. Answer without "
|
||||
f"it and say so.]",
|
||||
)
|
||||
|
||||
|
||||
def _text_parts(path: str) -> List[Dict[str, Any]]:
|
||||
name = os.path.basename(path)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
content = fh.read()
|
||||
except OSError as exc:
|
||||
return _fail(f"{path}: could not be read ({exc})")
|
||||
return [text_part(f"[Attachment: {name}]\n{_truncate(content, name)}")]
|
||||
|
||||
|
||||
def _capability(kind: str, model_name: str) -> bool:
|
||||
"""Capability probe, isolated so a broken/absent litellm never crashes a run."""
|
||||
try:
|
||||
from ..llm.model_capabilities import supports_audio_input, supports_pdf_input
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
try:
|
||||
if kind == "pdf":
|
||||
return bool(supports_pdf_input(model_name))
|
||||
if kind == "audio":
|
||||
return bool(supports_audio_input(model_name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logging.debug("Capability probe %s/%s failed: %s", kind, model_name, exc)
|
||||
return False
|
||||
|
||||
|
||||
# --- Entry point -----------------------------------------------------------
|
||||
|
||||
def build_attachment_parts(attachment: Any, model_name: str = "") -> List[Dict[str, Any]]:
|
||||
"""Turn one attachment into zero-or-more model content parts.
|
||||
|
||||
Never returns an empty list for an input it could not handle: it either
|
||||
raises :class:`AttachmentError` or returns a visible marker part.
|
||||
"""
|
||||
# Already-structured content parts pass through untouched.
|
||||
if isinstance(attachment, dict):
|
||||
if "type" not in attachment:
|
||||
logging.warning(
|
||||
"Attachment dict has no 'type' key and may be ignored by the provider: %r",
|
||||
attachment,
|
||||
)
|
||||
return [attachment]
|
||||
|
||||
if isinstance(attachment, os.PathLike):
|
||||
attachment = os.fspath(attachment)
|
||||
|
||||
if not isinstance(attachment, str):
|
||||
raise AttachmentError(
|
||||
f"Unsupported attachment type {type(attachment).__name__!r}: expected a file "
|
||||
f"path, an http(s)/data URL, or a content-part dict, got {attachment!r}"
|
||||
)
|
||||
|
||||
if not attachment.strip():
|
||||
raise AttachmentError("Empty attachment path")
|
||||
|
||||
if attachment.startswith(("http://", "https://", "data:")):
|
||||
return _remote_parts(attachment, model_name)
|
||||
|
||||
if os.path.isdir(attachment):
|
||||
return _fail(f"{attachment}: is a directory, not a file")
|
||||
|
||||
if not os.path.isfile(attachment):
|
||||
return _fail(
|
||||
f"{attachment}: no such file (attachment paths are resolved relative to the "
|
||||
f"current working directory {os.getcwd()!r})"
|
||||
)
|
||||
|
||||
ext = os.path.splitext(attachment)[1].lower()
|
||||
name = os.path.basename(attachment)
|
||||
|
||||
try:
|
||||
if ext in IMAGE_EXTENSIONS:
|
||||
return [_image_message_part(attachment, ext)]
|
||||
if ext in PDF_EXTENSIONS:
|
||||
return _pdf_parts(attachment, model_name)
|
||||
if ext in AUDIO_EXTENSIONS:
|
||||
return _audio_parts(attachment, ext, model_name)
|
||||
if ext in TEXT_EXTENSIONS:
|
||||
return _text_parts(attachment)
|
||||
except AttachmentError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
return _fail(f"{attachment}: could not be read ({exc})")
|
||||
|
||||
if ext in VIDEO_EXTENSIONS:
|
||||
return _degrade(
|
||||
f"{attachment}: video attachments are not supported on the chat-completions "
|
||||
f"path (extract frames as images, or a transcript as text, first)",
|
||||
f"[Attachment omitted: {name} - video attachments are not supported. "
|
||||
f"Answer without it and say so.]",
|
||||
)
|
||||
|
||||
# Unknown extension. Rather than guess at a binary, try it as text when it
|
||||
# decodes cleanly as UTF-8; otherwise say plainly that it was not sent.
|
||||
sample_text = _maybe_text_file(attachment)
|
||||
if sample_text is not None:
|
||||
logging.warning(
|
||||
"Attachment %s has an unrecognised extension %r; inlining it as UTF-8 text.",
|
||||
attachment, ext or "(none)",
|
||||
)
|
||||
return [text_part(f"[Attachment: {name} (unrecognised type, sent as text)]\n"
|
||||
f"{_truncate(sample_text, name)}")]
|
||||
|
||||
return _degrade(
|
||||
f"{attachment}: unsupported attachment type {ext or '(no extension)'!r}. Supported: "
|
||||
f"images {sorted(IMAGE_EXTENSIONS)}, PDF, audio {sorted(AUDIO_EXTENSIONS)}, and "
|
||||
f"text-like files. Convert the file, or pass its text instead.",
|
||||
f"[Attachment omitted: {name} - unsupported file type "
|
||||
f"{ext or '(no extension)'!r}. Answer without it and say so.]",
|
||||
)
|
||||
|
||||
|
||||
def _maybe_text_file(path: str) -> Optional[str]:
|
||||
"""Return the file's text if it is valid UTF-8 and not obviously binary."""
|
||||
try:
|
||||
raw = _read_bytes(path)
|
||||
except OSError:
|
||||
return None
|
||||
if b"\x00" in raw[:8192]:
|
||||
return None
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def _remote_parts(url: str, model_name: str) -> List[Dict[str, Any]]:
|
||||
"""Route an http(s) URL or data URI.
|
||||
|
||||
Images (and anything without a recognisable non-image extension) keep the
|
||||
original behaviour: a straight ``image_url`` part. A URL that plainly names
|
||||
a non-image document is called out instead of being passed off as an image.
|
||||
"""
|
||||
if url.startswith("data:"):
|
||||
mime = url[5:].split(";", 1)[0].split(",", 1)[0].lower()
|
||||
if mime and not mime.startswith("image/"):
|
||||
return _degrade(
|
||||
f"data URI with media type {mime!r} is not supported as an attachment; "
|
||||
f"only image data URIs are passed through",
|
||||
f"[Attachment omitted: a {mime} data URI was not sent to the model. "
|
||||
f"Answer without it and say so.]",
|
||||
)
|
||||
return [{"type": "image_url", "image_url": {"url": url}}]
|
||||
|
||||
path = url.split("?", 1)[0].split("#", 1)[0]
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext and ext not in IMAGE_EXTENSIONS and (
|
||||
ext in PDF_EXTENSIONS or ext in AUDIO_EXTENSIONS
|
||||
or ext in VIDEO_EXTENSIONS or ext in TEXT_EXTENSIONS
|
||||
):
|
||||
return _degrade(
|
||||
f"{url}: remote {ext} attachments are not fetched (this function performs no "
|
||||
f"network I/O); download the file and pass the local path instead",
|
||||
f"[Attachment omitted: {url} - remote {ext} files are not downloaded "
|
||||
f"automatically. Answer without it and say so.]",
|
||||
)
|
||||
|
||||
return [{"type": "image_url", "image_url": {"url": url}}]
|
||||
@@ -95,51 +95,6 @@ class ChatMixin:
|
||||
return _TurnCancelToken(source)
|
||||
return _TurnCancelToken(source, turn_id=begin_turn())
|
||||
|
||||
@staticmethod
|
||||
def _notify_tool_call(tool_name, tool_input, tool_result, elapsed_time=None, success=True):
|
||||
"""Fire the ``tool_call`` display callback for one tool execution.
|
||||
|
||||
The non-streaming path fires this from the OpenAI client after every
|
||||
tool (``display_tool_call`` -> ``execute_sync_callback('tool_call')``),
|
||||
which is how streaming UIs such as the desktop learn that a tool ran.
|
||||
The streaming path executes tools inline here, so it has to fire the
|
||||
same callback itself with the same kwargs (Issue #4716).
|
||||
|
||||
Best-effort by design: a callback failure is logged and swallowed so
|
||||
it can never break the stream it is reporting on.
|
||||
|
||||
A durable run converts a raising tool into an ``{"error": ...}`` result
|
||||
instead of re-raising (see ``DurableRunContext.wrap_sync``), so the
|
||||
raising-tool ``except`` branch never fires for it. Detect that shape
|
||||
here and report ``success=False`` so streaming UIs do not label a failed
|
||||
tool as successful (Issue #4735 review).
|
||||
"""
|
||||
if (
|
||||
success
|
||||
and isinstance(tool_result, dict)
|
||||
and tool_result.get("error") is not None
|
||||
):
|
||||
success = False
|
||||
try:
|
||||
if tool_result is None:
|
||||
result_str = None
|
||||
else:
|
||||
try:
|
||||
result_str = json.dumps(tool_result)
|
||||
except (TypeError, ValueError):
|
||||
result_str = str(tool_result)
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'tool_call',
|
||||
message=f"Calling function: {tool_name}",
|
||||
tool_name=tool_name,
|
||||
tool_input=tool_input,
|
||||
tool_output=result_str[:200] if result_str else None,
|
||||
elapsed_time=elapsed_time,
|
||||
success=success,
|
||||
)
|
||||
except Exception as callback_error:
|
||||
logging.debug(f"tool_call callback failed for '{tool_name}': {callback_error}")
|
||||
|
||||
def _durable_sync_tool_executor(self, execute_tool_fn):
|
||||
"""Wrap tool execution only while an opt-in durable run is active."""
|
||||
context = self._get_durable_run_context()
|
||||
@@ -1203,37 +1158,56 @@ Your Goal: {self.goal}"""
|
||||
This is a DRY helper used by chat/achat/run/arun/start/astart.
|
||||
Attachments are ephemeral - only text is stored in history.
|
||||
|
||||
Routing lives in ``praisonaiagents.agent.attachments``. The contract is
|
||||
that an attachment is **never dropped silently**: a bad path raises
|
||||
:class:`~praisonaiagents.agent.attachments.AttachmentError`, and a file
|
||||
the target model cannot ingest is logged as a warning *and* replaced
|
||||
with a visible marker part (or a locally extracted text form) so both
|
||||
the caller and the model know what happened.
|
||||
|
||||
Args:
|
||||
prompt: Text query (ALWAYS stored in chat_history)
|
||||
attachments: Image/document/audio paths, URLs, or content-part
|
||||
dicts for THIS turn only (NEVER stored)
|
||||
attachments: Image/file paths for THIS turn only (NEVER stored)
|
||||
|
||||
Returns:
|
||||
Either a string (no attachments) or multimodal message list
|
||||
|
||||
Raises:
|
||||
AttachmentError: for a path that does not exist or cannot be read,
|
||||
or an attachment that is not a path/URL/content-part dict.
|
||||
"""
|
||||
if not attachments:
|
||||
return prompt
|
||||
|
||||
from .attachments import build_attachment_parts, resolve_attachment_model_name
|
||||
|
||||
model_name = resolve_attachment_model_name(self)
|
||||
|
||||
|
||||
# Build multimodal content list
|
||||
content = [{"type": "text", "text": prompt}]
|
||||
|
||||
for attachment in attachments:
|
||||
content.extend(build_attachment_parts(attachment, model_name))
|
||||
|
||||
# Handle image files
|
||||
if isinstance(attachment, str):
|
||||
import os
|
||||
import base64
|
||||
|
||||
if os.path.isfile(attachment):
|
||||
# File path - read and encode
|
||||
ext = os.path.splitext(attachment)[1].lower()
|
||||
if ext in ('.jpg', '.jpeg', '.png', '.gif', '.webp'):
|
||||
try:
|
||||
with open(attachment, 'rb') as f:
|
||||
data = base64.b64encode(f.read()).decode('utf-8')
|
||||
media_type = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
}.get(ext, 'image/jpeg')
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:{media_type};base64,{data}"}
|
||||
})
|
||||
logging.debug(f"Successfully encoded image attachment: {attachment} ({len(data)} bytes base64)")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to load attachment {attachment}: {e}")
|
||||
elif attachment.startswith(('http://', 'https://', 'data:')):
|
||||
# URL or data URI
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": attachment}
|
||||
})
|
||||
elif isinstance(attachment, dict):
|
||||
# Already structured content
|
||||
content.append(attachment)
|
||||
|
||||
return content
|
||||
|
||||
def _extract_llm_response_content(self, response) -> Optional[str]:
|
||||
@@ -1664,47 +1638,6 @@ Your Goal: {self.goal}"""
|
||||
return None
|
||||
return self.fallback_models[fallback_index]
|
||||
|
||||
def _build_fallback_dispatcher(self, model):
|
||||
"""Build a throwaway dispatcher bound to a fallback ``model``.
|
||||
|
||||
The async fallback path passes the result to
|
||||
``_execute_unified_achat_completion(..., _dispatcher=...)`` for a single
|
||||
retry so it NEVER mutates the shared ``self.llm`` /
|
||||
``self._unified_dispatcher``. Concurrent async turns on the same Agent
|
||||
therefore keep dispatching on the original model with correct cost /
|
||||
observability attribution — closing the ordinary-turn fallback race.
|
||||
Returns ``None`` for custom-LLM agents (the custom client owns its own
|
||||
model), which keeps that path on the shared dispatcher as before.
|
||||
"""
|
||||
from ..llm import create_llm_dispatcher
|
||||
if self._using_custom_llm and hasattr(self, 'llm_instance'):
|
||||
return None
|
||||
if not hasattr(self, '_openai_client') or self._openai_client is None:
|
||||
from ..llm import get_openai_client
|
||||
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
|
||||
return create_llm_dispatcher(openai_client=self._openai_client, model=model)
|
||||
|
||||
def _get_model_fallback_lock(self):
|
||||
"""Lazily create the per-instance lock guarding model-fallback swaps.
|
||||
|
||||
Async fallback now runs against a per-call throwaway dispatcher
|
||||
(``_build_fallback_dispatcher``), so it does not mutate shared state and
|
||||
ordinary async turns can never observe the fallback model. The sync
|
||||
fallback path still recurses through the full ``_chat_completion``
|
||||
wrapper (which reads ``self.llm`` for BEFORE_LLM hook attribution and
|
||||
budget), so it temporarily overrides ``self.llm`` /
|
||||
``self._unified_dispatcher`` for a single retry. This dual (sync/async)
|
||||
lock serialises that mutate/restore window; normal, non-fallback turns
|
||||
never acquire it and stay fully concurrent. Created on demand so no
|
||||
extra state is added for agents without ``fallback_models``.
|
||||
"""
|
||||
lock = getattr(self, '_model_fallback_lock', None)
|
||||
if lock is None:
|
||||
from .async_safety import DualLock
|
||||
lock = DualLock()
|
||||
self._model_fallback_lock = lock
|
||||
return lock
|
||||
|
||||
def _emit_model_fallback(self, from_model, to_model, reason_category, fallback_index,
|
||||
stream_callback=None):
|
||||
"""Surface a mid-turn model fallback as an observable state transition.
|
||||
@@ -2161,26 +2094,22 @@ Your Goal: {self.goal}"""
|
||||
if classification.backoff_seconds and classification.backoff_seconds > 0:
|
||||
time.sleep(classification.backoff_seconds)
|
||||
|
||||
# Temporarily override the model and clear dispatcher cache
|
||||
# for this call. Serialise the mutate/restore window so a
|
||||
# concurrent turn on a shared Agent can't observe the wrong
|
||||
# model; only fallback turns contend on this lock.
|
||||
with self._get_model_fallback_lock().sync():
|
||||
original_llm = self.llm
|
||||
original_dispatcher = getattr(self, '_unified_dispatcher', None)
|
||||
try:
|
||||
self.llm = next_model
|
||||
self._unified_dispatcher = None # Force recreation with new model
|
||||
return self._chat_completion(
|
||||
messages, temperature, tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id, response_format,
|
||||
_retry_depth=_retry_depth + 1,
|
||||
_fallback_index=_fallback_index + 1,
|
||||
cancel_token=cancel_token
|
||||
)
|
||||
finally:
|
||||
self.llm = original_llm
|
||||
self._unified_dispatcher = original_dispatcher # Restore original dispatcher
|
||||
# Temporarily override the model and clear dispatcher cache for this call
|
||||
original_llm = self.llm
|
||||
original_dispatcher = getattr(self, '_unified_dispatcher', None)
|
||||
try:
|
||||
self.llm = next_model
|
||||
self._unified_dispatcher = None # Force recreation with new model
|
||||
return self._chat_completion(
|
||||
messages, temperature, tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id, response_format,
|
||||
_retry_depth=_retry_depth + 1,
|
||||
_fallback_index=_fallback_index + 1,
|
||||
cancel_token=cancel_token
|
||||
)
|
||||
finally:
|
||||
self.llm = original_llm
|
||||
self._unified_dispatcher = original_dispatcher # Restore original dispatcher
|
||||
else:
|
||||
logging.warning(f"[{self.name}] {classification.user_message} (no more fallback models available)")
|
||||
# Continue to error handling without retry
|
||||
@@ -2343,21 +2272,16 @@ Your Goal: {self.goal}"""
|
||||
if classification.backoff_seconds and classification.backoff_seconds > 0:
|
||||
await asyncio.sleep(classification.backoff_seconds)
|
||||
|
||||
# Run this single retry against a throwaway dispatcher bound to
|
||||
# the fallback model, passed per-call. This never mutates the
|
||||
# shared self.llm / self._unified_dispatcher, so a concurrent
|
||||
# async turn on this shared Agent keeps dispatching on the
|
||||
# original model (correct cost / observability attribution) even
|
||||
# while this turn is suspended on the await — no lock needed on
|
||||
# the async path. Custom-LLM agents get None here and fall back
|
||||
# to the shared dispatcher, matching prior behaviour.
|
||||
_fallback_dispatcher = self._build_fallback_dispatcher(next_model)
|
||||
# Temporarily override the model and clear dispatcher cache for this call
|
||||
original_llm = self.llm
|
||||
original_dispatcher = getattr(self, '_unified_dispatcher', None)
|
||||
try:
|
||||
self.llm = next_model
|
||||
self._unified_dispatcher = None # Force recreation with new model
|
||||
return await self._execute_unified_achat_completion(
|
||||
messages, temperature, tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id, response_format,
|
||||
stream_callback, emit_events,
|
||||
_dispatcher=_fallback_dispatcher,
|
||||
stream_callback, emit_events
|
||||
)
|
||||
except Exception as retry_error:
|
||||
return await self._handle_async_llm_error(
|
||||
@@ -2365,6 +2289,9 @@ Your Goal: {self.goal}"""
|
||||
reasoning_steps, task_name, task_description, task_id, response_format,
|
||||
stream_callback, emit_events, _retry_depth + 1, _fallback_index + 1
|
||||
)
|
||||
finally:
|
||||
self.llm = original_llm
|
||||
self._unified_dispatcher = original_dispatcher # Restore original dispatcher
|
||||
else:
|
||||
logging.warning(f"[{self.name}] {classification.user_message} (no more fallback models available)")
|
||||
# Continue to error handling without retry
|
||||
@@ -2572,43 +2499,29 @@ Your Goal: {self.goal}"""
|
||||
response_format=None,
|
||||
stream_callback=None,
|
||||
emit_events=True,
|
||||
_dispatcher=None,
|
||||
):
|
||||
"""
|
||||
Execute unified async chat completion using composition instead of runtime class mutation.
|
||||
|
||||
This method provides a unified async dispatch for all LLM providers using
|
||||
composition for safety and maintainability.
|
||||
|
||||
``_dispatcher`` lets the model-fallback path run a single retry against a
|
||||
caller-supplied throwaway dispatcher WITHOUT mutating the shared
|
||||
``self.llm`` / ``self._unified_dispatcher``, so concurrent async turns on
|
||||
the same Agent never observe another turn's fallback model.
|
||||
"""
|
||||
from ..llm import create_llm_dispatcher
|
||||
|
||||
# Resolve the dispatcher for THIS call. A caller-supplied dispatcher
|
||||
# (async model fallback) is used as-is and never cached onto the
|
||||
# instance, so the shared cached dispatcher is left intact for
|
||||
# concurrent turns.
|
||||
dispatcher = _dispatcher
|
||||
if dispatcher is None:
|
||||
# Get or create the shared cached dispatcher
|
||||
if not hasattr(self, '_unified_dispatcher') or self._unified_dispatcher is None:
|
||||
# Provider selection based on existing agent configuration
|
||||
if self._using_custom_llm and hasattr(self, 'llm_instance'):
|
||||
dispatcher = create_llm_dispatcher(llm_instance=self.llm_instance)
|
||||
else:
|
||||
# Initialize OpenAI client if not present
|
||||
if not hasattr(self, '_openai_client') or self._openai_client is None:
|
||||
from ..llm import get_openai_client
|
||||
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
|
||||
dispatcher = create_llm_dispatcher(openai_client=self._openai_client, model=self.llm)
|
||||
|
||||
# Cache the dispatcher
|
||||
self._unified_dispatcher = dispatcher
|
||||
# Get or create unified dispatcher
|
||||
if not hasattr(self, '_unified_dispatcher') or self._unified_dispatcher is None:
|
||||
# Provider selection based on existing agent configuration
|
||||
if self._using_custom_llm and hasattr(self, 'llm_instance'):
|
||||
dispatcher = create_llm_dispatcher(llm_instance=self.llm_instance)
|
||||
else:
|
||||
dispatcher = self._unified_dispatcher
|
||||
# Initialize OpenAI client if not present
|
||||
if not hasattr(self, '_openai_client') or self._openai_client is None:
|
||||
from ..llm import get_openai_client
|
||||
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
|
||||
dispatcher = create_llm_dispatcher(openai_client=self._openai_client, model=self.llm)
|
||||
|
||||
# Cache the dispatcher
|
||||
self._unified_dispatcher = dispatcher
|
||||
|
||||
# Pre-call budget guard (parity with sync _chat_completion). Bots and
|
||||
# other async callers route through here, not _chat_completion.
|
||||
@@ -2664,7 +2577,7 @@ Your Goal: {self.goal}"""
|
||||
try:
|
||||
if stream_callback is None and hasattr(self, 'stream_emitter'):
|
||||
stream_callback = getattr(self.stream_emitter, 'emit', None)
|
||||
final_response = await dispatcher.achat_completion(
|
||||
final_response = await self._unified_dispatcher.achat_completion(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
tool_choice=getattr(self, 'tool_choice', None),
|
||||
@@ -3274,10 +3187,6 @@ Your Goal: {self.goal}"""
|
||||
prompt = res.output.modified_input["prompt"]
|
||||
llm_prompt = self._build_multimodal_prompt(prompt, attachments) if attachments else prompt
|
||||
|
||||
# Track execution via telemetry
|
||||
if hasattr(self, '_telemetry') and self._telemetry:
|
||||
self._telemetry.track_agent_execution(self.name, success=True)
|
||||
|
||||
# Reset the final display flag for each new conversation
|
||||
self._final_display_shown = False
|
||||
|
||||
@@ -3514,7 +3423,7 @@ Your Goal: {self.goal}"""
|
||||
|
||||
# Apply guardrail validation for custom LLM response
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=processed_history)
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
# Execute callback and display after validation
|
||||
self._execute_callback_and_display(prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(prompt, validated_response, start_time)
|
||||
@@ -3638,7 +3547,7 @@ Your Goal: {self.goal}"""
|
||||
self._persist_message("assistant", response_text)
|
||||
# Apply guardrail validation even for JSON output
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=messages)
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
@@ -3659,7 +3568,7 @@ Your Goal: {self.goal}"""
|
||||
if reasoning_steps and hasattr(response.choices[0].message, 'reasoning_content') and response.choices[0].message.reasoning_content:
|
||||
# Apply guardrail to reasoning content
|
||||
try:
|
||||
validated_reasoning = self._apply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=messages)
|
||||
validated_reasoning = self._apply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_reasoning, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_reasoning, start_time)
|
||||
@@ -3670,7 +3579,7 @@ Your Goal: {self.goal}"""
|
||||
return None
|
||||
# Apply guardrail to regular response
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=messages)
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
@@ -3736,7 +3645,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Apply guardrail validation after satisfactory reflection
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=messages)
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
self._end_run(validated_response, "completed", {"duration_ms": (time.time() - start_time) * 1000})
|
||||
@@ -3756,7 +3665,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Apply guardrail validation after max reflections
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=messages)
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
@@ -3792,7 +3701,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
_get_display_functions()['display_self_reflection']("Maximum reflection count reached after repeated parse errors, returning current response", console=self.console)
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=messages)
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as guard_e:
|
||||
@@ -4037,18 +3946,18 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._ensure_knowledge_processed()
|
||||
|
||||
if not skip_retrieval and self.knowledge:
|
||||
# Use the unified, already-correct normalization helper (handles
|
||||
# the SearchResult dataclass, dict, and list shapes). It is
|
||||
# synchronous (Mem0 embedding + vector-store I/O), so offload it
|
||||
# to a thread to avoid blocking the event loop for coroutines
|
||||
# sharing the loop.
|
||||
knowledge_context, _ = await asyncio.to_thread(
|
||||
self._get_knowledge_context,
|
||||
prompt if isinstance(prompt, str) else str(prompt),
|
||||
True
|
||||
# Knowledge.search is synchronous (Mem0 embedding + vector-store
|
||||
# I/O). Offload to a thread so it never blocks the event loop for
|
||||
# other coroutines sharing the loop.
|
||||
search_results = await asyncio.to_thread(
|
||||
self.knowledge.search, prompt, agent_id=self.agent_id
|
||||
)
|
||||
if knowledge_context:
|
||||
prompt = f"{prompt}\n\nKnowledge: {knowledge_context}"
|
||||
if search_results:
|
||||
if isinstance(search_results, dict) and 'results' in search_results:
|
||||
knowledge_content = "\n".join([result['memory'] for result in search_results['results']])
|
||||
else:
|
||||
knowledge_content = "\n".join(search_results)
|
||||
prompt = f"{prompt}\n\nKnowledge: {knowledge_content}"
|
||||
|
||||
if self._using_custom_llm:
|
||||
# Store chat history length for potential rollback
|
||||
@@ -4166,7 +4075,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Apply guardrail validation for custom LLM response
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id, messages=effective_history)
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(normalized_content, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(prompt, validated_response, start_time)
|
||||
@@ -4302,7 +4211,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
await self._apersist_message("assistant", response_text)
|
||||
# Apply guardrail validation even for JSON output
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, messages=messages)
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
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)
|
||||
@@ -4325,7 +4234,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if reasoning_steps and hasattr(response, 'choices') and response.choices and hasattr(response.choices[0].message, 'reasoning_content') and response.choices[0].message.reasoning_content:
|
||||
# Apply guardrail to reasoning content
|
||||
try:
|
||||
validated_reasoning = await self._aapply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id, messages=messages)
|
||||
validated_reasoning = await self._aapply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_reasoning, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, validated_reasoning, start_time)
|
||||
@@ -4337,7 +4246,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
else:
|
||||
# Apply guardrail to regular response content
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, messages=messages)
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
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)
|
||||
@@ -4377,7 +4286,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Persist assistant message to DB (offloaded to a worker thread)
|
||||
await self._apersist_message("assistant", response_text)
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, messages=messages)
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
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)
|
||||
@@ -4411,7 +4320,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Persist assistant message to DB (offloaded to a worker thread)
|
||||
await self._apersist_message("assistant", response_text)
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, messages=messages)
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
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)
|
||||
@@ -4430,7 +4339,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Persist assistant message to DB (offloaded to a worker thread)
|
||||
await self._apersist_message("assistant", response_text)
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, messages=messages)
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
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)
|
||||
@@ -4480,7 +4389,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Persist assistant message to DB (offloaded to a worker thread)
|
||||
await self._apersist_message("assistant", response_text)
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, messages=messages)
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
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)
|
||||
@@ -4644,7 +4553,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Apply guardrail validation for OpenAI client response
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, messages=messages)
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
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)
|
||||
@@ -4660,10 +4569,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
total_time = time.time() - start_time
|
||||
logging.debug(f"Agent.achat failed in {total_time:.2f} seconds: {str(e)}")
|
||||
# Rollback chat history so a failed async turn does not leave
|
||||
# a dangling, unanswered user message (mirrors the sync path
|
||||
# and achat()'s own guardrail handler).
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
except ToolExecutionError:
|
||||
raise
|
||||
@@ -4672,9 +4577,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
total_time = time.time() - start_time
|
||||
logging.debug(f"Agent.achat failed in {total_time:.2f} seconds: {str(e)}")
|
||||
# Rollback chat history so a failed async turn does not leave a
|
||||
# dangling, unanswered user message (mirrors the sync path).
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
|
||||
async def _achat_completion(self, response, tools, reasoning_steps=False):
|
||||
@@ -4898,15 +4800,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
original_verbose = self.verbose
|
||||
self.verbose = False
|
||||
memory_prefetch_context = self._prefetch_memory(prompt)
|
||||
|
||||
# Ephemeral attachments (images / data URIs) are folded into a
|
||||
# multimodal prompt so streaming turns reach vision models too,
|
||||
# matching chat()/achat(). The text prompt is kept intact through
|
||||
# knowledge retrieval below and the multimodal content is built
|
||||
# afterwards, so knowledge augmentation never flattens the image.
|
||||
# Only the text is stored in history.
|
||||
attachments = kwargs.get('attachments')
|
||||
|
||||
|
||||
# For custom LLM path, use the new get_response_stream generator
|
||||
if self._using_custom_llm:
|
||||
# Handle knowledge search
|
||||
@@ -4915,15 +4809,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._ensure_knowledge_processed()
|
||||
|
||||
if self.knowledge:
|
||||
knowledge_context, _ = self._get_knowledge_context(
|
||||
prompt if isinstance(prompt, str) else str(prompt),
|
||||
use_rag=True
|
||||
)
|
||||
if knowledge_context:
|
||||
actual_prompt = f"{prompt}\n\nKnowledge: {knowledge_context}"
|
||||
|
||||
if attachments:
|
||||
actual_prompt = self._build_multimodal_prompt(actual_prompt, attachments)
|
||||
search_results = self.knowledge.search(prompt, agent_id=self.agent_id)
|
||||
if search_results:
|
||||
if isinstance(search_results, dict) and 'results' in search_results:
|
||||
knowledge_content = "\n".join([result['memory'] for result in search_results['results']])
|
||||
else:
|
||||
knowledge_content = "\n".join(search_results)
|
||||
actual_prompt = f"{prompt}\n\nKnowledge: {knowledge_content}"
|
||||
|
||||
# Handle tools properly
|
||||
tools = kwargs.get('tools', self.tools)
|
||||
@@ -4964,47 +4856,18 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
try:
|
||||
# Use the new streaming generator from LLM class
|
||||
response_content = ""
|
||||
# Compact before sending, exactly as the OpenAI-client
|
||||
# branch below does. #4729 added compaction to that branch
|
||||
# only, so a configured ContextManager -- auto-enabled
|
||||
# whenever tools are present -- was still skipped for every
|
||||
# agent routed through the custom-LLM path. The trigger is
|
||||
# the routing flag, not the vendor: llm="openai/gpt-4o-mini"
|
||||
# takes this branch too. Zero overhead when context=False.
|
||||
stream_system_prompt = self._build_system_prompt(
|
||||
tool_param,
|
||||
memory_prefetch_context=memory_prefetch_context,
|
||||
)
|
||||
if self.context_manager:
|
||||
stream_history, _ = self._apply_context_management(
|
||||
messages=self.chat_history,
|
||||
system_prompt=stream_system_prompt or "",
|
||||
tools=tool_param,
|
||||
)
|
||||
stream_history = list(stream_history)
|
||||
else:
|
||||
stream_history = self.chat_history
|
||||
stream_history = self.chat_history
|
||||
durable_context = self._get_durable_run_context()
|
||||
if durable_context is not None:
|
||||
stream_history = durable_context.restore_messages(
|
||||
list(stream_history)
|
||||
)
|
||||
stream_sampling_kwargs = {}
|
||||
if kwargs.get('max_tokens') is not None:
|
||||
stream_sampling_kwargs['max_tokens'] = kwargs['max_tokens']
|
||||
if kwargs.get('top_p') is not None:
|
||||
stream_sampling_kwargs['top_p'] = kwargs['top_p']
|
||||
# reasoning_effort travelled the same route as max_tokens
|
||||
# and top_p -- collected by the caller, forwarded into
|
||||
# start(), and then dropped, because this enumeration never
|
||||
# listed it. llm.py already consumes it from here.
|
||||
_stream_effort = kwargs.get(
|
||||
'reasoning_effort', getattr(self, 'reasoning_effort', None))
|
||||
if _stream_effort is not None:
|
||||
stream_sampling_kwargs['reasoning_effort'] = _stream_effort
|
||||
for chunk in self.llm_instance.get_response_stream(
|
||||
prompt=actual_prompt,
|
||||
system_prompt=stream_system_prompt,
|
||||
system_prompt=self._build_system_prompt(
|
||||
tool_param,
|
||||
memory_prefetch_context=memory_prefetch_context,
|
||||
),
|
||||
chat_history=stream_history,
|
||||
temperature=kwargs.get('temperature', 1.0),
|
||||
tools=tool_param,
|
||||
@@ -5022,8 +4885,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self.execute_tool
|
||||
),
|
||||
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
|
||||
max_tool_calls_per_turn=self._resolve_max_tool_calls(),
|
||||
**stream_sampling_kwargs
|
||||
max_tool_calls_per_turn=self._resolve_max_tool_calls()
|
||||
):
|
||||
response_content += chunk
|
||||
yield chunk
|
||||
@@ -5049,15 +4911,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._ensure_knowledge_processed()
|
||||
|
||||
if self.knowledge:
|
||||
knowledge_context, _ = self._get_knowledge_context(
|
||||
prompt if isinstance(prompt, str) else str(prompt),
|
||||
use_rag=True
|
||||
)
|
||||
if knowledge_context:
|
||||
actual_prompt = f"{prompt}\n\nKnowledge: {knowledge_context}"
|
||||
|
||||
if attachments:
|
||||
actual_prompt = self._build_multimodal_prompt(actual_prompt, attachments)
|
||||
search_results = self.knowledge.search(prompt, agent_id=self.agent_id)
|
||||
if search_results:
|
||||
if isinstance(search_results, dict) and 'results' in search_results:
|
||||
knowledge_content = "\n".join([result['memory'] for result in search_results['results']])
|
||||
else:
|
||||
knowledge_content = "\n".join(search_results)
|
||||
actual_prompt = f"{prompt}\n\nKnowledge: {knowledge_content}"
|
||||
|
||||
# Handle tools properly
|
||||
tools = kwargs.get('tools', self.tools)
|
||||
@@ -5071,27 +4931,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
kwargs.get('output_json'), kwargs.get('output_pydantic'),
|
||||
memory_prefetch_context=memory_prefetch_context)
|
||||
|
||||
# Apply context management so the streaming path compacts long
|
||||
# histories the same way the non-streaming path does (Issue #4714).
|
||||
# The system prompt is embedded as messages[0]; split it out so the
|
||||
# token ledger accounts for it, then reattach after optimization.
|
||||
if self.context_manager and messages:
|
||||
if messages[0].get("role") == "system":
|
||||
stream_system_prompt = messages[0].get("content", "")
|
||||
optimized_history, _ = self._apply_context_management(
|
||||
messages=messages[1:],
|
||||
system_prompt=stream_system_prompt,
|
||||
tools=tool_param,
|
||||
)
|
||||
messages = [messages[0]] + list(optimized_history)
|
||||
else:
|
||||
optimized_history, _ = self._apply_context_management(
|
||||
messages=messages,
|
||||
system_prompt="",
|
||||
tools=tool_param,
|
||||
)
|
||||
messages = list(optimized_history)
|
||||
|
||||
# Store chat history length for potential rollback
|
||||
chat_history_length = len(self.chat_history)
|
||||
|
||||
@@ -5121,37 +4960,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
"temperature": kwargs.get('temperature', 1.0),
|
||||
"stream": True
|
||||
}
|
||||
if kwargs.get('max_tokens') is not None:
|
||||
completion_args["max_tokens"] = kwargs['max_tokens']
|
||||
if kwargs.get('top_p') is not None:
|
||||
completion_args["top_p"] = kwargs['top_p']
|
||||
# Native reasoning control. resolve_reasoning_params returns
|
||||
# {} for "off", for an unset level, and for a model with no
|
||||
# reasoning knob, so the default stays a no-op; a reasoning
|
||||
# model gets reasoning_effort and Anthropic/Gemini get their
|
||||
# thinking budget. completion_args is copied into
|
||||
# followup_args below, so tool follow-ups inherit it.
|
||||
_effort = kwargs.get(
|
||||
'reasoning_effort', getattr(self, 'reasoning_effort', None))
|
||||
if _effort is not None:
|
||||
try:
|
||||
from ..thinking.effort import resolve_reasoning_params
|
||||
_reasoning = resolve_reasoning_params(_effort, self.llm)
|
||||
for _key, _value in _reasoning.items():
|
||||
# ``reasoning_effort`` is a native OpenAI SDK
|
||||
# keyword; anything else (e.g. an extended-thinking
|
||||
# ``thinking`` budget for an Anthropic/Gemini-named
|
||||
# model reached over an OpenAI-compatible endpoint)
|
||||
# is a provider-specific body field the OpenAI SDK
|
||||
# rejects as a top-level kwarg, so route it through
|
||||
# ``extra_body`` instead of raising TypeError.
|
||||
if _key == "reasoning_effort":
|
||||
completion_args[_key] = _value
|
||||
else:
|
||||
completion_args.setdefault(
|
||||
"extra_body", {})[_key] = _value
|
||||
except ImportError:
|
||||
pass
|
||||
if formatted_tools:
|
||||
completion_args["tools"] = formatted_tools
|
||||
|
||||
@@ -5287,34 +5095,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
)
|
||||
else {}
|
||||
)
|
||||
_tool_started = time_module.perf_counter()
|
||||
try:
|
||||
tool_result = executor(
|
||||
tool_call['function']['name'],
|
||||
parsed_args,
|
||||
tool_call_id=tool_call.get('id'),
|
||||
**iteration_kwargs,
|
||||
)
|
||||
except Exception as _exec_error:
|
||||
# Streaming UIs only learn about tool
|
||||
# activity through this callback; report
|
||||
# the failure before re-raising.
|
||||
self._notify_tool_call(
|
||||
tool_call['function']['name'],
|
||||
parsed_args,
|
||||
{"error": str(_exec_error)},
|
||||
elapsed_time=time_module.perf_counter() - _tool_started,
|
||||
success=False,
|
||||
)
|
||||
raise
|
||||
# Parity with the non-streaming path, which
|
||||
# fires 'tool_call' after every tool run.
|
||||
self._notify_tool_call(
|
||||
tool_result = executor(
|
||||
tool_call['function']['name'],
|
||||
parsed_args,
|
||||
tool_result,
|
||||
elapsed_time=time_module.perf_counter() - _tool_started,
|
||||
success=True,
|
||||
tool_call_id=tool_call.get('id'),
|
||||
**iteration_kwargs,
|
||||
)
|
||||
# Add tool result to chat history (multimodal-aware)
|
||||
from .tool_execution import build_tool_result_message_pair
|
||||
@@ -5450,7 +5235,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
logging.error(f"OpenAI streaming error: {e}")
|
||||
# Fall back to simulated streaming
|
||||
response = self._stream_fallback_chat(prompt, kwargs, e)
|
||||
response = self.chat(prompt, **kwargs)
|
||||
if response:
|
||||
words = str(response).split()
|
||||
chunk_size = max(1, len(words) // 20)
|
||||
@@ -5470,77 +5255,12 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
except Exception as e:
|
||||
# Restore verbose mode on any error
|
||||
self.verbose = original_verbose
|
||||
if getattr(e, '_praisonai_stream_fallback_exhausted', False):
|
||||
# The inner fallback already ran chat() and it failed too.
|
||||
# Running it a second time would only bury the streaming
|
||||
# error one level deeper; surface the chained failure as-is.
|
||||
raise
|
||||
# Graceful fallback to non-streaming if streaming fails
|
||||
logging.warning(f"Streaming failed, falling back to regular response: {e}")
|
||||
response = self._stream_fallback_chat(prompt, kwargs, e)
|
||||
response = self.chat(prompt, **kwargs)
|
||||
if response:
|
||||
yield response
|
||||
|
||||
def _stream_fallback_chat(self, prompt: str, kwargs: Dict[str, Any], streaming_error: BaseException) -> Optional[str]:
|
||||
"""Run the non-streaming ``chat()`` after a streaming attempt failed.
|
||||
|
||||
``start(stream=True)`` hands ``_start_stream_impl`` the caller's raw
|
||||
kwargs with ``stream=True`` already set. Forwarding them to ``chat()``
|
||||
unchanged broke the fallback in two ways (#4719): ``stream=True``
|
||||
reached the sync adapter, which refuses to stream; and any sampling
|
||||
knob ``chat()`` does not declare (``max_tokens``, ``top_p``...) raised
|
||||
``TypeError`` before a request was made. The desktop passes
|
||||
``max_tokens`` on every turn, so its fallback never worked.
|
||||
|
||||
This builds the call from the subset of kwargs ``chat()`` actually
|
||||
accepts (introspected, so subclass overrides are honoured), forces
|
||||
``stream=False``, and -- if the fallback fails as well -- raises that
|
||||
failure chained ``from`` the original streaming exception so the real
|
||||
cause is not lost.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
try:
|
||||
parameters = inspect.signature(self.chat).parameters
|
||||
except (TypeError, ValueError):
|
||||
parameters = {}
|
||||
accepts_var_kwargs = any(
|
||||
p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values()
|
||||
)
|
||||
accepted = {
|
||||
name for name, p in parameters.items()
|
||||
if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
|
||||
}
|
||||
accepted.discard('prompt')
|
||||
|
||||
fallback_kwargs = {
|
||||
key: value for key, value in kwargs.items()
|
||||
if accepts_var_kwargs or key in accepted
|
||||
}
|
||||
dropped = sorted(set(kwargs) - set(fallback_kwargs))
|
||||
if dropped:
|
||||
logging.debug(
|
||||
f"Streaming fallback: dropping kwargs chat() does not accept: {dropped}"
|
||||
)
|
||||
# The fallback is, by definition, the non-streaming path.
|
||||
if accepts_var_kwargs or 'stream' in accepted:
|
||||
fallback_kwargs['stream'] = False
|
||||
else:
|
||||
fallback_kwargs.pop('stream', None)
|
||||
|
||||
try:
|
||||
return self.chat(prompt, **fallback_kwargs)
|
||||
except Exception as fallback_error:
|
||||
logging.error(
|
||||
f"Streaming failed ({streaming_error!r}) and the non-streaming "
|
||||
f"fallback also failed ({fallback_error!r})"
|
||||
)
|
||||
try:
|
||||
fallback_error._praisonai_stream_fallback_exhausted = True
|
||||
except Exception:
|
||||
pass
|
||||
raise fallback_error from streaming_error
|
||||
|
||||
def _create_llm_summarize_function(self):
|
||||
"""
|
||||
Create an async LLM summarization function for context compaction.
|
||||
|
||||
@@ -802,32 +802,21 @@ Write the complete compiled report:"""
|
||||
Args:
|
||||
new_model: The new model name to switch to (e.g., "gpt-4o", "claude-3-sonnet")
|
||||
"""
|
||||
# Serialise against an in-flight sync model fallback (which also swaps
|
||||
# self.llm / self._unified_dispatcher) so the two can't interleave.
|
||||
with self._get_model_fallback_lock().sync():
|
||||
# Store the new model name
|
||||
self.llm = new_model
|
||||
|
||||
# Drop any cached dispatcher bound to the old model so the next
|
||||
# chat()/achat() rebuilds against new_model. Without this, the
|
||||
# OpenAI path keeps dispatching on the old model even though
|
||||
# self.llm now reads the new name (mirrors _apply_default_llm).
|
||||
if getattr(self, '_unified_dispatcher', None) is not None:
|
||||
self._unified_dispatcher = None
|
||||
|
||||
# Recreate the LLM instance with the new model
|
||||
try:
|
||||
from ..llm.llm import LLM
|
||||
self._llm_instance = LLM(
|
||||
model=new_model,
|
||||
base_url=self._openai_base_url,
|
||||
api_key=self._openai_api_key,
|
||||
)
|
||||
self._using_custom_llm = True
|
||||
self._wire_deferred_history_callback()
|
||||
except ImportError:
|
||||
# If LLM class not available, just update the model string
|
||||
pass
|
||||
# Store the new model name
|
||||
self.llm = new_model
|
||||
|
||||
# Recreate the LLM instance with the new model
|
||||
try:
|
||||
from ..llm.llm import LLM
|
||||
self._llm_instance = LLM(
|
||||
model=new_model,
|
||||
base_url=self._openai_base_url,
|
||||
api_key=self._openai_api_key,
|
||||
)
|
||||
self._using_custom_llm = True
|
||||
except ImportError:
|
||||
# If LLM class not available, just update the model string
|
||||
pass
|
||||
|
||||
def start(self, prompt: Optional[str] = None, **kwargs: Any) -> Union[str, Generator[str, None, None], None]:
|
||||
"""Start the agent interactively with verbose output.
|
||||
@@ -1421,27 +1410,10 @@ Write the complete compiled report:"""
|
||||
)
|
||||
if _verdict.get("stuck"):
|
||||
if _verdict.get("level") == "critical":
|
||||
# Async parity with the sync path in tool_execution.py:
|
||||
# route the critical verdict through the unified approval
|
||||
# pipeline (human override / per-project policy) instead
|
||||
# of an unconditional hard stop. Use the native async
|
||||
# helper so an async-only / event-loop-bound approval
|
||||
# backend runs on *this* loop via ``approve_async``,
|
||||
# preserving loop-bound resources — the sync bridge would
|
||||
# otherwise run the backend on a throwaway worker-thread
|
||||
# loop and turn its failure into a spurious denial.
|
||||
_approved = await self._doom_loop_approved_async(
|
||||
function_name, arguments, _verdict,
|
||||
)
|
||||
if _approved:
|
||||
_loop_detection.reset_matching_history(
|
||||
_ld_history, function_name, arguments
|
||||
)
|
||||
else:
|
||||
return {
|
||||
"error": _verdict.get("message", "loop detected"),
|
||||
"loop_blocked": True,
|
||||
}
|
||||
return {
|
||||
"error": _verdict.get("message", "loop detected"),
|
||||
"loop_blocked": True,
|
||||
}
|
||||
elif not getattr(self, '_loop_warned_this_turn', False):
|
||||
self._loop_warned_this_turn = True
|
||||
self._pending_self_correction = (
|
||||
@@ -1725,40 +1697,11 @@ Write the complete compiled report:"""
|
||||
# deny or a tool-call guardrail. The check is pure/sync (no awaits).
|
||||
check = getattr(self, "_check_tool_policy_and_guardrails", None)
|
||||
if check is not None:
|
||||
# ``tools_override`` is threaded through so a run-scoped tool
|
||||
# list cannot bypass the per-tool guardrails its tools declare.
|
||||
policy_result = check(function_name, arguments, tools_override)
|
||||
policy_result = check(function_name, arguments)
|
||||
if isinstance(policy_result, dict):
|
||||
return policy_result # Error dict
|
||||
_, arguments = policy_result
|
||||
|
||||
# MCP tools first — parity with the sync path (_execute_tool_impl).
|
||||
# Without this an MCP-backed tool call hard-fails via achat()/astart().
|
||||
# Only consult MCP when searching the agent's own tools (no override),
|
||||
# matching the sync path which resolves MCP against self.tools.
|
||||
if tools_override is None:
|
||||
resolve_mcp = getattr(self, "_resolve_mcp_tool_result", None)
|
||||
if resolve_mcp is not None:
|
||||
found, mcp_result = resolve_mcp(function_name, arguments)
|
||||
if found:
|
||||
if inspect.isawaitable(mcp_result):
|
||||
mcp_result = await mcp_result
|
||||
# Normalize MCP transport/timeout failures into a native
|
||||
# error dict — parity with the sync path so an async MCP
|
||||
# timeout classifies as a retryable failure instead of
|
||||
# being handed to the model as a bare "Error: ..." string
|
||||
# that looks like a successful result.
|
||||
normalize_mcp = getattr(self, "_normalize_mcp_result", None)
|
||||
if normalize_mcp is not None:
|
||||
mcp_result = normalize_mcp(mcp_result)
|
||||
# Tool-result guardrail gate — the native path applies this
|
||||
# below, but the MCP branch returns early, so gate here too
|
||||
# or successful MCP output would bypass validation entirely.
|
||||
apply_result_guardrails = getattr(self, "_apply_tool_result_guardrails", None)
|
||||
if apply_result_guardrails is not None:
|
||||
mcp_result = apply_result_guardrails(function_name, mcp_result)
|
||||
return mcp_result
|
||||
|
||||
# Try to find the function in the override tools list first, then agent's tools list.
|
||||
# Resolve by BaseTool/FunctionTool ``.name`` (instances like BrowserBaseTool or
|
||||
# aliased decorated tools), plain callable ``__name__``, or class name so async
|
||||
@@ -1777,36 +1720,8 @@ Write the complete compiled report:"""
|
||||
(inspect.isclass(tool) and tool.__name__ == function_name):
|
||||
func = tool
|
||||
break
|
||||
|
||||
# Name self-repair — parity with the sync path. A slightly hallucinated
|
||||
# tool name (e.g. 'WebSearch' for 'web_search') is re-matched instead of
|
||||
# hard-failing. Only over the agent's own active tools (no override).
|
||||
if func is None and tools_override is None:
|
||||
repair = getattr(self, "_self_repair_tool_name", None)
|
||||
if repair is not None:
|
||||
func = repair(function_name)
|
||||
|
||||
|
||||
if func is None:
|
||||
# Corrective, model-readable message with the available tools and a
|
||||
# closest-match hint, mirroring the sync path so the model can retry.
|
||||
available = None
|
||||
if hasattr(self, "_available_active_tool_names"):
|
||||
try:
|
||||
available = self._available_active_tool_names()
|
||||
except Exception:
|
||||
available = None
|
||||
if available is not None:
|
||||
suggestion = None
|
||||
try:
|
||||
import difflib
|
||||
near = difflib.get_close_matches(function_name, available, n=1, cutoff=0.5)
|
||||
suggestion = near[0] if near else None
|
||||
except Exception:
|
||||
pass
|
||||
hint = f" Did you mean '{suggestion}'?" if suggestion else ""
|
||||
error_msg = f"Tool '{function_name}' not found.{hint} Available tools: {available}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg, "available_tools": available}
|
||||
logging.error(f"Function {function_name} not found in tools")
|
||||
return {"error": f"Function {function_name} not found in tools"}
|
||||
|
||||
@@ -1885,42 +1800,17 @@ Write the complete compiled report:"""
|
||||
call_arguments = dict(arguments)
|
||||
call_arguments["idempotency_key"] = durable_key
|
||||
|
||||
# Establish the injection context (Injected[T] params, including
|
||||
# the cooperative cancel_event) around dispatch so the async path
|
||||
# matches the sync execute_tool path. Without this, a long-running
|
||||
# tool (e.g. shell) executed via achat()/astart() never observes an
|
||||
# interrupt and runs until its own timeout. Built lazily and reused
|
||||
# for both the coroutine and executor branches.
|
||||
from ..tools.injected import with_injection_context
|
||||
_inject_state = None
|
||||
if hasattr(self, "_build_agent_state"):
|
||||
try:
|
||||
_inject_state = self._build_agent_state()
|
||||
except Exception:
|
||||
_inject_state = None
|
||||
|
||||
async def _invoke():
|
||||
# Set the tool-progress channel BEFORE dispatch so the sink
|
||||
# propagates into the executor thread via contextvars, mirroring
|
||||
# the sync execute_tool path.
|
||||
if inspect.iscoroutinefunction(call_target):
|
||||
logging.debug(f"Executing async function: {function_name}")
|
||||
if _inject_state is not None:
|
||||
with with_injection_context(_inject_state), tool_progress_channel(_progress_sink):
|
||||
return await call_target(**call_arguments)
|
||||
with tool_progress_channel(_progress_sink):
|
||||
return await call_target(**call_arguments)
|
||||
logging.debug(f"Executing sync function in executor: {function_name}")
|
||||
loop = asyncio.get_running_loop()
|
||||
from ..trace.context_events import copy_context_to_callable
|
||||
# copy_context_to_callable snapshots the CURRENT context (incl.
|
||||
# the injection contextvar) into the executor thread, so setting
|
||||
# it here propagates cancel_event to the sync tool body too.
|
||||
if _inject_state is not None:
|
||||
with with_injection_context(_inject_state), tool_progress_channel(_progress_sink):
|
||||
return await loop.run_in_executor(
|
||||
None, copy_context_to_callable(lambda: call_target(**call_arguments))
|
||||
)
|
||||
with tool_progress_channel(_progress_sink):
|
||||
return await loop.run_in_executor(
|
||||
None, copy_context_to_callable(lambda: call_target(**call_arguments))
|
||||
@@ -2060,14 +1950,6 @@ Write the complete compiled report:"""
|
||||
)
|
||||
breaker_record(function_name, not is_breaker_failure)
|
||||
|
||||
# Tool-result guardrail gate (protocol-driven) — parity with the
|
||||
# sync path in tool_execution.py. Runs on the raw result before it
|
||||
# re-enters the LLM context so a guardrail can inspect or redact
|
||||
# unsafe tool output. Fail-closed. Zero overhead when unset.
|
||||
apply_result_guardrails = getattr(self, "_apply_tool_result_guardrails", None)
|
||||
if apply_result_guardrails is not None:
|
||||
result = apply_result_guardrails(function_name, result, tools_override)
|
||||
|
||||
# Loop guard (post-execution) — record the outcome and surface a
|
||||
# block/halt decision back to the model on this same turn, mirroring
|
||||
# the sync path in tool_execution.py so repeated failures accumulate
|
||||
|
||||
@@ -6,26 +6,14 @@ operations. Follows protocol-driven design with zero overhead when not used.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from typing import Optional, Protocol, Set, runtime_checkable
|
||||
from typing import Optional, Protocol, Set
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
__all__ = [
|
||||
"InterruptControllerProtocol",
|
||||
"CancellableInterruptControllerProtocol",
|
||||
"InterruptController",
|
||||
]
|
||||
__all__ = ["InterruptControllerProtocol", "InterruptController"]
|
||||
|
||||
|
||||
class InterruptControllerProtocol(Protocol):
|
||||
"""Protocol for interrupt controller extension point.
|
||||
|
||||
``event`` is intentionally NOT part of this base contract: exposing the
|
||||
underlying cancellation Event is an OPTIONAL capability. Existing third-party
|
||||
controllers that only implement request/clear/is_set/reason/check remain
|
||||
valid; the runtime probes for ``event`` via ``getattr(..., 'event', None)``.
|
||||
Controllers that want in-tool cooperative abort can implement
|
||||
:class:`CancellableInterruptControllerProtocol`.
|
||||
"""
|
||||
"""Protocol for interrupt controller extension point."""
|
||||
|
||||
def request(self, reason: str = "user") -> None:
|
||||
"""Request cancellation with optional reason."""
|
||||
@@ -43,27 +31,12 @@ class InterruptControllerProtocol(Protocol):
|
||||
def reason(self) -> Optional[str]:
|
||||
"""Get interrupt reason if set."""
|
||||
...
|
||||
|
||||
|
||||
def check(self) -> None:
|
||||
"""Check for interrupt and raise if set."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CancellableInterruptControllerProtocol(InterruptControllerProtocol, Protocol):
|
||||
"""Optional extension: controllers that expose the cancellation Event.
|
||||
|
||||
A running tool body can wait on / poll this Event to abort promptly instead
|
||||
of running until its own timeout. Implementing it is entirely optional and
|
||||
fully backward compatible with :class:`InterruptControllerProtocol`.
|
||||
"""
|
||||
|
||||
@property
|
||||
def event(self) -> "threading.Event":
|
||||
"""Get the underlying cancellation Event for in-tool observation."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterruptController:
|
||||
"""Thread-safe cooperative cancellation for agent runs.
|
||||
@@ -122,19 +95,10 @@ class InterruptController:
|
||||
return turn_id in self._cancelled_turns
|
||||
|
||||
def _end_turn(self, turn_id: int) -> None:
|
||||
"""Unregister a turn so its request cannot affect a future turn.
|
||||
|
||||
Also clears the underlying Event once no turn still needs it (no active
|
||||
or still-cancelled turns and no pending request). The Event is exposed to
|
||||
running tool bodies via :attr:`event`; leaving it set after an interrupted
|
||||
turn would make a reused controller abort tools launched by the NEXT turn.
|
||||
"""
|
||||
"""Unregister a turn so its request cannot affect a future turn."""
|
||||
with self._lock:
|
||||
self._active_turns.discard(turn_id)
|
||||
self._cancelled_turns.discard(turn_id)
|
||||
if not self._active_turns and not self._cancelled_turns and not self._pending_turn_cancel:
|
||||
self._flag.clear()
|
||||
self._reason = None
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the cancellation request."""
|
||||
@@ -162,16 +126,6 @@ class InterruptController:
|
||||
with self._lock:
|
||||
return self._reason
|
||||
|
||||
@property
|
||||
def event(self) -> threading.Event:
|
||||
"""Expose the underlying cancellation Event.
|
||||
|
||||
Lets a running tool body observe the interrupt cooperatively (e.g. wait
|
||||
on it with a timeout, or poll ``is_set()``) so an in-flight subprocess/
|
||||
request can be aborted rather than left running until its own timeout.
|
||||
"""
|
||||
return self._flag
|
||||
|
||||
def check(self) -> None:
|
||||
"""Check for cancellation and raise if requested.
|
||||
|
||||
|
||||
@@ -9,9 +9,8 @@ for maintainability.
|
||||
import os
|
||||
import inspect
|
||||
import logging
|
||||
import threading
|
||||
import contextvars
|
||||
from typing import Optional, Dict
|
||||
from typing import Optional
|
||||
|
||||
# Per-turn ownership of appended chat-history messages. Each chat()/achat() turn
|
||||
# runs in its own thread (sync) or task (async); both get an isolated copy of
|
||||
@@ -22,38 +21,6 @@ _active_turn_owned: "contextvars.ContextVar[Optional[list]]" = contextvars.Conte
|
||||
"praisonai_active_turn_owned", default=None
|
||||
)
|
||||
|
||||
# Registry of the ownership lists of every currently in-flight chat()/achat()
|
||||
# turn, keyed by id() of the list. A turn registers its list in
|
||||
# _begin_turn_tracking and removes it in _end_turn_tracking. ephemeral() reads
|
||||
# this at exit so a message a *concurrent live turn* appended is preserved even
|
||||
# when that append did not flow through the ephemeral tracker (e.g. the turn
|
||||
# ran on another thread that never entered this block's context). Guarded by
|
||||
# _live_turns_lock because turns on different threads mutate it concurrently.
|
||||
_live_turn_owners: "Dict[int, list]" = {}
|
||||
_live_turns_lock = threading.Lock()
|
||||
|
||||
|
||||
def _register_live_turn(owned: list) -> None:
|
||||
with _live_turns_lock:
|
||||
_live_turn_owners[id(owned)] = owned
|
||||
|
||||
|
||||
def _unregister_live_turn(owned: Optional[list]) -> None:
|
||||
if owned is None:
|
||||
return
|
||||
with _live_turns_lock:
|
||||
_live_turn_owners.pop(id(owned), None)
|
||||
|
||||
|
||||
def _live_turn_message_ids() -> set:
|
||||
"""ids of every message any currently in-flight turn has appended so far."""
|
||||
with _live_turns_lock:
|
||||
owners = list(_live_turn_owners.values())
|
||||
ids = set()
|
||||
for owned in owners:
|
||||
ids.update(id(m) for m in owned)
|
||||
return ids
|
||||
|
||||
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
|
||||
from ._lazy_display import _get_console, _get_live, _get_display_functions
|
||||
|
||||
@@ -154,18 +121,11 @@ class MemoryMixin:
|
||||
runs in its own thread (sync ``chat``) or task (async ``achat``), so the
|
||||
context var copy is isolated per turn. Safe to call unconditionally; when
|
||||
a turn opts in, its rollback removes only its own messages by identity.
|
||||
|
||||
The list is also registered in the process-wide live-turn registry so a
|
||||
concurrent ``ephemeral()`` block on another thread can preserve messages
|
||||
this in-flight turn appends (see ``_live_turn_message_ids``).
|
||||
"""
|
||||
owned: list = []
|
||||
_register_live_turn(owned)
|
||||
return _active_turn_owned.set(owned)
|
||||
return _active_turn_owned.set([])
|
||||
|
||||
def _end_turn_tracking(self, token):
|
||||
"""Stop recording for the current turn and drop its ownership list."""
|
||||
_unregister_live_turn(_active_turn_owned.get())
|
||||
try:
|
||||
_active_turn_owned.reset(token)
|
||||
except (ValueError, LookupError):
|
||||
@@ -176,10 +136,8 @@ class MemoryMixin:
|
||||
|
||||
Used at the start of an async turn (whose control flow has no single
|
||||
wrapping ``finally``) so a stale list from a completed turn on the same
|
||||
task cannot accumulate messages from unrelated turns. Also unregisters
|
||||
that stale list from the live-turn registry so it cannot linger there.
|
||||
task cannot accumulate messages from unrelated turns.
|
||||
"""
|
||||
_unregister_live_turn(_active_turn_owned.get())
|
||||
_active_turn_owned.set(None)
|
||||
|
||||
def _rollback_chat_history_to(self, rollback_length):
|
||||
@@ -204,7 +162,7 @@ class MemoryMixin:
|
||||
"""
|
||||
owned = _active_turn_owned.get()
|
||||
with self._history_lock:
|
||||
if owned is not None:
|
||||
if owned:
|
||||
owned_ids = {id(m) for m in owned}
|
||||
self.chat_history[:] = [
|
||||
m for m in self.chat_history if id(m) not in owned_ids
|
||||
@@ -242,12 +200,9 @@ class MemoryMixin:
|
||||
Also resets _auto_save_last_index to prevent silent message loss
|
||||
when auto_save is enabled.
|
||||
"""
|
||||
# Hold the same lock every other history mutator takes so a concurrent
|
||||
# _add_to_chat_history append can't land on the list object we discard.
|
||||
with self._history_lock:
|
||||
self.chat_history = []
|
||||
# Reset auto-save index to prevent stale index causing message loss
|
||||
self._auto_save_last_index = 0
|
||||
self.chat_history = []
|
||||
# Reset auto-save index to prevent stale index causing message loss
|
||||
self._auto_save_last_index = 0
|
||||
|
||||
def prune_history(self, keep_last: int = 5) -> int:
|
||||
"""
|
||||
@@ -341,32 +296,16 @@ class MemoryMixin:
|
||||
response = agent.chat("[IMAGE] Analyze this")
|
||||
# After block, history is restored - image NOT persisted
|
||||
"""
|
||||
# Restore by identity, not by a blind ``chat_history = saved_history``.
|
||||
# A message a concurrent chat()/achat() turn on the same shared Agent
|
||||
# commits during the block must survive; only the ephemeral additions go.
|
||||
#
|
||||
# A message is KEPT on exit when it is either:
|
||||
# * present in the pre-block snapshot (it predates the block), or
|
||||
# * owned by a chat()/achat() turn that is still in flight (a live
|
||||
# concurrent turn appended it - preserving it is the fix for the
|
||||
# history-loss race).
|
||||
# Everything else that appeared during the block is an ephemeral addition
|
||||
# and is removed. This also drops direct ``chat_history.append`` writes
|
||||
# made inside the block, which carry no turn ownership. Nested blocks
|
||||
# compose: the inner block removes its own additions first, and the outer
|
||||
# block's snapshot still holds everything that predates it.
|
||||
# Save current history state
|
||||
with self._history_lock:
|
||||
snapshot_ids = {id(m) for m in self.chat_history}
|
||||
|
||||
saved_history = self.chat_history.copy()
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
live_ids = _live_turn_message_ids()
|
||||
keep_ids = snapshot_ids | live_ids
|
||||
# Restore history to pre-block state
|
||||
with self._history_lock:
|
||||
self.chat_history[:] = [
|
||||
m for m in self.chat_history if id(m) in keep_ids
|
||||
]
|
||||
self.chat_history = saved_history
|
||||
|
||||
def _init_db_session(self):
|
||||
"""Initialize DB session if db adapter is provided (lazy, first chat only)."""
|
||||
@@ -791,16 +730,11 @@ class MemoryMixin:
|
||||
# Reset counter and emit nudge
|
||||
self._turns_since_nudge = 0
|
||||
|
||||
# Optional tool-work floor (Issue #4864): ``nudge_min_tool_iters`` is a
|
||||
# tunable override, not a hard floor. When it is <= 0 the cadence fires
|
||||
# on conversational (tool-less) turns too, so a chat-only companion bot
|
||||
# still learns; a positive value keeps the "only nudge after real tool
|
||||
# work" behaviour for task-oriented agents.
|
||||
# Optional: only nudge if agent has done meaningful work
|
||||
min_iters = getattr(self._learn_config, 'nudge_min_tool_iters', 3)
|
||||
if min_iters > 0:
|
||||
tool_calls_count = getattr(self, '_recent_tool_calls_count', 0)
|
||||
if tool_calls_count < min_iters:
|
||||
return None
|
||||
tool_calls_count = getattr(self, '_recent_tool_calls_count', 0)
|
||||
if tool_calls_count < min_iters:
|
||||
return None
|
||||
|
||||
return (
|
||||
"\n\n[System nudge] Review the recent conversation. If you discovered a "
|
||||
|
||||
@@ -113,20 +113,11 @@ def resolve_placement(
|
||||
TypeError: on any combination that names two different places for the
|
||||
same thing, or a place that cannot do the job asked of it.
|
||||
"""
|
||||
managed = managed_runtimes()
|
||||
places = tool_places()
|
||||
|
||||
# ── a place that cannot do the job asked of it ───────────────────────────
|
||||
run_on_name = _name_of(run_on)
|
||||
tools_name = _name_of(tools_run_on)
|
||||
|
||||
# The two lookups below feed only the branches guarded on run_on_name /
|
||||
# tools_name being set; the entry-point scan in tool_places() is the
|
||||
# dominant cost of Agent() construction, so skip both on the default
|
||||
# (no-placement) path rather than compute results that get discarded.
|
||||
if run_on_name is not None or tools_name is not None:
|
||||
managed = managed_runtimes()
|
||||
places = tool_places()
|
||||
else:
|
||||
managed = places = ()
|
||||
|
||||
if run_on_name is not None and run_on_name not in managed:
|
||||
if run_on_name in places:
|
||||
raise TypeError(
|
||||
@@ -144,6 +135,7 @@ def resolve_placement(
|
||||
f"(pass those as tools_run_on=)"
|
||||
)
|
||||
|
||||
tools_name = _name_of(tools_run_on)
|
||||
if tools_name is not None and tools_name in managed and tools_name not in places:
|
||||
raise TypeError(
|
||||
f"{owner}(tools_run_on={tools_run_on!r}) is not valid: {tools_name!r} "
|
||||
|
||||
@@ -436,50 +436,6 @@ class ToolExecutionMixin:
|
||||
logging.debug(f"Type casting failed for {getattr(func, '__name__', 'unknown function')}: {e}")
|
||||
return arguments
|
||||
|
||||
def _current_cancel_event(self):
|
||||
"""Return a cancellation Event a running tool body can observe, or None.
|
||||
|
||||
Sourced from the agent's ``interrupt_controller`` when present. The event
|
||||
is only meaningful while a cancellation is actually in effect for the
|
||||
current turn — the controller clears its underlying flag once an
|
||||
interrupted turn ends (see ``InterruptController._end_turn``), so a stale
|
||||
request from a previous turn does not abort newly started tool bodies.
|
||||
A controller without an ``event`` member yields ``None`` (fully optional).
|
||||
"""
|
||||
controller = getattr(self, 'interrupt_controller', None)
|
||||
if controller is None:
|
||||
return None
|
||||
return getattr(controller, 'event', None)
|
||||
|
||||
def _build_agent_state(self):
|
||||
"""Construct the AgentState injected into tools for the current call.
|
||||
|
||||
Shared by the sync (``execute_tool``) and async
|
||||
(``_execute_tool_async_impl``) paths so both establish an identical
|
||||
injection context — including the cooperative ``cancel_event`` — avoiding
|
||||
drift where one path silently omits injected state.
|
||||
"""
|
||||
from ..tools.injected import AgentState
|
||||
try:
|
||||
from .durable import get_durable_idempotency_key
|
||||
|
||||
idempotency_key = get_durable_idempotency_key()
|
||||
except ImportError:
|
||||
idempotency_key = None
|
||||
state_metadata = {'agent_name': self.name}
|
||||
if idempotency_key:
|
||||
state_metadata['idempotency_key'] = idempotency_key
|
||||
return AgentState(
|
||||
agent_id=self.name,
|
||||
run_id=getattr(self, '_current_run_id', 'unknown'),
|
||||
session_id=getattr(self, '_session_id', None) or 'default',
|
||||
last_user_message=self.chat_history[-1].get('content') if self.chat_history else None,
|
||||
memory=getattr(self, '_memory_instance', None),
|
||||
learn_manager=getattr(getattr(self, '_memory_instance', None), 'learn', None),
|
||||
metadata=state_metadata,
|
||||
cancel_event=self._current_cancel_event(),
|
||||
)
|
||||
|
||||
def execute_tool(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None) -> Any:
|
||||
"""
|
||||
Execute a tool dynamically based on the function name and arguments.
|
||||
@@ -503,8 +459,25 @@ class ToolExecutionMixin:
|
||||
# Do NOT call it here to avoid duplicate output
|
||||
|
||||
# Set up injection context for tools with Injected parameters
|
||||
state = self._build_agent_state()
|
||||
cancel_event = state.cancel_event
|
||||
from ..tools.injected import AgentState
|
||||
try:
|
||||
from .durable import get_durable_idempotency_key
|
||||
|
||||
idempotency_key = get_durable_idempotency_key()
|
||||
except ImportError:
|
||||
idempotency_key = None
|
||||
state_metadata = {'agent_name': self.name}
|
||||
if idempotency_key:
|
||||
state_metadata['idempotency_key'] = idempotency_key
|
||||
state = AgentState(
|
||||
agent_id=self.name,
|
||||
run_id=getattr(self, '_current_run_id', 'unknown'),
|
||||
session_id=getattr(self, '_session_id', None) or 'default',
|
||||
last_user_message=self.chat_history[-1].get('content') if self.chat_history else None,
|
||||
memory=getattr(self, '_memory_instance', None),
|
||||
learn_manager=getattr(getattr(self, '_memory_instance', None), 'learn', None),
|
||||
metadata=state_metadata,
|
||||
)
|
||||
|
||||
# Route through user-supplied tool middleware (Agent(hooks=[...])) when
|
||||
# present. Zero overhead when no hooks: the fast path calls straight
|
||||
@@ -523,7 +496,6 @@ class ToolExecutionMixin:
|
||||
session_id=getattr(self, '_session_id', None) or 'default',
|
||||
tool_name=function_name,
|
||||
metadata={"tool_call_id": tool_call_id},
|
||||
cancel_event=cancel_event,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -983,13 +955,6 @@ class ToolExecutionMixin:
|
||||
)
|
||||
time.sleep(delay)
|
||||
|
||||
# Tool-result guardrail gate (protocol-driven). Runs on the raw
|
||||
# result before any middleware/trust-wrapping/hooks so a guardrail
|
||||
# can inspect or redact unsafe tool output (a leaked secret, a
|
||||
# prompt-injection payload) before it re-enters the LLM context.
|
||||
# Fail-closed. Zero overhead when unset.
|
||||
result = self._apply_tool_result_guardrails(function_name, result)
|
||||
|
||||
# Apply runtime-scoped middleware normalization BEFORE hooks fire
|
||||
# Plugin harnesses can register middleware to normalize vendor-specific results
|
||||
runtime_id = getattr(self, '_runtime_id', 'praisonai') # Default to native runtime
|
||||
@@ -1682,50 +1647,6 @@ class ToolExecutionMixin:
|
||||
or (trust_level == "external") # External tools need approval
|
||||
)
|
||||
if needs_approval:
|
||||
# Consult the registry's standing grants before prompting.
|
||||
# This branch used to go straight to the backend, skipping
|
||||
# is_env_auto_approve / is_yaml_approved / is_auto_approved /
|
||||
# is_already_approved entirely -- and it is the DEFAULT branch,
|
||||
# since a bare Agent() on a TTY installs a ConsoleBackend. So
|
||||
# PRAISONAI_AUTO_APPROVE=true still prompted (measured: 3 of 3
|
||||
# identical calls), a YAML approval was ignored, and an
|
||||
# "approve for this session" grant was never remembered.
|
||||
agent_name_for_registry = getattr(self, 'name', None)
|
||||
scope_id = getattr(self, '_approval_scope_id', None) or agent_name_for_registry
|
||||
try:
|
||||
standing_grant = (
|
||||
approval_registry.is_env_auto_approve()
|
||||
or approval_registry.is_yaml_approved(tool_name)
|
||||
or approval_registry.is_auto_approved(tool_name, scope_id)
|
||||
or approval_registry.is_already_approved(
|
||||
tool_name, tool_args, scope_id
|
||||
)
|
||||
# A "[s] this session" grant is stored by reusable
|
||||
# permission target, not by exact arguments, so a later
|
||||
# call with different args (same target, e.g. the same
|
||||
# ``bash:git status *`` prefix) is covered too. Without
|
||||
# this check the attached backend re-prompts for a call
|
||||
# the user already blessed for the session -- the exact
|
||||
# re-prompting this PR set out to stop.
|
||||
or approval_registry._is_session_scoped(
|
||||
agent_name_for_registry, tool_name, tool_args, scope_id
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a grant lookup must never
|
||||
# block the call; fall through and ask, which is the safe
|
||||
# direction to fail in.
|
||||
standing_grant = False
|
||||
if standing_grant:
|
||||
decision = ApprovalDecision(
|
||||
approved=True,
|
||||
reason="Approved by a standing registry grant",
|
||||
)
|
||||
if is_async:
|
||||
async def _granted():
|
||||
return decision
|
||||
return _granted()
|
||||
return decision
|
||||
|
||||
# Attach a rendered unified diff for file-mutating tools so the
|
||||
# reviewer approves the actual change rather than a truncated
|
||||
# argument dump. Non-edit tools get no diff (``None``) and keep
|
||||
@@ -1743,46 +1664,19 @@ class ToolExecutionMixin:
|
||||
context={"diff": diff_preview} if diff_preview else {},
|
||||
)
|
||||
|
||||
# Record a backend decision back into the registry so a
|
||||
# "[s] this session" (or "always") grant the user gives at the
|
||||
# prompt is remembered -- exactly as the no-backend
|
||||
# registry.approve_* path does via _persist_scoped_decision.
|
||||
# Without this, the fast-path _is_session_scoped check above can
|
||||
# never match, and the user is re-prompted on every matching
|
||||
# call for the rest of the run. Best-effort: a bookkeeping error
|
||||
# must never change or block the returned decision.
|
||||
def _remember(decision):
|
||||
try:
|
||||
if getattr(decision, "approved", False):
|
||||
approval_registry.mark_approved(
|
||||
tool_name, tool_args, agent_name_for_registry, scope_id
|
||||
)
|
||||
approval_registry._persist_scoped_decision(
|
||||
agent_name_for_registry, tool_name, tool_args,
|
||||
decision, scope_id,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - bookkeeping only
|
||||
pass
|
||||
return decision
|
||||
|
||||
if is_async:
|
||||
# Async path - return the coroutine for caller to await
|
||||
cfg_timeout = getattr(self, '_approval_timeout', 0)
|
||||
|
||||
async def _ask_backend_async():
|
||||
if cfg_timeout is None:
|
||||
decision = await backend.request_approval(request)
|
||||
elif cfg_timeout > 0:
|
||||
import asyncio
|
||||
decision = await asyncio.wait_for(
|
||||
backend.request_approval(request),
|
||||
timeout=cfg_timeout,
|
||||
)
|
||||
else:
|
||||
decision = await backend.request_approval(request)
|
||||
return _remember(decision)
|
||||
|
||||
return _ask_backend_async()
|
||||
if cfg_timeout is None:
|
||||
return backend.request_approval(request)
|
||||
elif cfg_timeout > 0:
|
||||
import asyncio
|
||||
return asyncio.wait_for(
|
||||
backend.request_approval(request),
|
||||
timeout=cfg_timeout,
|
||||
)
|
||||
else:
|
||||
return backend.request_approval(request)
|
||||
else:
|
||||
# Sync path - handle timeout and sync/async backend compatibility
|
||||
cfg_timeout = getattr(self, '_approval_timeout', 0)
|
||||
@@ -1798,7 +1692,7 @@ class ToolExecutionMixin:
|
||||
|
||||
try:
|
||||
if hasattr(backend, 'request_approval_sync'):
|
||||
return _remember(backend.request_approval_sync(request))
|
||||
return backend.request_approval_sync(request)
|
||||
else:
|
||||
# Use the shared utility to avoid code duplication and handle timeout correctly
|
||||
from ..approval.utils import run_coroutine_safely
|
||||
@@ -1812,10 +1706,10 @@ class ToolExecutionMixin:
|
||||
# cfg_timeout == 0: use backend default or fallback
|
||||
effective_timeout = getattr(backend, '_timeout', 60)
|
||||
|
||||
return _remember(run_coroutine_safely(
|
||||
return run_coroutine_safely(
|
||||
backend.request_approval(request),
|
||||
timeout=effective_timeout
|
||||
))
|
||||
)
|
||||
finally:
|
||||
if orig_timeout is not None and hasattr(backend, '_timeout'):
|
||||
backend._timeout = orig_timeout
|
||||
@@ -1936,61 +1830,6 @@ class ToolExecutionMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
async def _doom_loop_approved_async(self, function_name, arguments, verdict) -> bool:
|
||||
"""Async twin of :meth:`_doom_loop_approved`.
|
||||
|
||||
Identical decision semantics, but routes the backend prompt through the
|
||||
registry's native ``approve_async`` so an async-only or event-loop-bound
|
||||
approval backend runs on the *caller's* loop (preserving loop-bound
|
||||
resources) instead of the sync bridge's throwaway worker-thread loop.
|
||||
Every non-allow outcome — deny, timeout, no backend, or any error —
|
||||
returns ``False`` so the caller falls back to the historical hard-stop.
|
||||
"""
|
||||
try:
|
||||
from ..approval import get_approval_registry
|
||||
from ..approval.registry import DOOM_LOOP_TARGET
|
||||
|
||||
manager = getattr(self, "_permission_manager", None)
|
||||
if manager is not None:
|
||||
try:
|
||||
from ..permissions import PermissionAction
|
||||
action = manager.resolve_tool_action(
|
||||
"doom_loop", getattr(self, "name", None)
|
||||
)
|
||||
if action == PermissionAction.ALLOW:
|
||||
return True
|
||||
if action == PermissionAction.DENY:
|
||||
return False
|
||||
except Exception as e: # noqa: BLE001
|
||||
logging.debug(
|
||||
"doom_loop permission-manager resolve failed (%s); "
|
||||
"falling through to backend", e,
|
||||
)
|
||||
|
||||
registry = get_approval_registry()
|
||||
request_args = {
|
||||
"tool": function_name,
|
||||
"detector": verdict.get("detector"),
|
||||
"count": verdict.get("count"),
|
||||
"args_fingerprint": self._doom_loop_args_fingerprint(
|
||||
function_name, arguments
|
||||
),
|
||||
}
|
||||
decision = await registry.approve_async(
|
||||
getattr(self, "name", None),
|
||||
DOOM_LOOP_TARGET,
|
||||
request_args,
|
||||
force=True,
|
||||
scope_id=getattr(self, "_approval_scope_id", None),
|
||||
)
|
||||
return bool(getattr(decision, "approved", False))
|
||||
except Exception as e: # noqa: BLE001 — fail closed to the block path
|
||||
logging.debug(
|
||||
"doom_loop async approval routing failed for %s (%s); blocking",
|
||||
function_name, e,
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _doom_loop_args_fingerprint(function_name, arguments) -> str:
|
||||
"""Stable, immutable fingerprint of the looping (tool, args) pair."""
|
||||
@@ -2634,124 +2473,7 @@ class ToolExecutionMixin:
|
||||
"remediation": "Wait for recovery_timeout (60s) or investigate recent tool failures.",
|
||||
}
|
||||
|
||||
def _find_declared_tool(self, function_name, tools=None):
|
||||
"""Return the tool OBJECT named ``function_name``, or ``None``.
|
||||
|
||||
Per-tool guardrails are declared on the tool the user wrote
|
||||
(``@tool(input_guardrails=...)``), so gating them needs the object, not
|
||||
just the name. Searched over the agent's own tools (or the caller's
|
||||
``tools_override``, so a run-scoped tool list cannot silently bypass its
|
||||
own guardrails). Deliberately does NOT fall back to the global tool
|
||||
registry: a guardrail must come from the tool this agent was actually
|
||||
given, never from a same-named tool registered elsewhere.
|
||||
"""
|
||||
candidates = tools if tools is not None else getattr(self, "tools", None)
|
||||
if not isinstance(candidates, (list, tuple)):
|
||||
return None
|
||||
for tool_obj in candidates:
|
||||
if getattr(tool_obj, "name", None) == function_name:
|
||||
return tool_obj
|
||||
if getattr(tool_obj, "__name__", None) == function_name:
|
||||
return tool_obj
|
||||
return None
|
||||
|
||||
def _check_per_tool_input_guardrails(self, function_name, arguments, tools=None):
|
||||
"""Run the guardrails declared on THIS tool against its arguments.
|
||||
|
||||
ORDERING (deliberate, see also ``_apply_per_tool_output_guardrails``):
|
||||
this runs LAST on the way in - after the human approval gate, after the
|
||||
agent-wide PolicyEngine, and after any agent-wide tool-call guardrail -
|
||||
so it sits immediately before dispatch. That is the safest order for two
|
||||
reasons:
|
||||
|
||||
1. Nothing can rewrite the arguments between this verdict and the call.
|
||||
A guardrail that approved ``send_email(to=alice@corp.com)`` and then
|
||||
had the arguments rewritten underneath it would be a guardrail in
|
||||
name only, and the rewrite is exactly the capability an attacker-
|
||||
steered agent-wide component would reach for. "The tool never runs
|
||||
with arguments its own guardrail did not see" is the whole promise of
|
||||
this feature, so it is the invariant that gets protected.
|
||||
2. Human approval stays *upstream* of every automated rewrite. The human
|
||||
is auditing what the MODEL proposed; if a guardrail could sanitise
|
||||
the arguments first, an approval prompt would show a call the model
|
||||
never made, and a rewriting guardrail could launder a call past human
|
||||
review.
|
||||
|
||||
Returns an error dict when blocked (never raises), or ``(None, arguments)``
|
||||
with the possibly-rewritten arguments when allowed. Zero overhead for
|
||||
tools that declared no guardrail: one attribute read, then out.
|
||||
"""
|
||||
tool_obj = self._find_declared_tool(function_name, tools)
|
||||
if tool_obj is None:
|
||||
return None, arguments
|
||||
from ..guardrails.tool_guardrails import get_tool_guardrail_chain, INPUT
|
||||
chain = get_tool_guardrail_chain(tool_obj, INPUT)
|
||||
if chain is None:
|
||||
return None, arguments
|
||||
is_valid, processed = chain.validate_tool_call(function_name, arguments)
|
||||
if not is_valid:
|
||||
# Hand the reason back to the MODEL as this tool's result so it can
|
||||
# react (pick different arguments, choose another tool, explain to
|
||||
# the user). Raising here would abort the run and surface a stack
|
||||
# trace to the user for what is a normal, expected policy outcome.
|
||||
logging.warning(
|
||||
f"Tool '{function_name}' blocked by its input guardrail: {processed}"
|
||||
)
|
||||
return {
|
||||
"error": (
|
||||
f"Tool '{function_name}' was blocked by an input guardrail: "
|
||||
f"{processed}"
|
||||
),
|
||||
"guardrail_denied": True,
|
||||
"tool_guardrail": "input",
|
||||
}
|
||||
if isinstance(processed, dict):
|
||||
arguments = processed
|
||||
return None, arguments
|
||||
|
||||
def _apply_per_tool_output_guardrails(self, function_name, result, tools=None):
|
||||
"""Run the guardrails declared on THIS tool against its raw result.
|
||||
|
||||
ORDERING (deliberate): this runs FIRST on the way out - before any
|
||||
agent-wide tool-result guardrail and before the ``trust_level``
|
||||
external-content fence (``tools.trust.wrap_if_external``) and output
|
||||
truncation. Mirroring the input side, the per-tool guardrail is the
|
||||
layer closest to the tool in both directions:
|
||||
|
||||
* It sees the RAW result. If the trust fence ran first, every guardrail
|
||||
would have to parse and strip ``<external_tool_result>`` markers to
|
||||
find the content it cares about, and a guardrail that redacts by
|
||||
rewriting could damage or forge the fence.
|
||||
* The fence therefore stays outermost, wrapping whatever the guardrails
|
||||
finally allow, so a substituted result is fenced exactly like an
|
||||
original one and cannot escape the injection markers.
|
||||
|
||||
Returns the (possibly substituted) result, or an error dict tagged
|
||||
``guardrail_denied`` when blocked. Never raises.
|
||||
"""
|
||||
tool_obj = self._find_declared_tool(function_name, tools)
|
||||
if tool_obj is None:
|
||||
return result
|
||||
from ..guardrails.tool_guardrails import get_tool_guardrail_chain, OUTPUT
|
||||
chain = get_tool_guardrail_chain(tool_obj, OUTPUT)
|
||||
if chain is None:
|
||||
return result
|
||||
is_valid, processed = chain.validate_tool_result(function_name, result)
|
||||
if not is_valid:
|
||||
logging.warning(
|
||||
f"Tool '{function_name}' result blocked by its output guardrail: {processed}"
|
||||
)
|
||||
return {
|
||||
"error": (
|
||||
f"Tool '{function_name}' result was blocked by an output "
|
||||
f"guardrail: {processed}"
|
||||
),
|
||||
"guardrail_denied": True,
|
||||
"tool_guardrail": "output",
|
||||
}
|
||||
return processed
|
||||
|
||||
def _check_tool_policy_and_guardrails(self, function_name, arguments, tools=None):
|
||||
def _check_tool_policy_and_guardrails(self, function_name, arguments):
|
||||
"""Gate a tool call through the attached PolicyEngine and tool guardrails.
|
||||
|
||||
Consults ``self._policy`` (a ``PolicyEngine``) via ``check_tool`` and any
|
||||
@@ -2811,92 +2533,8 @@ class ToolExecutionMixin:
|
||||
}
|
||||
if isinstance(processed, dict):
|
||||
arguments = processed
|
||||
|
||||
# Per-tool guardrails run LAST, immediately before dispatch, so nothing
|
||||
# can rewrite the arguments between the tool's own verdict and the call.
|
||||
# See _check_per_tool_input_guardrails for the full ordering rationale.
|
||||
per_tool = self._check_per_tool_input_guardrails(function_name, arguments, tools)
|
||||
if isinstance(per_tool, dict):
|
||||
return per_tool # Error dict -> handed back to the model
|
||||
_, arguments = per_tool
|
||||
|
||||
return None, arguments
|
||||
|
||||
def _apply_tool_result_guardrails(self, function_name, result, tools=None):
|
||||
"""Gate a tool's raw result through any tool-result guardrails.
|
||||
|
||||
Runs after execution and before the result re-enters the LLM context so
|
||||
a guardrail can inspect or redact unsafe tool output (a leaked secret, a
|
||||
prompt-injection payload) that ``validate_output`` never sees. Returns
|
||||
the (possibly rewritten) result when allowed, or an error dict tagged
|
||||
``guardrail_denied`` when rejected. Fail-closed on guardrail error,
|
||||
mirroring ``_check_tool_policy_and_guardrails``. Zero overhead when no
|
||||
tool-result guardrail is set. Denial error dicts pass straight through
|
||||
untouched so a gated/failed tool is not re-inspected.
|
||||
|
||||
Order: per-tool guardrails (``@tool(output_guardrails=...)``) run first,
|
||||
then agent-wide ones, then - back in the caller - the ``trust_level``
|
||||
external-content fence and truncation. The tool's own validator is
|
||||
therefore closest to the tool and always sees raw, unfenced output.
|
||||
"""
|
||||
guardrails = getattr(self, "_tool_result_guardrails", None)
|
||||
# NOTE: the early return here used to be ``if not guardrails: return``.
|
||||
# Per-tool output guardrails are declared on the TOOL, not on the agent,
|
||||
# so an agent with no agent-wide result guardrail must still run them -
|
||||
# bailing out on the agent-wide list alone would make
|
||||
# ``@tool(output_guardrails=...)`` silently dead for the common case.
|
||||
if not guardrails and self._find_declared_tool(function_name, tools) is None:
|
||||
return result
|
||||
# Only skip results the framework itself already gated/denied — those
|
||||
# carry an explicit control marker and were never real tool output. An
|
||||
# ordinary tool-authored ``{"error": ...}`` (a crawl result with both
|
||||
# ``error`` and ``content``, a shell tool's recoverable failure) is still
|
||||
# untrusted content that can carry a secret or an injection payload, so
|
||||
# it MUST be validated like any other result.
|
||||
if isinstance(result, dict) and (
|
||||
result.get("guardrail_denied")
|
||||
or result.get("policy_denied")
|
||||
or result.get("approval_denied")
|
||||
or result.get("permission_denied")
|
||||
or result.get("approval_error")
|
||||
):
|
||||
return result
|
||||
|
||||
# Per-tool guardrails run FIRST on the way out, so the tool's own
|
||||
# validator sees the raw result before any agent-wide guardrail rewrites
|
||||
# it and before the trust fence wraps it. See
|
||||
# _apply_per_tool_output_guardrails for the full ordering rationale.
|
||||
result = self._apply_per_tool_output_guardrails(function_name, result, tools)
|
||||
if isinstance(result, dict) and result.get("guardrail_denied"):
|
||||
return result
|
||||
|
||||
for guardrail in guardrails or []:
|
||||
validate = getattr(guardrail, "validate_tool_result", None)
|
||||
if validate is None:
|
||||
continue
|
||||
try:
|
||||
is_valid, processed = validate(function_name, result)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Fail closed: a guardrail dependency/implementation error must
|
||||
# block, not deliver, the unchecked result.
|
||||
logging.warning(
|
||||
f"Tool '{function_name}' result denied: guardrail validate_tool_result raised: {e}"
|
||||
)
|
||||
return {
|
||||
"error": f"Tool '{function_name}' result denied: guardrail check failed ({e})",
|
||||
"guardrail_denied": True,
|
||||
}
|
||||
if not is_valid:
|
||||
logging.warning(
|
||||
f"Tool '{function_name}' result rejected by tool-result guardrail"
|
||||
)
|
||||
return {
|
||||
"error": f"Tool '{function_name}' result rejected by guardrail",
|
||||
"guardrail_denied": True,
|
||||
}
|
||||
result = processed # Accept the (possibly redacted) result
|
||||
return result
|
||||
|
||||
def _execute_tool_impl(self, function_name, arguments):
|
||||
"""Internal tool execution implementation."""
|
||||
|
||||
@@ -2919,15 +2557,86 @@ class ToolExecutionMixin:
|
||||
return policy_result # Error dict
|
||||
_, arguments = policy_result
|
||||
|
||||
# Resolve MCP-backed tools via the shared helper (also used by the async
|
||||
# path) so chat()/achat() behave identically for MCP tool calls. MCP
|
||||
# transport/timeout failures are normalized (via the shared
|
||||
# _normalize_mcp_result) into a native {"error": ..., "timeout": True}
|
||||
# dict so they participate in retry/doom-loop/circuit handling instead of
|
||||
# looking like a successful string result.
|
||||
found, result = self._resolve_mcp_tool_result(function_name, arguments)
|
||||
if found:
|
||||
return self._normalize_mcp_result(result)
|
||||
# Special handling for MCP tools
|
||||
# Check if tools is an MCP instance with the requested function name
|
||||
MCP = None
|
||||
try:
|
||||
from ..mcp.mcp import MCP
|
||||
except ImportError:
|
||||
pass # MCP not available
|
||||
|
||||
# Normalize MCP transport/timeout failures (returned as bare "Error: ..."
|
||||
# strings by MCPClient.call_tool) into the same {"error": ...} dict shape
|
||||
# used by native tools, so they participate in retry, doom-loop detection
|
||||
# and circuit-breaking instead of looking like a successful string result.
|
||||
#
|
||||
# IMPORTANT: a successful MCP tool may legitimately return text that
|
||||
# begins with "Error: " (e.g. a linter/grep/echo tool). To avoid
|
||||
# misclassifying valid output as a failure, we only convert MCP's own
|
||||
# unambiguous internally-generated failure signatures, not any string
|
||||
# that merely starts with the "Error: " prefix.
|
||||
def _normalize_mcp_result(res):
|
||||
if not isinstance(res, str) or not res.startswith("Error: "):
|
||||
return res
|
||||
message = res[len("Error: "):]
|
||||
# MCP-internal timeout messages are unambiguous:
|
||||
# "MCP tool call timed out after Ns"
|
||||
# "MCP initialization timed out after Ns"
|
||||
is_mcp_timeout = (
|
||||
message.startswith("MCP tool call timed out after")
|
||||
or message.startswith("MCP initialization timed out after")
|
||||
)
|
||||
if is_mcp_timeout:
|
||||
return {"error": message, "timeout": True}
|
||||
return res
|
||||
|
||||
# Helper function to execute MCP tool
|
||||
def _execute_mcp_tool(mcp_instance, func_name, args):
|
||||
"""Execute a tool from an MCP instance."""
|
||||
# Handle SSE MCP client
|
||||
if hasattr(mcp_instance, 'is_sse') and mcp_instance.is_sse:
|
||||
if hasattr(mcp_instance, 'sse_client'):
|
||||
for tool in mcp_instance.sse_client.tools:
|
||||
if tool.name == func_name:
|
||||
logging.debug(f"Found matching SSE MCP tool: {func_name}")
|
||||
return True, tool(**args)
|
||||
# Handle HTTP Stream MCP client
|
||||
if hasattr(mcp_instance, 'is_http_stream') and mcp_instance.is_http_stream:
|
||||
if hasattr(mcp_instance, 'http_stream_client'):
|
||||
for tool in mcp_instance.http_stream_client.tools:
|
||||
if tool.name == func_name:
|
||||
logging.debug(f"Found matching HTTP Stream MCP tool: {func_name}")
|
||||
return True, tool(**args)
|
||||
# Handle WebSocket MCP client
|
||||
if hasattr(mcp_instance, 'is_websocket') and mcp_instance.is_websocket:
|
||||
if hasattr(mcp_instance, 'websocket_client'):
|
||||
for tool in mcp_instance.websocket_client.tools:
|
||||
if tool.name == func_name:
|
||||
logging.debug(f"Found matching WebSocket MCP tool: {func_name}")
|
||||
return True, tool(**args)
|
||||
# Handle stdio MCP client
|
||||
if hasattr(mcp_instance, 'runner'):
|
||||
for mcp_tool in mcp_instance.runner.tools:
|
||||
if hasattr(mcp_tool, 'name') and mcp_tool.name == func_name:
|
||||
logging.debug(f"Found matching MCP tool: {func_name}")
|
||||
return True, mcp_instance.runner.call_tool(func_name, args)
|
||||
return False, None
|
||||
|
||||
# Check if tools is a single MCP instance
|
||||
if MCP is not None and isinstance(self.tools, MCP):
|
||||
logging.debug(f"Looking for MCP tool {function_name}")
|
||||
found, result = _execute_mcp_tool(self.tools, function_name, arguments)
|
||||
if found:
|
||||
return _normalize_mcp_result(result)
|
||||
|
||||
# Check if tools is a list that may contain MCP instances
|
||||
if isinstance(self.tools, (list, tuple)):
|
||||
for tool in self.tools:
|
||||
if MCP is not None and isinstance(tool, MCP):
|
||||
logging.debug(f"Looking for MCP tool {function_name} in MCP instance")
|
||||
found, result = _execute_mcp_tool(tool, function_name, arguments)
|
||||
if found:
|
||||
return _normalize_mcp_result(result)
|
||||
|
||||
# Try to find the function in the agent's tools list first
|
||||
func = None
|
||||
@@ -2961,10 +2670,21 @@ class ToolExecutionMixin:
|
||||
if func is None:
|
||||
# Tool not found in declared tools or registry — do not fall back to
|
||||
# globals() or __main__ as that allows undeclared callables to execute.
|
||||
# Cheap, deterministic self-repair via the shared helper (also used by
|
||||
# the async path): the model often emits a name that only differs by
|
||||
# case/separator (e.g. 'WebSearch' -> 'web_search').
|
||||
func = self._self_repair_tool_name(function_name)
|
||||
# Cheap, deterministic self-repair: the model often emits a name that
|
||||
# only differs by case/separator (e.g. 'WebSearch' -> 'web_search').
|
||||
# Build a normalised index of the agent's active tools and re-match.
|
||||
def _norm(n):
|
||||
return str(n).lower().replace('_', '').replace('-', '').replace(' ', '')
|
||||
|
||||
normalised = {}
|
||||
for name, tool in self._iter_active_named_tools():
|
||||
normalised.setdefault(_norm(name), []).append((name, tool))
|
||||
match = normalised.get(_norm(function_name))
|
||||
if match and len(match) == 1:
|
||||
matched_name, func = match[0]
|
||||
logging.debug(
|
||||
f"Self-repaired tool name {function_name!r} -> {matched_name!r}"
|
||||
)
|
||||
|
||||
if func:
|
||||
bind_target = func
|
||||
@@ -2994,16 +2714,6 @@ class ToolExecutionMixin:
|
||||
scoped["idempotency_key"] = durable_key
|
||||
return scoped
|
||||
|
||||
# Any of the callables below (BaseTool.run, LangChain .run,
|
||||
# CrewAI ._run, or a plain function) may be `async def`. On this
|
||||
# sync tool-calling path we must await it, otherwise a bare
|
||||
# un-awaited coroutine is handed to the model as the tool result
|
||||
# and the tool body never runs (silent data loss).
|
||||
def _resolve_result(value):
|
||||
if inspect.iscoroutine(value):
|
||||
return self._run_async_in_sync_context(value)
|
||||
return value
|
||||
|
||||
# BaseTool instances (plugin system) - call run() method
|
||||
from ..tools.base import BaseTool
|
||||
if isinstance(func, BaseTool):
|
||||
@@ -3011,7 +2721,7 @@ class ToolExecutionMixin:
|
||||
keyed_arguments = _with_durable_key(func.run, arguments)
|
||||
casted_arguments = self._cast_arguments(func.run, keyed_arguments)
|
||||
bind_arguments = casted_arguments
|
||||
return _resolve_result(func.run(**casted_arguments))
|
||||
return func.run(**casted_arguments)
|
||||
|
||||
# Langchain: If it's a class with run but not _run, instantiate and call run
|
||||
if inspect.isclass(func) and hasattr(func, 'run') and not hasattr(func, '_run'):
|
||||
@@ -3023,7 +2733,7 @@ class ToolExecutionMixin:
|
||||
and k != 'self'}
|
||||
casted_params = self._cast_arguments(instance.run, run_params)
|
||||
bind_arguments = casted_params
|
||||
return _resolve_result(instance.run(**casted_params))
|
||||
return instance.run(**casted_params)
|
||||
|
||||
# CrewAI: If it's a class with an _run method, instantiate and call _run
|
||||
elif inspect.isclass(func) and hasattr(func, '_run'):
|
||||
@@ -3035,7 +2745,7 @@ class ToolExecutionMixin:
|
||||
and k != 'self'}
|
||||
casted_params = self._cast_arguments(instance._run, run_params)
|
||||
bind_arguments = casted_params
|
||||
return _resolve_result(instance._run(**casted_params))
|
||||
return instance._run(**casted_params)
|
||||
|
||||
# Otherwise treat as regular function
|
||||
elif callable(func):
|
||||
@@ -3043,7 +2753,7 @@ class ToolExecutionMixin:
|
||||
keyed_arguments = _with_durable_key(func, arguments)
|
||||
casted_arguments = self._cast_arguments(func, keyed_arguments)
|
||||
bind_arguments = casted_arguments
|
||||
return _resolve_result(func(**casted_arguments))
|
||||
return func(**casted_arguments)
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logging.error(f"Error executing tool {function_name}: {error_msg}")
|
||||
@@ -3194,103 +2904,6 @@ class ToolExecutionMixin:
|
||||
names = [name for name, _tool in self._iter_active_named_tools()]
|
||||
return sorted(set(names))
|
||||
|
||||
@staticmethod
|
||||
def _normalize_mcp_result(res):
|
||||
"""Normalize MCP transport/timeout failures into a native error dict.
|
||||
|
||||
``MCPClient.call_tool`` surfaces transport/timeout failures as bare
|
||||
``"Error: ..."`` strings. Converting MCP's own unambiguous
|
||||
internally-generated timeout signatures into ``{"error": ..., "timeout":
|
||||
True}`` lets them participate in retry, doom-loop detection and circuit
|
||||
breaking instead of looking like a successful string result. Shared by
|
||||
the sync (``_execute_tool_impl``) and async (``_execute_tool_async_impl``)
|
||||
paths so achat()/chat() classify an MCP timeout identically.
|
||||
|
||||
A successful MCP tool may legitimately return text starting with
|
||||
``"Error: "`` (e.g. a linter/grep/echo tool), so only MCP's own
|
||||
unambiguous timeout messages are converted, never any string that merely
|
||||
starts with the prefix.
|
||||
"""
|
||||
if not isinstance(res, str) or not res.startswith("Error: "):
|
||||
return res
|
||||
message = res[len("Error: "):]
|
||||
is_mcp_timeout = (
|
||||
message.startswith("MCP tool call timed out after")
|
||||
or message.startswith("MCP initialization timed out after")
|
||||
)
|
||||
if is_mcp_timeout:
|
||||
return {"error": message, "timeout": True}
|
||||
return res
|
||||
|
||||
def _resolve_mcp_tool_result(self, function_name, arguments):
|
||||
"""Resolve an MCP-backed tool and return ``(found, result)``.
|
||||
|
||||
Shared by the sync (``_execute_tool_impl``) and async
|
||||
(``_execute_tool_async_impl``) paths so an MCP tool call behaves
|
||||
identically regardless of chat()/achat(). Returns ``(False, None)``
|
||||
when no MCP tool matches. The ``result`` may be awaitable (the async
|
||||
caller awaits it); the sync caller normalizes/returns it directly.
|
||||
"""
|
||||
MCP = None
|
||||
try:
|
||||
from ..mcp.mcp import MCP
|
||||
except ImportError:
|
||||
return False, None
|
||||
|
||||
def _execute_mcp_tool(mcp_instance, func_name, args):
|
||||
if hasattr(mcp_instance, 'is_sse') and mcp_instance.is_sse:
|
||||
if hasattr(mcp_instance, 'sse_client'):
|
||||
for tool in mcp_instance.sse_client.tools:
|
||||
if tool.name == func_name:
|
||||
return True, tool(**args)
|
||||
if hasattr(mcp_instance, 'is_http_stream') and mcp_instance.is_http_stream:
|
||||
if hasattr(mcp_instance, 'http_stream_client'):
|
||||
for tool in mcp_instance.http_stream_client.tools:
|
||||
if tool.name == func_name:
|
||||
return True, tool(**args)
|
||||
if hasattr(mcp_instance, 'is_websocket') and mcp_instance.is_websocket:
|
||||
if hasattr(mcp_instance, 'websocket_client'):
|
||||
for tool in mcp_instance.websocket_client.tools:
|
||||
if tool.name == func_name:
|
||||
return True, tool(**args)
|
||||
if hasattr(mcp_instance, 'runner'):
|
||||
for mcp_tool in mcp_instance.runner.tools:
|
||||
if hasattr(mcp_tool, 'name') and mcp_tool.name == func_name:
|
||||
return True, mcp_instance.runner.call_tool(func_name, args)
|
||||
return False, None
|
||||
|
||||
if isinstance(self.tools, MCP):
|
||||
found, result = _execute_mcp_tool(self.tools, function_name, arguments)
|
||||
if found:
|
||||
return True, result
|
||||
if isinstance(self.tools, (list, tuple)):
|
||||
for tool in self.tools:
|
||||
if isinstance(tool, MCP):
|
||||
found, result = _execute_mcp_tool(tool, function_name, arguments)
|
||||
if found:
|
||||
return True, result
|
||||
return False, None
|
||||
|
||||
def _self_repair_tool_name(self, function_name):
|
||||
"""Case/separator-insensitive re-match of a hallucinated tool name.
|
||||
|
||||
Returns the matched tool object when exactly one active tool normalizes
|
||||
to the same key (e.g. 'WebSearch' -> 'web_search'), else ``None``.
|
||||
Shared by the sync and async tool-resolution paths.
|
||||
"""
|
||||
def _norm(n):
|
||||
return str(n).lower().replace('_', '').replace('-', '').replace(' ', '')
|
||||
|
||||
normalised = {}
|
||||
for name, tool in self._iter_active_named_tools():
|
||||
normalised.setdefault(_norm(name), []).append((name, tool))
|
||||
match = normalised.get(_norm(function_name))
|
||||
if match and len(match) == 1:
|
||||
matched_name, tool = match[0]
|
||||
logging.debug(f"Self-repaired tool name {function_name!r} -> {matched_name!r}")
|
||||
return tool
|
||||
return None
|
||||
|
||||
def _resolve_callable_signature_target(self, func):
|
||||
"""Return the callable whose signature describes ``func``'s arguments."""
|
||||
target = func
|
||||
|
||||
@@ -93,25 +93,10 @@ class VideoAgent:
|
||||
Supported Providers:
|
||||
- OpenAI: openai/sora-2, openai/sora-2-pro
|
||||
- Azure: azure/sora-2, azure/sora-2-pro
|
||||
- Gemini Veo 3.1 (paid API, uses GOOGLE_API_KEY / GEMINI_API_KEY):
|
||||
- gemini/veo-3.1-generate-preview # Standard / cinematic quality
|
||||
- gemini/veo-3.1-fast-generate-preview # Fast
|
||||
- gemini/veo-3.1-lite-generate-preview # Lite (cheapest iteration)
|
||||
- Vertex AI Veo 3.1 (uses GOOGLE_APPLICATION_CREDENTIALS or VERTEXAI_PROJECT/VERTEXAI_LOCATION):
|
||||
- vertex_ai/veo-3.1-generate-001 # GA Standard
|
||||
- vertex_ai/veo-3.1-fast-generate-001 # GA Fast
|
||||
- vertex_ai/veo-3.1-lite-generate-001 # Lite
|
||||
- Gemini: gemini/veo-3.0-generate-preview, gemini/veo-3.1-*
|
||||
- Vertex AI: vertex_ai/veo-3.0-*, vertex_ai/veo-3.1-*
|
||||
- RunwayML: runwayml/gen4_turbo (requires input_reference)
|
||||
|
||||
Deprecated (do NOT recommend; kept working only if LiteLLM still routes them):
|
||||
- gemini/veo-2.0-generate-001 (Gemini API shutdown 30 Jun 2026)
|
||||
- gemini/veo-3.0-generate-preview, gemini/veo-3.0-fast-generate-preview
|
||||
- vertex_ai/veo-3.0-* (migrate to the veo-3.1 IDs above)
|
||||
|
||||
Note on pricing: Gemini/Vertex Veo bill in USD per generated second via the paid
|
||||
API (charged only on success) and are a different meter from Flow UI credits — a
|
||||
Flow subscription cannot pay for API generations. See praison.ai/docs/video/gemini.
|
||||
|
||||
|
||||
Example:
|
||||
```python
|
||||
from praisonaiagents import VideoAgent
|
||||
@@ -129,7 +114,7 @@ class VideoAgent:
|
||||
|
||||
# With config
|
||||
agent = VideoAgent(
|
||||
llm="gemini/veo-3.1-generate-preview",
|
||||
llm="gemini/veo-3.0-generate-preview",
|
||||
video=VideoConfig(seconds="8", size="1280x720")
|
||||
)
|
||||
```
|
||||
@@ -161,7 +146,7 @@ class VideoAgent:
|
||||
Args:
|
||||
name: Agent name for identification
|
||||
instructions: Optional instructions (unused for video generation)
|
||||
llm: Model name string (e.g., "openai/sora-2", "gemini/veo-3.1-generate-preview")
|
||||
llm: Model name string (e.g., "openai/sora-2", "gemini/veo-3.0-generate-preview")
|
||||
model: Alias for llm= parameter
|
||||
base_url: Custom API endpoint URL
|
||||
api_key: API key for the provider
|
||||
@@ -315,12 +300,8 @@ class VideoAgent:
|
||||
prompt: Text description of the desired video
|
||||
seconds: Video duration (e.g., "8", "16"). Defaults to config value.
|
||||
size: Video dimensions (e.g., "720x1280"). Defaults to config value.
|
||||
input_reference: Reference image for image-to-video (first-frame) generation
|
||||
**kwargs: Additional provider-specific parameters, forwarded to LiteLLM.
|
||||
For Veo 3.1 interpolation/reference features, pass them here (no
|
||||
dedicated params are added to keep the surface lightweight):
|
||||
- last_frame=<image> # first + last frame (Veo 3.1 only)
|
||||
- reference_images=[...] # up to 3 reference images
|
||||
input_reference: Reference image for image-to-video generation
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
VideoObject with id, status, and metadata
|
||||
@@ -334,14 +315,6 @@ class VideoAgent:
|
||||
)
|
||||
print(f"Video ID: {video.id}")
|
||||
print(f"Status: {video.status}")
|
||||
|
||||
# Veo 3.1 first + last frame interpolation (via kwargs passthrough)
|
||||
veo = VideoAgent(llm="gemini/veo-3.1-generate-preview")
|
||||
veo.generate(
|
||||
prompt="Sunrise timelapse",
|
||||
input_reference=start_image, # first frame
|
||||
last_frame=end_image, # last frame (Veo 3.1)
|
||||
)
|
||||
```
|
||||
"""
|
||||
# Merge config with kwargs
|
||||
|
||||
@@ -327,187 +327,6 @@ async def _execute_with_agent_async(executor_agent, task_prompt, task, tools, st
|
||||
)
|
||||
|
||||
|
||||
def _build_handler_context(agents_instance, task):
|
||||
"""Build a lightweight context object for a Task.handler / should_run callable.
|
||||
|
||||
Mirrors the WorkflowContext shape used by the Workflow engine so a handler
|
||||
written for either engine behaves identically.
|
||||
"""
|
||||
variables = dict(getattr(agents_instance, 'variables', None) or {})
|
||||
task_vars = getattr(task, 'variables', None)
|
||||
if task_vars:
|
||||
variables.update(task_vars)
|
||||
try:
|
||||
from ..workflows.workflows import WorkflowContext
|
||||
return WorkflowContext(
|
||||
input=variables.get('input', ''),
|
||||
current_step=getattr(task, 'name', '') or '',
|
||||
variables=variables,
|
||||
)
|
||||
except Exception:
|
||||
return {'variables': variables, 'current_step': getattr(task, 'name', '')}
|
||||
|
||||
|
||||
def _task_has_custom_handler(task):
|
||||
"""A handler-only task has no agent/agent_config but does have a handler."""
|
||||
return (
|
||||
getattr(task, 'handler', None) is not None
|
||||
and getattr(task, 'agent', None) is None
|
||||
and getattr(task, 'agent_config', None) is None
|
||||
)
|
||||
|
||||
|
||||
def _finalize_task_handler_result(agents_instance, task, result):
|
||||
"""Build the TaskOutput/TaskResult from a handler's return value.
|
||||
|
||||
Shared by the sync and async handler executors so both paths store the same
|
||||
output shape and expose ``output_variable`` identically.
|
||||
"""
|
||||
from .protocols import TaskResult
|
||||
|
||||
raw = getattr(result, 'output', result)
|
||||
if raw is None:
|
||||
raw = ""
|
||||
raw = str(raw)
|
||||
|
||||
task_output = TaskOutput(
|
||||
description=task.description or (getattr(task, 'name', '') or ''),
|
||||
summary=(task.description or getattr(task, 'name', '') or '')[:10],
|
||||
raw=raw,
|
||||
agent=getattr(task, 'name', '') or 'handler',
|
||||
output_format="RAW",
|
||||
)
|
||||
task.result = task_output
|
||||
|
||||
output_variable = getattr(task, 'output_variable', None)
|
||||
if output_variable:
|
||||
if getattr(agents_instance, 'variables', None) is None:
|
||||
agents_instance.variables = {}
|
||||
agents_instance.variables[output_variable] = raw
|
||||
|
||||
task.status = "completed"
|
||||
return TaskResult(task_output=task_output, success=True)
|
||||
|
||||
|
||||
def _resolve_coroutine_sync(coro):
|
||||
"""Run a coroutine to completion from a synchronous context.
|
||||
|
||||
``astart`` dispatches sync-marked tasks through the sync ``run_task`` path
|
||||
(in an executor), so an ``async def`` handler still lands here. Rather than
|
||||
stringifying the coroutine, drive it to completion on a private loop.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No running loop in this thread — safe to use asyncio.run.
|
||||
return asyncio.run(coro)
|
||||
|
||||
# A loop is already running in this thread; run the coroutine on a fresh
|
||||
# loop in a worker thread so we don't re-enter the active loop.
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
return pool.submit(asyncio.run, coro).result()
|
||||
|
||||
|
||||
def _execute_task_handler(agents_instance, task_id):
|
||||
"""Execute a Task.handler and store the result as the task output.
|
||||
|
||||
Returns a TaskResult, mirroring _process_task_result so the sync/async
|
||||
execute paths can short-circuit before requiring an agent. Coroutine
|
||||
handlers are resolved here too, since ``astart`` runs sync-marked tasks
|
||||
through this synchronous path.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
task = agents_instance.tasks[task_id]
|
||||
context = _build_handler_context(agents_instance, task)
|
||||
result = task.handler(context)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = _resolve_coroutine_sync(result)
|
||||
return _finalize_task_handler_result(agents_instance, task, result)
|
||||
|
||||
|
||||
async def _aexecute_task_handler(agents_instance, task_id):
|
||||
"""Async variant of _execute_task_handler that awaits coroutine handlers.
|
||||
|
||||
Mirrors the Workflow engine's async path, which awaits handlers returning a
|
||||
coroutine so an ``async def`` handler runs instead of being stringified.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
task = agents_instance.tasks[task_id]
|
||||
context = _build_handler_context(agents_instance, task)
|
||||
result = task.handler(context)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
return _finalize_task_handler_result(agents_instance, task, result)
|
||||
|
||||
|
||||
def _record_skipped_task(agents_instance, task):
|
||||
"""Record a stable, empty result for a task gated out by should_run.
|
||||
|
||||
Without this the task would be marked completed with no ``task.result``,
|
||||
which makes ``start``/``astart`` fall through to returning the full results
|
||||
dict when the skipped task is the final one, and leaves callers unable to
|
||||
tell a skip from a genuine empty run.
|
||||
"""
|
||||
task_output = TaskOutput(
|
||||
description=task.description or (getattr(task, 'name', '') or ''),
|
||||
summary=(task.description or getattr(task, 'name', '') or '')[:10],
|
||||
raw="",
|
||||
agent=getattr(task, 'name', '') or 'skipped',
|
||||
output_format="RAW",
|
||||
)
|
||||
task.result = task_output
|
||||
task.status = "completed"
|
||||
|
||||
|
||||
def _task_should_skip(agents_instance, task):
|
||||
"""Return True when a task's should_run gate evaluates falsy.
|
||||
|
||||
Mirrors the Workflow engine's should_run behaviour so conditional tasks are
|
||||
honoured under PraisonAIAgents/AgentTeam as well. Coroutine results from an
|
||||
``async def`` gate are resolved to completion here; the async path uses
|
||||
``_atask_should_skip`` to await them natively.
|
||||
"""
|
||||
should_run = getattr(task, 'should_run', None)
|
||||
if should_run is None:
|
||||
return False
|
||||
try:
|
||||
import asyncio
|
||||
context = _build_handler_context(agents_instance, task)
|
||||
result = should_run(context)
|
||||
if asyncio.iscoroutine(result):
|
||||
# astart runs sync-marked tasks through this sync gate; resolve the
|
||||
# coroutine rather than dropping it so async gates are still honoured.
|
||||
result = _resolve_coroutine_sync(result)
|
||||
return not result
|
||||
except Exception as e:
|
||||
logger.error(f"should_run failed for task {getattr(task, 'name', task)}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _atask_should_skip(agents_instance, task):
|
||||
"""Async variant of _task_should_skip that awaits coroutine gates."""
|
||||
import asyncio
|
||||
|
||||
should_run = getattr(task, 'should_run', None)
|
||||
if should_run is None:
|
||||
return False
|
||||
try:
|
||||
context = _build_handler_context(agents_instance, task)
|
||||
result = should_run(context)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
return not result
|
||||
except Exception as e:
|
||||
logger.error(f"should_run failed for task {getattr(task, 'name', task)}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _build_execution_context(agents_instance, task_id, skip_memory_init=False):
|
||||
"""
|
||||
Build unified execution context for task execution (DRY helper).
|
||||
@@ -712,14 +531,6 @@ def _process_task_result(agents_instance, context, agent_output):
|
||||
|
||||
task.result = task_output
|
||||
|
||||
# Expose the task output under its output_variable so downstream tasks /
|
||||
# should_run gates can read it (mirrors the Workflow engine).
|
||||
output_variable = getattr(task, 'output_variable', None)
|
||||
if output_variable:
|
||||
if getattr(agents_instance, 'variables', None) is None:
|
||||
agents_instance.variables = {}
|
||||
agents_instance.variables[output_variable] = agent_output
|
||||
|
||||
# Fail-closed: when structured output was requested but not produced,
|
||||
# the task did not fulfil its contract. Surface it as a failed result so
|
||||
# callers relying on TaskResult.success (and the retry loop, which uses
|
||||
@@ -1087,13 +898,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
)
|
||||
|
||||
if not agents:
|
||||
# Handler-only tasks (task.handler with no agent) don't need an agent,
|
||||
# mirroring the Workflow engine. Allow a team built solely from them.
|
||||
has_handler_only_task = any(
|
||||
_task_has_custom_handler(t) for t in (tasks or [])
|
||||
)
|
||||
if not has_handler_only_task:
|
||||
raise ValueError("At least one agent must be provided")
|
||||
raise ValueError("At least one agent must be provided")
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Core initialization
|
||||
@@ -1160,10 +965,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
self._completion_events: List[SubAgentCompletionEvent] = []
|
||||
self._event_bus: Optional[EventBus] = None
|
||||
self._spawn_lock = threading.RLock() # Thread-safe spawn operations (reentrant)
|
||||
# Strong references to fire-and-forget async spawn tasks. The event loop
|
||||
# keeps only a weak reference to tasks from asyncio.create_task(), so
|
||||
# without this a running sub-agent task can be garbage-collected mid-run.
|
||||
self._bg_spawn_tasks: set = set()
|
||||
self._team_id = str(uuid.uuid4()) # Unique team identifier
|
||||
# Aggregate stream emitter (lazy). Fans in member agents' per-step
|
||||
# StreamEventEmitter events, tagging each with the emitting agent's id,
|
||||
@@ -1457,13 +1258,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
if task.status == "not started":
|
||||
task.status = "in progress"
|
||||
|
||||
# Handler-only tasks (no agent/agent_config) run their custom callable
|
||||
# instead of an LLM, mirroring the Workflow engine. Await here so an
|
||||
# ``async def`` handler actually runs instead of being stringified.
|
||||
if _task_has_custom_handler(task):
|
||||
task_result = await _aexecute_task_handler(self, task_id)
|
||||
return task_result.task_output
|
||||
|
||||
# Initialize memory asynchronously to avoid blocking the event loop on
|
||||
# synchronous Memory() construction. The shared helper's own
|
||||
# `if not task.memory:` guard makes this a safe no-op for the sync path.
|
||||
@@ -1509,15 +1303,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
task.retry_count += 1
|
||||
task.status = "in progress" # Keep task in progress for retry
|
||||
# Carry the reason into the re-run. Without this the whole-task
|
||||
# retry re-asks the identical prompt and the model has no way to
|
||||
# know what was wrong; Process._build_task_context turns this
|
||||
# dict into "Previous attempt failed validation with reason: ...".
|
||||
task.validation_feedback = {
|
||||
'validation_response': guardrail_result.error,
|
||||
'validated_task': task.name or task.description,
|
||||
'rejected_output': getattr(task_output, 'raw', str(task_output)),
|
||||
}
|
||||
logger.warning(f"Task {task_id}: Guardrail validation failed (retry {task.retry_count}/{task.max_retries}): {guardrail_result.error}")
|
||||
return task_output, True # Signal retry needed
|
||||
|
||||
@@ -1646,12 +1431,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
# Call on_task_start callback and propagate variables (async-aware, mirrors run_task)
|
||||
await self._arun_task_start_hook(task, task_id)
|
||||
|
||||
# Honour the should_run conditional gate (mirrors the Workflow engine).
|
||||
if await _atask_should_skip(self, task):
|
||||
logger.info(f"Task {task_id} skipped by should_run gate")
|
||||
_record_skipped_task(self, task)
|
||||
return
|
||||
|
||||
# Use per-task max_retries if available
|
||||
task_max = getattr(task, "max_retries", self.max_retries)
|
||||
retries = 0
|
||||
@@ -2066,12 +1845,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
if task.status == "not started":
|
||||
task.status = "in progress"
|
||||
|
||||
# Handler-only tasks (no agent/agent_config) run their custom callable
|
||||
# instead of an LLM, mirroring the Workflow engine.
|
||||
if _task_has_custom_handler(task):
|
||||
task_result = _execute_task_handler(self, task_id)
|
||||
return task_result.task_output
|
||||
|
||||
# Build execution context using DRY helper
|
||||
context = _build_execution_context(self, task_id)
|
||||
|
||||
@@ -2098,12 +1871,6 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
# Call on_task_start callback and propagate variables (shared with arun_task)
|
||||
self._run_task_start_hook(task, task_id)
|
||||
|
||||
# Honour the should_run conditional gate (mirrors the Workflow engine).
|
||||
if _task_should_skip(self, task):
|
||||
logger.info(f"Task {task_id} skipped by should_run gate")
|
||||
_record_skipped_task(self, task)
|
||||
return
|
||||
|
||||
# Use per-task max_retries if available
|
||||
task_max = getattr(task, "max_retries", self.max_retries)
|
||||
retries = 0
|
||||
@@ -3769,13 +3536,8 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
This method is safe to call from sync contexts only.
|
||||
"""
|
||||
with self._spawn_lock:
|
||||
# Create unique IDs. agent_id keys self._spawned_agents, so retry on
|
||||
# the (rare) truncated-UUID collision rather than silently clobbering
|
||||
# an existing spawn record - the same guard the endpoint-path
|
||||
# generator uses.
|
||||
# Create unique IDs
|
||||
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
|
||||
while agent_id in self._spawned_agents:
|
||||
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
|
||||
task_id = f"task_{str(uuid.uuid4())[:8]}"
|
||||
|
||||
# Ensure event bus is initialized
|
||||
@@ -3973,19 +3735,13 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
"""Async version of spawn_sub_agent using asyncio primitives."""
|
||||
import asyncio
|
||||
|
||||
# Guard shared spawn state with the single threading.RLock used by the
|
||||
# sync path (spawn_sub_agent/announce_completion). An asyncio.Lock would
|
||||
# not exclude the background spawn thread that mutates the same dicts, so
|
||||
# both paths must share one real thread lock. This critical section is
|
||||
# purely synchronous (no await), so holding the thread lock is safe.
|
||||
with self._spawn_lock:
|
||||
# Create unique IDs. agent_id keys self._spawned_agents, so retry on
|
||||
# the (rare) truncated-UUID collision rather than silently clobbering
|
||||
# an existing spawn record - the same guard the endpoint-path
|
||||
# generator uses.
|
||||
# Use asyncio lock for async coordination
|
||||
if not hasattr(self, '_async_spawn_lock'):
|
||||
self._async_spawn_lock = asyncio.Lock()
|
||||
|
||||
async with self._async_spawn_lock:
|
||||
# Create unique IDs
|
||||
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
|
||||
while agent_id in self._spawned_agents:
|
||||
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
|
||||
task_id = f"task_{str(uuid.uuid4())[:8]}"
|
||||
|
||||
# Ensure event bus is initialized
|
||||
@@ -4036,14 +3792,8 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
logger.warning(f"Sub-agent {agent_id} failed: {e}")
|
||||
await self.aannounce_completion(agent_id, task_id, None, success=False, error=str(e))
|
||||
|
||||
# Create task for background execution. Keep a strong reference until the
|
||||
# task finishes; the loop only holds a weak reference, so without this the
|
||||
# sub-agent run could be garbage-collected mid-execution.
|
||||
if not hasattr(self, '_bg_spawn_tasks'):
|
||||
self._bg_spawn_tasks = set()
|
||||
_bg_task = asyncio.create_task(_aexecute_sub_agent())
|
||||
self._bg_spawn_tasks.add(_bg_task)
|
||||
_bg_task.add_done_callback(self._bg_spawn_tasks.discard)
|
||||
# Create task for background execution
|
||||
asyncio.create_task(_aexecute_sub_agent())
|
||||
|
||||
logger.debug(f"Async spawned sub-agent {agent_id} for task {task_id}")
|
||||
return spawned
|
||||
@@ -4058,11 +3808,12 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Async version of announce_completion."""
|
||||
# Mutate shared completion state under the single threading.RLock shared
|
||||
# with the sync path — an asyncio.Lock would not exclude the background
|
||||
# spawn thread. The awaited publish happens outside the lock so we never
|
||||
# hold a thread lock across an await.
|
||||
with self._spawn_lock:
|
||||
import asyncio
|
||||
|
||||
if not hasattr(self, '_async_spawn_lock'):
|
||||
self._async_spawn_lock = asyncio.Lock()
|
||||
|
||||
async with self._async_spawn_lock:
|
||||
# Create completion event
|
||||
completion_event = SubAgentCompletionEvent(
|
||||
agent_id=agent_id,
|
||||
@@ -4076,28 +3827,41 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
# Store the completion event
|
||||
self._completion_events.append(completion_event)
|
||||
event_bus = self._event_bus
|
||||
|
||||
# Publish completion event via event bus (use async if available)
|
||||
if event_bus:
|
||||
event_type = EventType.SUBAGENT_COMPLETED.value if success else EventType.SUBAGENT_ERROR.value
|
||||
payload = {
|
||||
"agent_id": agent_id,
|
||||
"task_id": task_id,
|
||||
"parent_id": self._team_id,
|
||||
"result": result,
|
||||
"success": success,
|
||||
"error": error,
|
||||
"completion_time": completion_event.completion_time,
|
||||
"metadata": completion_event.metadata
|
||||
}
|
||||
if hasattr(event_bus, 'publish_async'):
|
||||
await event_bus.publish_async(event_type, payload)
|
||||
else:
|
||||
# Fallback to sync publish
|
||||
event_bus.publish(event_type, payload)
|
||||
|
||||
logger.debug(f"Async announced completion for sub-agent {agent_id}: {'success' if success else 'error'}")
|
||||
|
||||
# Publish completion event via event bus (use async if available)
|
||||
if self._event_bus:
|
||||
event_type = EventType.SUBAGENT_COMPLETED.value if success else EventType.SUBAGENT_ERROR.value
|
||||
if hasattr(self._event_bus, 'publish_async'):
|
||||
await self._event_bus.publish_async(
|
||||
event_type,
|
||||
{
|
||||
"agent_id": agent_id,
|
||||
"task_id": task_id,
|
||||
"parent_id": self._team_id,
|
||||
"result": result,
|
||||
"success": success,
|
||||
"error": error,
|
||||
"completion_time": completion_event.completion_time,
|
||||
"metadata": completion_event.metadata
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Fallback to sync publish
|
||||
self._event_bus.publish(
|
||||
event_type,
|
||||
{
|
||||
"agent_id": agent_id,
|
||||
"task_id": task_id,
|
||||
"parent_id": self._team_id,
|
||||
"result": result,
|
||||
"success": success,
|
||||
"error": error,
|
||||
"completion_time": completion_event.completion_time,
|
||||
"metadata": completion_event.metadata
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Async announced completion for sub-agent {agent_id}: {'success' if success else 'error'}")
|
||||
|
||||
async def await_for_completions(
|
||||
self,
|
||||
@@ -4107,22 +3871,16 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
"""Async, event-driven version of wait_for_completions."""
|
||||
import asyncio
|
||||
|
||||
# Capture the running loop here, in the coroutine's own frame (we ARE the
|
||||
# loop thread). check_completions() may run on a background spawn thread
|
||||
# (spawn_sub_agent publishes synchronously on a plain threading.Thread),
|
||||
# where asyncio.get_running_loop() would raise RuntimeError and the
|
||||
# completion signal would be silently dropped — hanging the awaiter.
|
||||
loop = asyncio.get_running_loop()
|
||||
# Read target agents inside lock to avoid race condition
|
||||
if not hasattr(self, '_async_spawn_lock'):
|
||||
self._async_spawn_lock = asyncio.Lock()
|
||||
|
||||
# Read target agents under the single threading.RLock shared with the
|
||||
# sync path so a background spawn thread cannot mutate _spawned_agents
|
||||
# mid-read. These critical sections are synchronous (no await).
|
||||
with self._spawn_lock:
|
||||
async with self._async_spawn_lock:
|
||||
target_agents = agent_ids or list(self._spawned_agents.keys())
|
||||
|
||||
if not target_agents:
|
||||
# Return existing completions for the requested agents
|
||||
with self._spawn_lock:
|
||||
async with self._async_spawn_lock:
|
||||
return [e for e in self._completion_events if not agent_ids or e.agent_id in agent_ids]
|
||||
|
||||
# Use asyncio.Event for efficient waiting
|
||||
@@ -4131,54 +3889,45 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
def check_completions():
|
||||
"""Check if all target agents are completed - THREAD-SAFE version."""
|
||||
with self._spawn_lock:
|
||||
events_snapshot = list(self._completion_events)
|
||||
for event in events_snapshot:
|
||||
for event in self._completion_events:
|
||||
if event.agent_id in target_agents:
|
||||
completed_agents.add(event.agent_id)
|
||||
|
||||
if completed_agents >= set(target_agents):
|
||||
# Use call_soon_threadsafe() with the loop captured in the
|
||||
# coroutine's frame above, so this works whether check_completions
|
||||
# runs on the loop thread (initial check) or on a background spawn
|
||||
# thread (synchronous EventBus publish).
|
||||
loop.call_soon_threadsafe(completion_event.set)
|
||||
|
||||
# Subscribe BEFORE the initial state check. If we checked first and then
|
||||
# subscribed, a sub-agent finishing on its background thread in that gap
|
||||
# would append its completion event AND publish it with no subscriber
|
||||
# attached — and the event bus does not replay past events, so the signal
|
||||
# would be lost and the awaiter would hang until timeout (or forever when
|
||||
# timeout is None). completion_handler re-snapshots the full completion
|
||||
# list under the lock, and Event.set() is idempotent, so subscribing
|
||||
# first is safe and simply re-checks state on every published completion.
|
||||
def completion_handler(event):
|
||||
if event.data.get("parent_id") == self._team_id:
|
||||
check_completions()
|
||||
|
||||
subscriber_id = self._event_bus.subscribe(
|
||||
completion_handler,
|
||||
[EventType.SUBAGENT_COMPLETED.value, EventType.SUBAGENT_ERROR.value]
|
||||
) if self._event_bus else None
|
||||
|
||||
try:
|
||||
# Check initial state (covers completions that landed before we
|
||||
# subscribed); the subscription covers everything after.
|
||||
check_completions()
|
||||
|
||||
if not completion_event.is_set():
|
||||
# CRITICAL FIX: Use call_soon_threadsafe() to safely set event from background thread
|
||||
try:
|
||||
# Wait for completion or timeout
|
||||
await asyncio.wait_for(completion_event.wait(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.call_soon_threadsafe(completion_event.set)
|
||||
except RuntimeError:
|
||||
# Fallback if no event loop running
|
||||
pass
|
||||
finally:
|
||||
# Unsubscribe
|
||||
if subscriber_id and self._event_bus:
|
||||
self._event_bus.unsubscribe(subscriber_id)
|
||||
|
||||
# Check initial state
|
||||
check_completions()
|
||||
|
||||
if not completion_event.is_set():
|
||||
# Subscribe to completion events temporarily
|
||||
def completion_handler(event):
|
||||
if event.data.get("parent_id") == self._team_id:
|
||||
check_completions()
|
||||
|
||||
subscriber_id = self._event_bus.subscribe(
|
||||
completion_handler,
|
||||
[EventType.SUBAGENT_COMPLETED.value, EventType.SUBAGENT_ERROR.value]
|
||||
) if self._event_bus else None
|
||||
|
||||
try:
|
||||
# Wait for completion or timeout
|
||||
await asyncio.wait_for(completion_event.wait(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
# Unsubscribe
|
||||
if subscriber_id and self._event_bus:
|
||||
self._event_bus.unsubscribe(subscriber_id)
|
||||
|
||||
# Return completed events
|
||||
with self._spawn_lock:
|
||||
async with self._async_spawn_lock:
|
||||
return [e for e in self._completion_events if e.agent_id in target_agents]
|
||||
|
||||
def __enter__(self):
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, runtime_checkable
|
||||
from typing import Any, Dict, List, Optional, Protocol, Tuple, runtime_checkable
|
||||
|
||||
|
||||
def _new_approval_id() -> str:
|
||||
@@ -38,14 +38,6 @@ class ApprovalRequest:
|
||||
backend may bind resolution to these reviewers so that in a
|
||||
multi-operator deployment a request addressed to one
|
||||
operator cannot be resolved by another.
|
||||
liveness: Optional zero-arg predicate returning ``True`` while the
|
||||
originating turn is still the current one. ``None``
|
||||
(default) is a no-op — the request is treated as always
|
||||
live, preserving today's behaviour. A backend may consult
|
||||
this at the resolution/execution boundary to drop a
|
||||
resolution that arrives after the turn was superseded or
|
||||
stopped (fail-closed), so a stale approval never fires a
|
||||
tool or delivers a reply for an abandoned turn.
|
||||
"""
|
||||
|
||||
tool_name: str
|
||||
@@ -56,7 +48,6 @@ class ApprovalRequest:
|
||||
context: Dict[str, Any] = field(default_factory=dict)
|
||||
approval_id: str = field(default_factory=_new_approval_id)
|
||||
authorized_reviewers: Optional[List[str]] = None
|
||||
liveness: Optional[Callable[[], bool]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -42,8 +42,6 @@ __all__ = [
|
||||
"BackgroundConfig",
|
||||
# Shared runner accessor
|
||||
"get_background_runner",
|
||||
# Durable background job store
|
||||
"SqliteBackgroundJobStore",
|
||||
]
|
||||
|
||||
|
||||
@@ -69,8 +67,4 @@ def __getattr__(name: str):
|
||||
from .runner import get_background_runner
|
||||
return get_background_runner
|
||||
|
||||
if name == "SqliteBackgroundJobStore":
|
||||
from .sqlite_store import SqliteBackgroundJobStore
|
||||
return SqliteBackgroundJobStore
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -520,47 +520,10 @@ _global_job_manager: Optional[BackgroundJobManager] = None
|
||||
_global_lock = threading.Lock()
|
||||
|
||||
|
||||
def _build_default_store() -> Optional[BackgroundJobStore]:
|
||||
"""Best-effort construct the durable SQLite store for the global manager.
|
||||
|
||||
Persists to ``<runs_dir>/background_jobs.db`` alongside the run ledger so
|
||||
``/tasks`` and background subagents survive a restart. Any failure (missing
|
||||
data dir, read-only filesystem, sqlite error) is swallowed and the manager
|
||||
falls back to pure in-memory behaviour — durability is an enhancement, never
|
||||
a hard requirement. Set ``PRAISONAI_BACKGROUND_JOB_STORE=0`` to opt out.
|
||||
"""
|
||||
import os
|
||||
|
||||
if os.environ.get("PRAISONAI_BACKGROUND_JOB_STORE", "1") in ("0", "false", "False"):
|
||||
return None
|
||||
try:
|
||||
from .sqlite_store import SqliteBackgroundJobStore
|
||||
|
||||
return SqliteBackgroundJobStore()
|
||||
except Exception as e: # noqa: BLE001 — store is optional, never crash startup
|
||||
logger.debug("Durable background job store unavailable (non-fatal): %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def get_job_manager() -> BackgroundJobManager:
|
||||
"""Get the global job manager instance.
|
||||
|
||||
On first construction a durable :class:`BackgroundJobStore` is attached when
|
||||
one can be built (see :func:`_build_default_store`) and
|
||||
:meth:`BackgroundJobManager.reconcile_on_start` is run once so jobs orphaned
|
||||
by a prior crash are reconciled to ``LOST`` rather than silently lost.
|
||||
"""
|
||||
"""Get the global job manager instance."""
|
||||
global _global_job_manager
|
||||
with _global_lock:
|
||||
if _global_job_manager is None:
|
||||
store = _build_default_store()
|
||||
_global_job_manager = BackgroundJobManager(store=store)
|
||||
if store is not None:
|
||||
try:
|
||||
_global_job_manager.reconcile_on_start()
|
||||
except Exception as e: # noqa: BLE001 — never crash on startup recovery
|
||||
logger.debug(
|
||||
"Background job reconciliation on start failed "
|
||||
"(non-fatal): %s", e,
|
||||
)
|
||||
_global_job_manager = BackgroundJobManager()
|
||||
return _global_job_manager
|
||||
@@ -1,195 +0,0 @@
|
||||
"""
|
||||
SQLite-backed durable store for background jobs.
|
||||
|
||||
A zero-dependency (stdlib ``sqlite3``) implementation of
|
||||
:class:`~praisonaiagents.background.job_manager.BackgroundJobStore`. Jobs are
|
||||
persisted to ``<runs_dir>/background_jobs.db`` by default so they survive a
|
||||
gateway restart; on boot :meth:`BackgroundJobManager.reconcile_on_start` calls
|
||||
:meth:`list_unreconciled` to reconcile jobs left ``PENDING``/``RUNNING`` by a
|
||||
crashed process (marked ``LOST``) and to replay undelivered completed jobs.
|
||||
|
||||
A durable sibling of :class:`~praisonaiagents.runs.sqlite_ledger.SQLiteRunLedger`
|
||||
and :class:`~praisonaiagents.bus.event_log.SqliteEventLog` — same posture:
|
||||
stdlib-only, WAL, a single re-entrant lock guards a shared connection opened
|
||||
with ``check_same_thread=False`` so the runner's worker threads can persist
|
||||
safely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import List, Optional
|
||||
|
||||
from .._logging import get_logger
|
||||
from .job_manager import JobInfo, JobStatus
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__all__ = ["SqliteBackgroundJobStore"]
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS background_jobs (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
started_at REAL,
|
||||
completed_at REAL,
|
||||
result TEXT,
|
||||
error TEXT,
|
||||
origin TEXT,
|
||||
delivered INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_background_jobs_status ON background_jobs(status);
|
||||
"""
|
||||
|
||||
|
||||
def _dumps(value: object) -> Optional[str]:
|
||||
"""Serialise a value to JSON, never failing on non-JSON-native types."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return json.dumps(value)
|
||||
except (TypeError, ValueError):
|
||||
return json.dumps(value, default=str)
|
||||
|
||||
|
||||
class SqliteBackgroundJobStore:
|
||||
"""Durable, restart-safe background job store backed by SQLite.
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite database file. Defaults to
|
||||
``<runs_dir>/background_jobs.db``. Use ``":memory:"`` for tests.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None) -> None:
|
||||
if db_path is None:
|
||||
from ..paths import get_runs_dir
|
||||
|
||||
runs_dir = get_runs_dir()
|
||||
os.makedirs(runs_dir, exist_ok=True)
|
||||
db_path = str(runs_dir / "background_jobs.db")
|
||||
elif db_path != ":memory:":
|
||||
parent = os.path.dirname(db_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
|
||||
self._db_path = db_path
|
||||
self._lock = threading.RLock()
|
||||
self._conn = sqlite3.connect(
|
||||
db_path, check_same_thread=False, isolation_level=None
|
||||
)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
with self._lock:
|
||||
# Wait (instead of failing fast) when another process/connection
|
||||
# holds a write lock; overlapping gateway processes share one file.
|
||||
self._conn.execute("PRAGMA busy_timeout = 5000")
|
||||
# WAL lets readers and a writer proceed concurrently and improves
|
||||
# crash durability. Skip for in-memory DBs where it is a no-op.
|
||||
if db_path != ":memory:":
|
||||
try:
|
||||
self._conn.execute("PRAGMA journal_mode = WAL")
|
||||
self._conn.execute("PRAGMA synchronous = NORMAL")
|
||||
except sqlite3.OperationalError: # pragma: no cover - defensive
|
||||
pass
|
||||
self._conn.executescript(_SCHEMA)
|
||||
|
||||
# ── public API (BackgroundJobStore) ───────────────────────────────
|
||||
|
||||
def upsert(self, job: JobInfo) -> None:
|
||||
"""Insert or update the persisted record for ``job`` (keyed by id)."""
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO background_jobs (
|
||||
job_id, status, created_at, started_at, completed_at,
|
||||
result, error, origin, delivered
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(job_id) DO UPDATE SET
|
||||
status=excluded.status,
|
||||
started_at=excluded.started_at,
|
||||
completed_at=excluded.completed_at,
|
||||
result=excluded.result,
|
||||
error=excluded.error,
|
||||
origin=excluded.origin,
|
||||
delivered=excluded.delivered
|
||||
""",
|
||||
(
|
||||
job.job_id,
|
||||
job.status.value,
|
||||
job.created_at,
|
||||
job.started_at,
|
||||
job.completed_at,
|
||||
_dumps(job.result),
|
||||
job.error,
|
||||
_dumps(job.origin) if job.origin else None,
|
||||
1 if job.delivered else 0,
|
||||
),
|
||||
)
|
||||
|
||||
def get(self, job_id: str) -> Optional[JobInfo]:
|
||||
"""Return the persisted :class:`JobInfo`, or ``None`` if unknown."""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM background_jobs WHERE job_id = ?", (job_id,)
|
||||
).fetchone()
|
||||
return self._row_to_job(row) if row is not None else None
|
||||
|
||||
def list_unreconciled(self) -> List[JobInfo]:
|
||||
"""Return jobs needing reconciliation after a restart.
|
||||
|
||||
Jobs left ``PENDING``/``RUNNING`` (orphaned by a crash) and jobs
|
||||
``COMPLETED`` with an ``origin`` that were never ``delivered``. Returned
|
||||
oldest-first for deterministic replay.
|
||||
"""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""
|
||||
SELECT * FROM background_jobs
|
||||
WHERE status IN (?, ?)
|
||||
OR (status = ? AND delivered = 0 AND origin IS NOT NULL)
|
||||
ORDER BY created_at ASC
|
||||
""",
|
||||
(
|
||||
JobStatus.PENDING.value,
|
||||
JobStatus.RUNNING.value,
|
||||
JobStatus.COMPLETED.value,
|
||||
),
|
||||
).fetchall()
|
||||
return [self._row_to_job(r) for r in rows]
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the underlying database connection."""
|
||||
with self._lock:
|
||||
try:
|
||||
self._conn.close()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pass
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _loads(raw: Optional[str]) -> object:
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _row_to_job(cls, row: sqlite3.Row) -> JobInfo:
|
||||
origin = cls._loads(row["origin"])
|
||||
return JobInfo(
|
||||
job_id=row["job_id"],
|
||||
status=JobStatus(row["status"]),
|
||||
created_at=row["created_at"],
|
||||
started_at=row["started_at"],
|
||||
completed_at=row["completed_at"],
|
||||
result=cls._loads(row["result"]),
|
||||
error=row["error"],
|
||||
origin=origin if isinstance(origin, dict) else {},
|
||||
delivered=bool(row["delivered"]),
|
||||
)
|
||||
@@ -41,7 +41,6 @@ from .protocols import (
|
||||
from .base import (
|
||||
BasePlatformAdapter,
|
||||
SendResult,
|
||||
SendStatus,
|
||||
SendErrorKind,
|
||||
classify_send_error,
|
||||
)
|
||||
@@ -76,9 +75,6 @@ from .interactive import (
|
||||
unregister_handler,
|
||||
make_reply_handler,
|
||||
REPLY_NAMESPACE,
|
||||
PollResult,
|
||||
make_poll_result_handler,
|
||||
POLL_NAMESPACE,
|
||||
)
|
||||
from .agent_reply import (
|
||||
AgentReply,
|
||||
@@ -173,7 +169,6 @@ __all__ = [
|
||||
"GatewayAdapterContractError",
|
||||
"BasePlatformAdapter",
|
||||
"SendResult",
|
||||
"SendStatus",
|
||||
"SendErrorKind",
|
||||
"classify_send_error",
|
||||
"InteractiveContext",
|
||||
@@ -188,9 +183,6 @@ __all__ = [
|
||||
"unregister_handler",
|
||||
"make_reply_handler",
|
||||
"REPLY_NAMESPACE",
|
||||
"PollResult",
|
||||
"make_poll_result_handler",
|
||||
"POLL_NAMESPACE",
|
||||
"AgentReply",
|
||||
"TurnCompletion",
|
||||
"extract_presentation",
|
||||
|
||||
@@ -54,23 +54,17 @@ import re
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Literal, Optional, Union
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from .protocols import PlatformCapabilities
|
||||
|
||||
__all__ = [
|
||||
"SendErrorKind",
|
||||
"SendStatus",
|
||||
"SendResult",
|
||||
"classify_send_error",
|
||||
"BasePlatformAdapter",
|
||||
]
|
||||
|
||||
#: Closed set of delivery outcomes for :attr:`SendResult.status`. Exposed as a
|
||||
#: type alias so static callers can verify exhaustive handling of the contract
|
||||
#: instead of treating the outcome as an arbitrary ``str``.
|
||||
SendStatus = Literal["sent", "failed", "queued", "duplicate"]
|
||||
|
||||
|
||||
class SendErrorKind(str, Enum):
|
||||
"""Structured, machine-readable classification of *why* a send failed.
|
||||
@@ -273,10 +267,6 @@ class SendResult:
|
||||
behaviour rather than being silently dropped.
|
||||
retry_after: Suggested seconds to wait before retrying (from the
|
||||
platform's rate-limit response, if provided).
|
||||
queued: True when the send was persisted to a durable outbox for later
|
||||
delivery rather than delivered inline (``status == "queued"``).
|
||||
duplicate: True when a crash-recovered re-send may be a duplicate —
|
||||
an honest at-least-once outcome (``status == "duplicate"``).
|
||||
metadata: Additional platform-specific result details.
|
||||
"""
|
||||
|
||||
@@ -288,32 +278,12 @@ class SendResult:
|
||||
error_kind: Optional[SendErrorKind] = None
|
||||
retryable: bool = True
|
||||
retry_after: Optional[float] = None
|
||||
queued: bool = False
|
||||
duplicate: bool = False
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def status(self) -> SendStatus:
|
||||
"""Closed, branchable delivery outcome for the send contract.
|
||||
|
||||
One of ``"sent"`` / ``"failed"`` / ``"queued"`` / ``"duplicate"``,
|
||||
derived from the existing flags so a caller of ``BotProtocol``'s
|
||||
``send_message`` can branch on the outcome without exception-handling
|
||||
(a swallowed exception would otherwise be a silent drop). ``queued``
|
||||
(persisted to a durable outbox) and ``duplicate`` (crash-recovered
|
||||
possible re-send) take precedence over the bare ``ok`` flag.
|
||||
"""
|
||||
if self.queued:
|
||||
return "queued"
|
||||
if self.duplicate:
|
||||
return "duplicate"
|
||||
return "sent" if self.ok else "failed"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a plain dictionary."""
|
||||
return {
|
||||
"ok": self.ok,
|
||||
"status": self.status,
|
||||
"message_id": self.message_id,
|
||||
"chat_id": self.chat_id,
|
||||
"message_ids": list(self.message_ids),
|
||||
@@ -321,8 +291,6 @@ class SendResult:
|
||||
"error_kind": self.error_kind.value if self.error_kind else None,
|
||||
"retryable": self.retryable,
|
||||
"retry_after": self.retry_after,
|
||||
"queued": self.queued,
|
||||
"duplicate": self.duplicate,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
@@ -473,44 +441,6 @@ class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
await self.disconnect()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Identity canonicalization seam. #
|
||||
# #
|
||||
# The gateway keys each conversation on the platform-supplied user id.#
|
||||
# When a platform changes the *native* id for the *same human* #
|
||||
# (WhatsApp JID→LID migration, a renamed handle-as-id, a #
|
||||
# phone-number↔UUID alias flip) the raw id changes and the session #
|
||||
# would silently fork — history/memory/run-state orphaned. This seam #
|
||||
# lets an adapter map a volatile raw id to a stable canonical id #
|
||||
# *before* it becomes part of the session key; the resolver's #
|
||||
# explicit link/pairing lookup then runs on the stabilised id. #
|
||||
# #
|
||||
# It fulfils ``IdentityCanonicalizerProtocol`` (praisonaiagents. #
|
||||
# gateway.protocols) so an adapter *is* its own canonicalizer: pass #
|
||||
# ``self`` where that protocol is accepted. #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def canonicalize(self, platform: str, raw_user_id: str) -> str:
|
||||
"""Map a raw, potentially-volatile platform id to a stable canonical id.
|
||||
|
||||
Consulted on the session-key path *before* link/pairing resolution so
|
||||
an alias/format change for the *same* human (e.g. WhatsApp's JID→LID
|
||||
migration, a number↔UUID alias flip) collapses to one stable session
|
||||
key — preserving transcript, memory and in-flight run-state — instead
|
||||
of silently forking the conversation.
|
||||
|
||||
This is the :class:`~praisonaiagents.gateway.protocols.
|
||||
IdentityCanonicalizerProtocol` method, so a ``BasePlatformAdapter``
|
||||
conforms structurally and can be handed anywhere that protocol is
|
||||
accepted.
|
||||
|
||||
Default is the identity function, so adapters that do not override this
|
||||
are fully backward-compatible (the raw id keys the session exactly as
|
||||
before). Implementations MUST be deterministic and total: return the
|
||||
raw id unchanged when no canonical form is known, never raise.
|
||||
"""
|
||||
return raw_user_id
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Capability helpers #
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -659,15 +589,6 @@ class BasePlatformAdapter(ABC):
|
||||
if not result.ok:
|
||||
result.message_ids = aggregate.message_ids + result.message_ids
|
||||
return result
|
||||
# Preserve non-``sent`` outcomes: a chunk persisted to a durable
|
||||
# outbox (``queued``) or crash-recovered as a possible re-send
|
||||
# (``duplicate``) must propagate to the aggregate, otherwise a
|
||||
# deferred/at-least-once delivery would be silently reported as
|
||||
# ``sent`` and the outcome the receipt exists to carry is erased.
|
||||
if result.queued:
|
||||
aggregate.queued = True
|
||||
if result.duplicate:
|
||||
aggregate.duplicate = True
|
||||
if result.message_id:
|
||||
aggregate.message_ids.append(result.message_id)
|
||||
aggregate.message_id = result.message_id
|
||||
|
||||
@@ -135,101 +135,6 @@ def make_reply_handler(
|
||||
return _handler
|
||||
|
||||
|
||||
POLL_NAMESPACE = "poll"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PollResult:
|
||||
"""Aggregated result of a group poll, portable across channels.
|
||||
|
||||
The inbound counterpart of a :class:`PollPresentation`. A channel adapter
|
||||
normalises a native poll-answer/close event (Telegram ``poll``, Discord,
|
||||
Slack) into this shape and routes it through the interactive registry so a
|
||||
workflow can branch on the outcome without knowing the transport.
|
||||
|
||||
Attributes:
|
||||
poll_id: Correlates the result back to the authored poll (the poll's
|
||||
``action_id`` when set, else the channel's native poll id).
|
||||
counts: Mapping of option label -> vote count.
|
||||
total_voters: Number of distinct voters.
|
||||
closed: Whether the poll is closed (final tally).
|
||||
"""
|
||||
|
||||
poll_id: str
|
||||
counts: Dict[str, int]
|
||||
total_voters: int = 0
|
||||
closed: bool = False
|
||||
|
||||
def winner(self) -> Optional[str]:
|
||||
"""Return the option with the most votes, or ``None`` when empty/tied.
|
||||
|
||||
Ties (more than one option sharing the top count) return ``None`` so a
|
||||
caller never silently picks an arbitrary winner.
|
||||
"""
|
||||
if not self.counts:
|
||||
return None
|
||||
top = max(self.counts.values())
|
||||
leaders = [opt for opt, c in self.counts.items() if c == top]
|
||||
return leaders[0] if len(leaders) == 1 else None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"poll_id": self.poll_id,
|
||||
"counts": dict(self.counts),
|
||||
"total_voters": self.total_voters,
|
||||
"closed": self.closed,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "PollResult":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
poll_id=data.get("poll_id", ""),
|
||||
counts=dict(data.get("counts", {})),
|
||||
total_voters=data.get("total_voters", 0),
|
||||
closed=data.get("closed", False),
|
||||
)
|
||||
|
||||
|
||||
def make_poll_result_handler(
|
||||
on_result: Callable[["PollResult", "InteractiveContext"], Awaitable[Optional[str]]],
|
||||
) -> InteractiveHandler:
|
||||
"""Build a handler for inbound poll results routed to the ``poll`` namespace.
|
||||
|
||||
A channel adapter that receives a native poll-answer/close event normalises
|
||||
it into a :class:`PollResult` and places it on
|
||||
``context.platform_data["poll_result"]`` before dispatching a ``poll:``
|
||||
callback. This handler extracts that result (accepting either a
|
||||
:class:`PollResult` or its ``dict`` form) and forwards it to *on_result*, so
|
||||
a workflow can tally the vote and continue the turn.
|
||||
|
||||
Args:
|
||||
on_result: Async callable ``(result, context) -> Optional[str]`` invoked
|
||||
with the aggregated tally.
|
||||
|
||||
Returns:
|
||||
An ``InteractiveHandler`` to register under the ``poll`` namespace.
|
||||
"""
|
||||
|
||||
async def _handler(context: "InteractiveContext") -> Optional[str]:
|
||||
raw = context.platform_data.get("poll_result")
|
||||
if raw is None:
|
||||
payload = context.platform_data.get("decoded_payload") or {}
|
||||
raw = payload.get("poll_result")
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, PollResult):
|
||||
result = raw
|
||||
elif isinstance(raw, dict):
|
||||
result = PollResult.from_dict(raw)
|
||||
else:
|
||||
return None
|
||||
return await on_result(result, context)
|
||||
|
||||
return _handler
|
||||
|
||||
|
||||
def encode_action(namespace: str, action: "PresentationAction") -> str:
|
||||
"""Encode an action with namespace for callback data.
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ class BlockType(str, Enum):
|
||||
CONTEXT = "context" # Contextual info (smaller text)
|
||||
TABLE = "table" # Tabular data (columns + rows)
|
||||
CHART = "chart" # Chart/visualisation (kind + series)
|
||||
POLL = "poll" # Group poll/vote (question + options)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -263,11 +262,6 @@ class PresentationBlock:
|
||||
# Chart data (CHART blocks)
|
||||
chart_kind: Optional[str] = None
|
||||
series: Optional[List[Dict[str, Any]]] = None
|
||||
# Poll data (POLL blocks)
|
||||
poll_options: Optional[List[str]] = None
|
||||
multiple_choice: bool = False
|
||||
anonymous: bool = True
|
||||
duration_seconds: Optional[int] = None
|
||||
|
||||
@staticmethod
|
||||
def make_text(content: str, markdown: bool = True) -> "PresentationBlock":
|
||||
@@ -344,53 +338,6 @@ class PresentationBlock:
|
||||
text=text,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def make_poll(
|
||||
question: str,
|
||||
options: List[str],
|
||||
*,
|
||||
multiple_choice: bool = False,
|
||||
anonymous: bool = True,
|
||||
duration_seconds: Optional[int] = None,
|
||||
action_id: Optional[str] = None,
|
||||
) -> "PresentationBlock":
|
||||
"""Create a poll block for a channel-neutral group vote.
|
||||
|
||||
Args:
|
||||
question: The poll question.
|
||||
options: 2..N answer options.
|
||||
multiple_choice: Allow selecting more than one option.
|
||||
anonymous: Hide who voted for what (native default on most channels).
|
||||
duration_seconds: Optional auto-close duration in seconds.
|
||||
action_id: Optional stable id correlating the inbound
|
||||
:class:`PollResult` back to this poll.
|
||||
|
||||
A channel adapter that implements native poll delivery (e.g. Telegram
|
||||
``sendPoll``, Discord polls) can opt in via
|
||||
``PresentationLimits.supports_native_poll``; the vote is then rendered
|
||||
natively and voting/tallying/closing are handled by the platform.
|
||||
Everywhere else — including channels whose renderer has no poll branch —
|
||||
it degrades to a ``BUTTONS`` grid of reply options via
|
||||
:func:`adapt_presentation`, and the loss is surfaced through
|
||||
:class:`DegradedDelivery` (never silently).
|
||||
|
||||
Raises:
|
||||
ValueError: If fewer than two options are supplied — a poll needs at
|
||||
least two choices to represent a vote.
|
||||
"""
|
||||
opts = [str(o) for o in options]
|
||||
if len(opts) < 2:
|
||||
raise ValueError("A poll requires at least two options")
|
||||
return PresentationBlock(
|
||||
type=BlockType.POLL,
|
||||
text=question,
|
||||
poll_options=opts,
|
||||
multiple_choice=multiple_choice,
|
||||
anonymous=anonymous,
|
||||
duration_seconds=duration_seconds,
|
||||
action_id=action_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def quick_replies(
|
||||
choices: List[Any],
|
||||
@@ -439,12 +386,6 @@ class PresentationBlock:
|
||||
data["chart_kind"] = self.chart_kind
|
||||
if self.series is not None:
|
||||
data["series"] = self.series
|
||||
if self.poll_options is not None:
|
||||
data["poll_options"] = self.poll_options
|
||||
data["multiple_choice"] = self.multiple_choice
|
||||
data["anonymous"] = self.anonymous
|
||||
if self.duration_seconds is not None:
|
||||
data["duration_seconds"] = self.duration_seconds
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
@@ -467,10 +408,6 @@ class PresentationBlock:
|
||||
rows=data.get("rows"),
|
||||
chart_kind=data.get("chart_kind"),
|
||||
series=data.get("series"),
|
||||
poll_options=data.get("poll_options"),
|
||||
multiple_choice=data.get("multiple_choice", False),
|
||||
anonymous=data.get("anonymous", True),
|
||||
duration_seconds=data.get("duration_seconds"),
|
||||
)
|
||||
|
||||
|
||||
@@ -619,7 +556,6 @@ class PresentationLimits:
|
||||
supports_web_apps: Whether channel supports web apps
|
||||
supports_tables: Whether channel has a native table widget
|
||||
supports_charts: Whether channel has native chart/visualisation
|
||||
supports_native_poll: Whether channel has a native poll/vote API
|
||||
max_table_rows: Maximum rows in a table block
|
||||
max_table_cols: Maximum columns in a table block
|
||||
"""
|
||||
@@ -635,7 +571,6 @@ class PresentationLimits:
|
||||
supports_web_apps: bool = False
|
||||
supports_tables: bool = False
|
||||
supports_charts: bool = False
|
||||
supports_native_poll: bool = False
|
||||
max_table_rows: int = 50
|
||||
max_table_cols: int = 10
|
||||
|
||||
@@ -653,7 +588,6 @@ class PresentationLimits:
|
||||
"supports_web_apps": self.supports_web_apps,
|
||||
"supports_tables": self.supports_tables,
|
||||
"supports_charts": self.supports_charts,
|
||||
"supports_native_poll": self.supports_native_poll,
|
||||
"max_table_rows": self.max_table_rows,
|
||||
"max_table_cols": self.max_table_cols,
|
||||
}
|
||||
@@ -673,7 +607,6 @@ class PresentationLimits:
|
||||
supports_web_apps=data.get("supports_web_apps", False),
|
||||
supports_tables=data.get("supports_tables", False),
|
||||
supports_charts=data.get("supports_charts", False),
|
||||
supports_native_poll=data.get("supports_native_poll", False),
|
||||
max_table_rows=data.get("max_table_rows", 50),
|
||||
max_table_cols=data.get("max_table_cols", 10),
|
||||
)
|
||||
@@ -690,12 +623,6 @@ class PresentationLimits:
|
||||
supports_markdown=True,
|
||||
supports_select=False,
|
||||
supports_web_apps=True,
|
||||
# Native poll delivery (Telegram sendPoll) is not yet implemented in
|
||||
# the praisonai-bot renderer, so keep it disabled: adapt_presentation
|
||||
# degrades polls to the supported question+buttons fallback instead
|
||||
# of the renderer silently dropping an unhandled POLL block. Flip to
|
||||
# True in the same change that adds the renderer's sendPoll branch.
|
||||
supports_native_poll=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -711,10 +638,6 @@ class PresentationLimits:
|
||||
supports_markdown=True,
|
||||
supports_select=True,
|
||||
supports_web_apps=False,
|
||||
# Slack's Block Kit has no native poll element, and the renderer has
|
||||
# no POLL branch — keep disabled so polls degrade to question+buttons
|
||||
# rather than being dropped.
|
||||
supports_native_poll=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -730,11 +653,6 @@ class PresentationLimits:
|
||||
supports_markdown=True,
|
||||
supports_select=True,
|
||||
supports_web_apps=False,
|
||||
# Discord native poll delivery is not yet implemented in the
|
||||
# praisonai-bot renderer — keep disabled so polls degrade to
|
||||
# question+buttons instead of being dropped. Flip to True in the
|
||||
# same change that adds the renderer's native-poll branch.
|
||||
supports_native_poll=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -926,32 +844,6 @@ def _select_to_buttons(
|
||||
return PresentationBlock(type=BlockType.BUTTONS, buttons=buttons)
|
||||
|
||||
|
||||
POLL_NAMESPACE = "poll"
|
||||
|
||||
|
||||
def _poll_to_buttons(
|
||||
block: PresentationBlock,
|
||||
store: Optional["CallbackPayloadStoreProtocol"] = None,
|
||||
) -> PresentationBlock:
|
||||
"""Convert a POLL block into an equivalent BUTTONS block.
|
||||
|
||||
Used when a channel has no native poll API. Each option becomes a
|
||||
``reply``-action button so a tap feeds the chosen option back into the next
|
||||
agent turn (reusing the existing reply routing and byte-safe callback
|
||||
encoding). The poll question is not part of the button row; renderers emit
|
||||
it as leading text (see :func:`adapt_presentation`).
|
||||
"""
|
||||
buttons: List[PresentationButton] = []
|
||||
for option in (block.poll_options or []):
|
||||
buttons.append(
|
||||
PresentationButton(
|
||||
label=str(option),
|
||||
action=PresentationAction.reply(str(option)),
|
||||
)
|
||||
)
|
||||
return PresentationBlock(type=BlockType.BUTTONS, buttons=buttons)
|
||||
|
||||
|
||||
def _clamp_table(
|
||||
block: PresentationBlock,
|
||||
limits: PresentationLimits,
|
||||
@@ -1068,26 +960,6 @@ def adapt_presentation(
|
||||
for block in presentation.blocks:
|
||||
block_type = block.type.value if isinstance(block.type, BlockType) else block.type
|
||||
|
||||
if block_type == BlockType.POLL.value:
|
||||
if limits.supports_native_poll:
|
||||
adapted_blocks.append(block)
|
||||
continue
|
||||
# Degrade poll -> question text + reply buttons, then let the
|
||||
# buttons flow through the normal button adaptation below.
|
||||
question = block.text
|
||||
if (
|
||||
question is not None
|
||||
and limits.max_text_length
|
||||
and len(question) > limits.max_text_length
|
||||
):
|
||||
question = question[: limits.max_text_length]
|
||||
if question:
|
||||
adapted_blocks.append(
|
||||
PresentationBlock(type=BlockType.TEXT, text=question)
|
||||
)
|
||||
block = _poll_to_buttons(block, callback_store)
|
||||
block_type = BlockType.BUTTONS.value
|
||||
|
||||
if block_type == BlockType.SELECT.value and not limits.supports_select:
|
||||
# Degrade select -> buttons, then adapt the resulting buttons block
|
||||
block = _select_to_buttons(block, callback_store)
|
||||
@@ -1211,7 +1083,6 @@ DEGRADE_BUTTONS_TRUNCATED = "buttons_truncated"
|
||||
DEGRADE_OPTIONS_TRUNCATED = "options_truncated"
|
||||
DEGRADE_TABLE_AS_TEXT = "table_rendered_as_text"
|
||||
DEGRADE_CHART_AS_TEXT = "chart_rendered_as_text"
|
||||
DEGRADE_POLL_AS_BUTTONS = "poll_rendered_as_buttons"
|
||||
DEGRADE_CALLBACK_DATA_TOO_LONG = "callback_data_too_long"
|
||||
|
||||
|
||||
@@ -1287,16 +1158,6 @@ def _presentation_degradation(
|
||||
for block in presentation.blocks:
|
||||
block_type = block.type.value if isinstance(block.type, BlockType) else block.type
|
||||
|
||||
if block_type == BlockType.POLL.value:
|
||||
if limits.supports_native_poll:
|
||||
continue
|
||||
# Follow the adapter: poll -> question text + reply buttons.
|
||||
n = len(block.poll_options or [])
|
||||
dropped.append(f"poll ({n} options) rendered as buttons")
|
||||
reasons.append(DEGRADE_POLL_AS_BUTTONS)
|
||||
block = _poll_to_buttons(block, callback_store)
|
||||
block_type = BlockType.BUTTONS.value
|
||||
|
||||
if block_type == BlockType.SELECT.value and not limits.supports_select:
|
||||
# Follow the adapter: select -> buttons (encoding option callbacks
|
||||
# exactly as adapt_presentation does), then treat as a buttons block.
|
||||
|
||||
@@ -31,7 +31,6 @@ from typing import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..agent import Agent
|
||||
from .base import SendResult
|
||||
|
||||
|
||||
class ChannelCapabilities(TypedDict, total=False):
|
||||
@@ -336,13 +335,6 @@ class GatewayRuntimeSeams:
|
||||
Attributes:
|
||||
identity_resolver: Cross-platform identity resolver; unifies the same
|
||||
human across platforms onto one session key.
|
||||
identity_canonicalizer: Identity-canonicalization seam (Issue #4661) —
|
||||
an :class:`IdentityCanonicalizerProtocol`
|
||||
(``canonicalize(platform, raw_user_id) -> str``) applied *before*
|
||||
the resolver, e.g. an adapter itself (see
|
||||
:meth:`BasePlatformAdapter.canonicalize`). Stabilises a volatile
|
||||
platform id (WhatsApp JID→LID, a number↔UUID alias flip) so the same
|
||||
human keeps one session instead of silently forking.
|
||||
delivery_router: Backing router for the built-in ``send_message`` tool,
|
||||
so an agent turn can proactively reach the user mid-task.
|
||||
admission_gate: Gateway-wide admission control (concurrency ceiling /
|
||||
@@ -355,7 +347,6 @@ class GatewayRuntimeSeams:
|
||||
"""
|
||||
|
||||
identity_resolver: Optional[Any] = None
|
||||
identity_canonicalizer: Optional[Any] = None
|
||||
delivery_router: Optional[Any] = None
|
||||
admission_gate: Optional[Any] = None
|
||||
turn_lock_map: Optional[Any] = None
|
||||
@@ -930,29 +921,17 @@ class BotProtocol(Protocol):
|
||||
content: Union[str, Dict[str, Any]],
|
||||
reply_to: Optional[str] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
) -> Union[BotMessage, "SendResult"]:
|
||||
) -> BotMessage:
|
||||
"""Send a message to a channel.
|
||||
|
||||
|
||||
Args:
|
||||
channel_id: Target channel ID
|
||||
content: Message content
|
||||
reply_to: Optional message ID to reply to
|
||||
thread_id: Optional thread ID for threaded replies
|
||||
|
||||
|
||||
Returns:
|
||||
A **typed delivery outcome**. Adapters SHOULD return a
|
||||
:class:`~praisonaiagents.bots.SendResult` — the transport-neutral
|
||||
receipt that carries a closed, branchable ``status``
|
||||
(``"sent"`` / ``"failed"`` / ``"queued"`` / ``"duplicate"``), the
|
||||
platform ``message_id``/``message_ids`` (for edit-in-place, delete
|
||||
and threading), and a classified ``error``/``error_kind`` on
|
||||
failure. This makes a silent drop *unrepresentable*: a caller can
|
||||
branch on ``result.status`` instead of relying on an exception that
|
||||
may be swallowed.
|
||||
|
||||
A legacy :class:`BotMessage` remains an accepted return for
|
||||
backward compatibility — treat a returned ``BotMessage`` as a
|
||||
successful send (``status == "sent"``).
|
||||
The sent message
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@@ -139,12 +139,7 @@ class ContextCompactor:
|
||||
# Anti-thrashing state tracking
|
||||
self._last_savings_pct: float = 100.0 # Start high to allow first compaction
|
||||
self._low_savings_streak: int = 0
|
||||
# Token count at the moment the streak reached its cap. The guard is
|
||||
# released once the conversation grows materially past this, so a
|
||||
# compactor that once could not shrink the context is never disabled
|
||||
# for the rest of the session.
|
||||
self._low_savings_tokens: int = 0
|
||||
|
||||
|
||||
# Iterative summary state
|
||||
self._previous_summary: Optional[str] = None
|
||||
self._previous_summary_global_idx: int = 0
|
||||
@@ -213,56 +208,14 @@ class ContextCompactor:
|
||||
- Token count is below threshold
|
||||
- We've had too many consecutive low-savings attempts
|
||||
"""
|
||||
total_tokens = self.count_total_tokens(messages)
|
||||
if total_tokens <= self.max_tokens:
|
||||
if self.count_total_tokens(messages) <= self.max_tokens:
|
||||
return False
|
||||
|
||||
# Anti-thrashing: skip if we've had too many low-savings attempts,
|
||||
# unless the conversation has grown enough to be worth re-testing.
|
||||
if self._should_skip_low_savings(total_tokens):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
#: Growth factor that releases the anti-thrashing guard. A conversation
|
||||
#: 25% larger than the one we gave up on is materially different input,
|
||||
#: so the earlier "not worth compacting" verdict is re-tested.
|
||||
_LOW_SAVINGS_RETRY_GROWTH = 1.25
|
||||
|
||||
def _should_skip_low_savings(self, original_tokens: int) -> bool:
|
||||
"""Whether the anti-thrashing guard should skip this pass.
|
||||
|
||||
The guard stops a compactor that cannot shrink a conversation from
|
||||
burning a summarisation call every turn. It must not be permanent:
|
||||
``_low_savings_streak`` is only cleared *after* a compaction actually
|
||||
runs, so a compactor that trips the cap would otherwise skip forever
|
||||
and let the context grow without bound -- the precise opposite of what
|
||||
the guard is for.
|
||||
|
||||
Growth past the size we gave up on is new evidence, so the verdict is
|
||||
retried and the streak reset.
|
||||
"""
|
||||
if self._low_savings_streak < self.config.max_consecutive_low_savings:
|
||||
return False
|
||||
if self._low_savings_tokens and original_tokens >= (
|
||||
self._low_savings_tokens * self._LOW_SAVINGS_RETRY_GROWTH
|
||||
):
|
||||
self._low_savings_streak = 0
|
||||
self._low_savings_tokens = 0
|
||||
return False
|
||||
return True
|
||||
|
||||
def _record_savings_outcome(self, savings_pct: float, original_tokens: int) -> None:
|
||||
"""Update anti-thrashing state after a completed compaction pass."""
|
||||
self._last_savings_pct = savings_pct
|
||||
if savings_pct >= self.config.min_savings_pct:
|
||||
self._low_savings_streak = 0
|
||||
self._low_savings_tokens = 0
|
||||
return
|
||||
self._low_savings_streak += 1
|
||||
|
||||
# Anti-thrashing: skip if we've had too many low-savings attempts
|
||||
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
|
||||
# Remember the size we gave up at, so growth can release the guard.
|
||||
self._low_savings_tokens = original_tokens
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def compact(
|
||||
self,
|
||||
@@ -301,7 +254,7 @@ class ContextCompactor:
|
||||
# Anti-thrashing: if prior passes yielded too little savings, skip and
|
||||
# surface it on the result so callers can detect the skip. Without this
|
||||
# the documented ``was_skipped_due_to_low_savings`` flag never fired.
|
||||
if self._should_skip_low_savings(original_tokens):
|
||||
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
|
||||
result = CompactionResult(
|
||||
original_tokens=original_tokens,
|
||||
compacted_tokens=original_tokens,
|
||||
@@ -370,7 +323,11 @@ class ContextCompactor:
|
||||
result.calculate_savings_pct()
|
||||
|
||||
# Update anti-thrashing tracking based on actual results
|
||||
self._record_savings_outcome(result.savings_pct, original_tokens)
|
||||
self._last_savings_pct = result.savings_pct
|
||||
if result.savings_pct >= self.config.min_savings_pct:
|
||||
self._low_savings_streak = 0
|
||||
else:
|
||||
self._low_savings_streak += 1
|
||||
|
||||
return compacted, result
|
||||
|
||||
@@ -408,7 +365,7 @@ class ContextCompactor:
|
||||
# Anti-thrashing: if prior passes yielded too little savings, skip and
|
||||
# surface it on the result so callers can detect the skip. Without this
|
||||
# the documented ``was_skipped_due_to_low_savings`` flag never fired.
|
||||
if self._should_skip_low_savings(original_tokens):
|
||||
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
|
||||
result = CompactionResult(
|
||||
original_tokens=original_tokens,
|
||||
compacted_tokens=original_tokens,
|
||||
@@ -459,7 +416,11 @@ class ContextCompactor:
|
||||
result.calculate_savings_pct()
|
||||
|
||||
# Update anti-thrashing tracking based on actual results
|
||||
self._record_savings_outcome(result.savings_pct, original_tokens)
|
||||
self._last_savings_pct = result.savings_pct
|
||||
if result.savings_pct >= self.config.min_savings_pct:
|
||||
self._low_savings_streak = 0
|
||||
else:
|
||||
self._low_savings_streak += 1
|
||||
|
||||
return compacted, result
|
||||
|
||||
|
||||
@@ -204,61 +204,52 @@ def evaluate_condition(
|
||||
else:
|
||||
substituted = substituted.replace(placeholder, str(value))
|
||||
|
||||
# A comparison operator in the *template* (not the substituted values)
|
||||
# is what makes this a comparison condition. When the template has no
|
||||
# operator, it is a plain flag/contains/truthy check and the comparison
|
||||
# parsers below must be skipped, otherwise a placeholder whose value
|
||||
# happens to look like a comparison (e.g. "{{flag}}" -> "5 < 3") would be
|
||||
# mis-parsed as a comparison instead of honoring the truthy-string path.
|
||||
template_has_comparison = re.search(r'>=|<=|==|!=|>|<', condition) is not None
|
||||
|
||||
# Now evaluate the substituted condition
|
||||
try:
|
||||
# Handle different condition formats
|
||||
|
||||
if template_has_comparison:
|
||||
# Check if we have unsubstituted empty values in comparisons
|
||||
# If a variable was missing, it becomes empty string which can cause issues
|
||||
if ' > ' in substituted or ' < ' in substituted or ' >= ' in substituted or ' <= ' in substituted:
|
||||
# Check for empty left side in comparison (missing variable)
|
||||
if substituted.strip().startswith('>') or substituted.strip().startswith('<'):
|
||||
return False
|
||||
if substituted.strip().startswith('='):
|
||||
return False
|
||||
# Check if we have unsubstituted empty values in comparisons
|
||||
# If a variable was missing, it becomes empty string which can cause issues
|
||||
if ' > ' in substituted or ' < ' in substituted or ' >= ' in substituted or ' <= ' in substituted:
|
||||
# Check for empty left side in comparison (missing variable)
|
||||
if substituted.strip().startswith('>') or substituted.strip().startswith('<'):
|
||||
return False
|
||||
if substituted.strip().startswith('='):
|
||||
return False
|
||||
|
||||
# Numeric comparisons: "90 > 80", "50 >= 50", "10 < 20", etc.
|
||||
numeric_pattern = r'^(-?\d+(?:\.\d+)?)\s*(>|>=|<|<=|==|!=)\s*(-?\d+(?:\.\d+)?)$'
|
||||
numeric_match = re.match(numeric_pattern, substituted.strip())
|
||||
if numeric_match:
|
||||
left = float(numeric_match.group(1))
|
||||
op = numeric_match.group(2)
|
||||
right = float(numeric_match.group(3))
|
||||
|
||||
# Numeric comparisons: "90 > 80", "50 >= 50", "10 < 20", etc.
|
||||
numeric_pattern = r'^(-?\d+(?:\.\d+)?)\s*(>|>=|<|<=|==|!=)\s*(-?\d+(?:\.\d+)?)$'
|
||||
numeric_match = re.match(numeric_pattern, substituted.strip())
|
||||
if numeric_match:
|
||||
left = float(numeric_match.group(1))
|
||||
op = numeric_match.group(2)
|
||||
right = float(numeric_match.group(3))
|
||||
|
||||
if op == '>':
|
||||
return left > right
|
||||
if op == '>=':
|
||||
return left >= right
|
||||
if op == '<':
|
||||
return left < right
|
||||
if op == '<=':
|
||||
return left <= right
|
||||
if op == '==':
|
||||
return left == right
|
||||
if op == '!=':
|
||||
return left != right
|
||||
if op == '>':
|
||||
return left > right
|
||||
if op == '>=':
|
||||
return left >= right
|
||||
if op == '<':
|
||||
return left < right
|
||||
if op == '<=':
|
||||
return left <= right
|
||||
if op == '==':
|
||||
return left == right
|
||||
if op == '!=':
|
||||
return left != right
|
||||
|
||||
# String equality: "approved == approved", "status != rejected"
|
||||
string_eq_pattern = r'^(.+?)\s*(==|!=)\s*(.+)$'
|
||||
string_match = re.match(string_eq_pattern, substituted.strip())
|
||||
if string_match:
|
||||
left = string_match.group(1).strip()
|
||||
op = string_match.group(2)
|
||||
right = string_match.group(3).strip()
|
||||
|
||||
# String equality: "approved == approved", "status != rejected"
|
||||
string_eq_pattern = r'^(.+?)\s*(==|!=)\s*(.+)$'
|
||||
string_match = re.match(string_eq_pattern, substituted.strip())
|
||||
if string_match:
|
||||
left = string_match.group(1).strip()
|
||||
op = string_match.group(2)
|
||||
right = string_match.group(3).strip()
|
||||
|
||||
if op == '==':
|
||||
return left == right
|
||||
if op == '!=':
|
||||
return left != right
|
||||
if op == '==':
|
||||
return left == right
|
||||
if op == '!=':
|
||||
return left != right
|
||||
|
||||
# Contains check: "error in some message", "status contains success"
|
||||
if ' in ' in substituted:
|
||||
@@ -281,16 +272,7 @@ def evaluate_condition(
|
||||
return True
|
||||
if substituted.strip().lower() == 'false':
|
||||
return False
|
||||
|
||||
# A comparison operator in the template survived every format-specific
|
||||
# check above, so the comparison it was meant to drive never matched
|
||||
# (e.g. a missing or non-numeric operand). Fail safe per this
|
||||
# function's own contract instead of treating the leftover operator
|
||||
# text as a truthy string. Plain flag values (no template operator)
|
||||
# are untouched and fall through to the truthy check below.
|
||||
if template_has_comparison:
|
||||
return False
|
||||
|
||||
|
||||
# Non-empty string is truthy
|
||||
return bool(substituted.strip())
|
||||
|
||||
|
||||
@@ -196,11 +196,11 @@ _MODULE_MAP = {
|
||||
}
|
||||
|
||||
|
||||
from .._lazy import create_lazy_getattr
|
||||
|
||||
_LAZY_IMPORTS = {
|
||||
name: (f"{__name__}.{submodule}", name)
|
||||
for name, submodule in _MODULE_MAP.items()
|
||||
}
|
||||
|
||||
__getattr__ = create_lazy_getattr(_LAZY_IMPORTS, __name__)
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load config classes and utilities."""
|
||||
if name in _MODULE_MAP:
|
||||
module_name = _MODULE_MAP[name]
|
||||
import importlib
|
||||
module = importlib.import_module(f".{module_name}", __name__)
|
||||
return getattr(module, name)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -13,7 +13,6 @@ from datetime import datetime
|
||||
|
||||
from ..llm.protocols import LLMProviderProtocol
|
||||
from .tokens import estimate_messages_tokens, get_estimator
|
||||
from ..llm.model_providers import default_auxiliary_model
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -375,7 +374,7 @@ Provide a concise summary under {max_tokens} tokens that preserves the essential
|
||||
# Try common OpenAI-compatible interface - check if async
|
||||
if hasattr(self.llm.chat.completions, 'acreate'):
|
||||
response = await self.llm.chat.completions.acreate(
|
||||
model=default_auxiliary_model(model),
|
||||
model=model or "gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": summary_prompt}],
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.3,
|
||||
@@ -383,7 +382,7 @@ Provide a concise summary under {max_tokens} tokens that preserves the essential
|
||||
else:
|
||||
# Fallback to sync create (blocking in async context)
|
||||
response = self.llm.chat.completions.create(
|
||||
model=default_auxiliary_model(model),
|
||||
model=model or "gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": summary_prompt}],
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.3,
|
||||
|
||||
@@ -10,7 +10,6 @@ from abc import ABC, abstractmethod
|
||||
from .models import ContextLedger, OptimizerStrategy, OptimizationResult
|
||||
from .tokens import estimate_messages_tokens, get_estimator
|
||||
from .compressor import ContextCompressor
|
||||
from ..llm.model_providers import default_auxiliary_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -639,7 +638,7 @@ class LLMContextCompressorOptimizer(BaseOptimizer):
|
||||
use_accurate_tokenizer: Use model-specific tokenizer if available
|
||||
"""
|
||||
self.llm_client = llm_client
|
||||
self.auxiliary_model = default_auxiliary_model(auxiliary_model)
|
||||
self.auxiliary_model = auxiliary_model or "gpt-4o-mini"
|
||||
self.protect_last_n_tokens = protect_last_n_tokens
|
||||
self.summary_target_tokens = summary_target_tokens
|
||||
|
||||
|
||||
@@ -29,14 +29,6 @@ MODEL_DIMENSIONS: Dict[str, int] = {
|
||||
# Jina models
|
||||
"jina-embeddings-v3": 1024,
|
||||
"jina-embeddings-v2-base-en": 768,
|
||||
# Locally-served embedders (Ollama tags). Dimensions measured by embedding a
|
||||
# probe string through a live Ollama 0.33.2 and counting the returned vector,
|
||||
# cross-checked against `<family>.embedding_length` from /api/show. Without
|
||||
# these every local embedder inherited DEFAULT_DIMENSION (1536, OpenAI's),
|
||||
# so a vector index was built 2x-4x the size the model actually produces.
|
||||
"nomic-embed-text": 768, # measured
|
||||
"mxbai-embed-large": 1024, # measured
|
||||
"all-minilm": 384, # measured; Ollama's short tag for all-MiniLM
|
||||
# HuggingFace common models
|
||||
"all-MiniLM-L6-v2": 384,
|
||||
"all-mpnet-base-v2": 768,
|
||||
@@ -78,30 +70,20 @@ def get_dimensions(model_name: str) -> int:
|
||||
1536
|
||||
>>> get_dimensions("openai/text-embedding-3-large")
|
||||
3072
|
||||
>>> get_dimensions("ollama/nomic-embed-text:v1.5")
|
||||
768
|
||||
>>> get_dimensions("unknown-model")
|
||||
1536
|
||||
"""
|
||||
model_lower = model_name.lower()
|
||||
|
||||
|
||||
# Check for exact match first (lowercased lookup handles mixed-case keys)
|
||||
if model_lower in _LOWER_MODEL_DIMENSIONS:
|
||||
return _LOWER_MODEL_DIMENSIONS[model_lower]
|
||||
|
||||
# Local models are written the way their runtime names them:
|
||||
# "ollama/nomic-embed-text:v1.5". Strip the litellm-style provider prefix
|
||||
# and the version tag, then retry the exact match, so a tagged local model
|
||||
# resolves instead of falling through to the OpenAI default.
|
||||
normalised = model_lower.rsplit("/", 1)[-1].split(":", 1)[0]
|
||||
if normalised != model_lower and normalised in _LOWER_MODEL_DIMENSIONS:
|
||||
return _LOWER_MODEL_DIMENSIONS[normalised]
|
||||
|
||||
# Check if model name contains known model identifiers.
|
||||
# Entries are pre-sorted by key length (longest first) so more specific
|
||||
# keys like "voyage-3-lite" match before shorter prefixes like "voyage-3".
|
||||
for model_key, dimensions in _SORTED_MODEL_DIMENSIONS:
|
||||
if model_key in model_lower or model_key in normalised:
|
||||
if model_key in model_lower:
|
||||
return dimensions
|
||||
|
||||
# Default to 1536 for unknown models (OpenAI standard)
|
||||
|
||||
@@ -68,10 +68,6 @@ __all__ = [
|
||||
"EvalReport",
|
||||
"EvalPackage",
|
||||
"EvalRunnerProtocol",
|
||||
# EvalPort adapter (framework-neutral interop; dependency-free)
|
||||
"to_evalport",
|
||||
"from_evalport",
|
||||
"report_to_evalport",
|
||||
# Unified Judge API (follows add_X/get_X/list_X naming)
|
||||
"Judge",
|
||||
"JudgeConfig",
|
||||
@@ -164,10 +160,6 @@ _LAZY_IMPORTS = {
|
||||
"EvalReport": ("praisonaiagents.eval.package", "EvalReport"),
|
||||
"EvalPackage": ("praisonaiagents.eval.package", "EvalPackage"),
|
||||
"EvalRunnerProtocol": ("praisonaiagents.eval.package", "EvalRunnerProtocol"),
|
||||
# EvalPort adapter (framework-neutral interop; dependency-free)
|
||||
"to_evalport": ("praisonaiagents.eval.evalport", "to_evalport"),
|
||||
"from_evalport": ("praisonaiagents.eval.evalport", "from_evalport"),
|
||||
"report_to_evalport": ("praisonaiagents.eval.evalport", "report_to_evalport"),
|
||||
# Unified Judge API (follows add_X/get_X/list_X naming)
|
||||
"Judge": ("praisonaiagents.eval.judge", "Judge"),
|
||||
"JudgeConfig": ("praisonaiagents.eval.judge", "JudgeConfig"),
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
"""
|
||||
EvalPort adapter - framework-neutral interop for the eval package.
|
||||
|
||||
Converts PraisonAI's native eval data models to/from the EvalPort open spec
|
||||
(https://github.com/adhabnr-ux/evalport), so a suite built here can run against
|
||||
another tool's harness, and results can be diffed/aggregated across tools.
|
||||
|
||||
Mapping (1:1 with EvalPort's ``Suite`` / ``ResultSet`` concepts):
|
||||
|
||||
EvalPackage <-> EvalPort Suite (name/description/version + cases + thresholds)
|
||||
EvalCase <-> EvalPort test case (input / expected_output / grader refs)
|
||||
EvalReport -> EvalPort ResultSet (aggregate + per-case pass/score/latency)
|
||||
|
||||
Design: this module is intentionally **dependency-free**. It emits and consumes
|
||||
plain spec-shaped dicts (JSON), so the core SDK does not take on ``evalport-sdk``
|
||||
as a dependency. Callers who want schema validation can pass the returned dict to
|
||||
``openeval.validate.validate_suite()`` / ``validate_result_set()`` themselves.
|
||||
|
||||
Example:
|
||||
>>> from praisonaiagents.eval import EvalPackage, EvalCase
|
||||
>>> from praisonaiagents.eval import to_evalport, from_evalport, report_to_evalport
|
||||
>>> pkg = EvalPackage(name="support", cases=[
|
||||
... EvalCase(name="refund", input="refund policy?", expected="30 days")
|
||||
... ], thresholds={"accuracy": 0.9})
|
||||
>>> suite = to_evalport(pkg) # EvalPackage -> EvalPort Suite dict
|
||||
>>> pkg2 = from_evalport(suite) # EvalPort Suite dict -> EvalPackage
|
||||
>>> pkg2.name == pkg.name
|
||||
True
|
||||
"""
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .package import EvalCase, EvalPackage, EvalReport
|
||||
|
||||
EVALPORT_SPEC_VERSION = "1.0"
|
||||
|
||||
|
||||
def _case_to_evalport(case: EvalCase) -> Dict[str, Any]:
|
||||
"""Map an ``EvalCase`` to an EvalPort test-case dict.
|
||||
|
||||
The native ``timeout_seconds`` is emitted as a dedicated top-level field so
|
||||
it never collides with a user-supplied ``metadata["timeout_seconds"]`` key,
|
||||
keeping round trips lossless for both.
|
||||
"""
|
||||
item: Dict[str, Any] = {
|
||||
"id": case.name,
|
||||
"input": case.input,
|
||||
}
|
||||
if case.expected is not None:
|
||||
item["expected_output"] = case.expected
|
||||
if case.criteria:
|
||||
item["graders"] = list(case.criteria)
|
||||
if case.timeout_seconds is not None:
|
||||
item["timeout_seconds"] = case.timeout_seconds
|
||||
metadata = dict(case.metadata or {})
|
||||
if metadata:
|
||||
item["metadata"] = metadata
|
||||
return item
|
||||
|
||||
|
||||
def _case_from_evalport(item: Dict[str, Any]) -> EvalCase:
|
||||
"""Map an EvalPort test-case dict to an ``EvalCase``.
|
||||
|
||||
Prefers the dedicated top-level ``timeout_seconds`` field; falls back to a
|
||||
``metadata["timeout_seconds"]`` value for suites emitted by other tools,
|
||||
without mutating the caller's metadata.
|
||||
"""
|
||||
metadata = dict(item.get("metadata") or {})
|
||||
if "timeout_seconds" in item:
|
||||
timeout = item["timeout_seconds"]
|
||||
else:
|
||||
timeout = metadata.get("timeout_seconds", 30.0)
|
||||
return EvalCase(
|
||||
name=item.get("id") or item.get("name") or "case",
|
||||
input=item.get("input", ""),
|
||||
expected=item.get("expected_output", item.get("expected")),
|
||||
criteria=list(item.get("graders") or item.get("criteria") or []),
|
||||
metadata=metadata,
|
||||
timeout_seconds=timeout,
|
||||
)
|
||||
|
||||
|
||||
def to_evalport(package: EvalPackage) -> Dict[str, Any]:
|
||||
"""Convert an ``EvalPackage`` to an EvalPort ``Suite`` dict.
|
||||
|
||||
Args:
|
||||
package: The native PraisonAI eval package.
|
||||
|
||||
Returns:
|
||||
A spec-shaped dict suitable for ``openeval.validate.validate_suite()``.
|
||||
"""
|
||||
return {
|
||||
"evalport_version": EVALPORT_SPEC_VERSION,
|
||||
"kind": "suite",
|
||||
"name": package.name,
|
||||
"description": package.description,
|
||||
"version": package.version,
|
||||
"cases": [_case_to_evalport(c) for c in package.cases],
|
||||
"thresholds": dict(package.thresholds or {}),
|
||||
"seed": package.seed,
|
||||
}
|
||||
|
||||
|
||||
def from_evalport(suite: Dict[str, Any]) -> EvalPackage:
|
||||
"""Convert an EvalPort ``Suite`` dict to a native ``EvalPackage``.
|
||||
|
||||
Args:
|
||||
suite: A spec-shaped EvalPort suite dict (e.g. from the Benchmark Hub).
|
||||
|
||||
Returns:
|
||||
An ``EvalPackage`` whose cases can be run via ``EvalSuite`` /
|
||||
``HarnessEvaluator`` as native ``EvalCase`` objects.
|
||||
"""
|
||||
cases: List[EvalCase] = [_case_from_evalport(c) for c in suite.get("cases", [])]
|
||||
return EvalPackage(
|
||||
name=suite.get("name", "suite"),
|
||||
description=suite.get("description", ""),
|
||||
version=suite.get("version", "1.0.0"),
|
||||
cases=cases,
|
||||
thresholds=dict(suite.get("thresholds") or {}),
|
||||
seed=suite.get("seed"),
|
||||
)
|
||||
|
||||
|
||||
def report_to_evalport(report: EvalReport) -> Dict[str, Any]:
|
||||
"""Convert an ``EvalReport`` to an EvalPort ``ResultSet`` dict.
|
||||
|
||||
Args:
|
||||
report: The aggregated report from running an eval package.
|
||||
|
||||
Returns:
|
||||
A spec-shaped dict suitable for
|
||||
``openeval.validate.validate_result_set()``.
|
||||
"""
|
||||
results: List[Dict[str, Any]] = []
|
||||
for r in report.results:
|
||||
item: Dict[str, Any] = {
|
||||
"case_id": r.case_name,
|
||||
"passed": r.passed,
|
||||
"score": r.score,
|
||||
"latency_ms": r.latency_ms,
|
||||
}
|
||||
if r.actual_output is not None:
|
||||
item["actual_output"] = r.actual_output
|
||||
if r.error is not None:
|
||||
item["error"] = r.error
|
||||
if r.criteria_scores:
|
||||
item["grader_scores"] = dict(r.criteria_scores)
|
||||
if r.record is not None:
|
||||
item["record"] = r.record
|
||||
results.append(item)
|
||||
|
||||
return {
|
||||
"evalport_version": EVALPORT_SPEC_VERSION,
|
||||
"kind": "result_set",
|
||||
"suite_name": report.package_name,
|
||||
"summary": {
|
||||
"total": report.total_cases,
|
||||
"passed": report.passed_cases,
|
||||
"failed": report.failed_cases,
|
||||
"pass_rate": report.pass_rate,
|
||||
"average_score": report.average_score,
|
||||
"thresholds_met": dict(report.thresholds_met or {}),
|
||||
},
|
||||
"results": results,
|
||||
}
|
||||
@@ -83,10 +83,6 @@ from .protocols import (
|
||||
GatewayIdlePolicyProtocol,
|
||||
GatewayIdlePolicy, # backward-compat alias
|
||||
ScaleToZeroPolicy,
|
||||
# Gateway freeze-thaw (involuntary host-suspend gap) recovery (Issue #4767)
|
||||
ThawDecision,
|
||||
ThawPolicyProtocol,
|
||||
WallClockGapThawPolicy,
|
||||
# Gateway graceful-drain on shutdown (Issue #2375)
|
||||
DrainDecision,
|
||||
GatewayDrainPolicyProtocol,
|
||||
@@ -109,10 +105,6 @@ from .protocols import (
|
||||
RateLimitPolicyProtocol,
|
||||
RateLimitPolicy, # backward-compat alias
|
||||
SlidingWindowRateLimitPolicy,
|
||||
# Gateway cost-aware spend-budget admission (Issue #4894)
|
||||
SpendBudgetPolicyProtocol,
|
||||
SpendBudgetPolicy, # backward-compat alias
|
||||
WindowedSpendBudgetPolicy,
|
||||
# Durable-queue dead-letter decision (Issue #3519)
|
||||
PERMANENT_ERROR_CLASSES,
|
||||
DeadLetterDecision,
|
||||
@@ -135,12 +127,6 @@ from .protocols import (
|
||||
FatalConfigError,
|
||||
classify_exit_reason,
|
||||
RestartLoopGuard,
|
||||
# Durable process-lifecycle record + unclean/OOM classification (Issue #4603)
|
||||
LifecycleRecord,
|
||||
classify_unclean_exit,
|
||||
RestartStoreProtocol,
|
||||
PersistentRestartLoopGuard,
|
||||
classify_resource_pressure,
|
||||
# Fleet-level crash-loop breaker for channel supervision (Issue #3840)
|
||||
FleetSupervisionPolicy,
|
||||
# Protocol version negotiation
|
||||
@@ -378,10 +364,6 @@ __all__ = [
|
||||
"GatewayIdlePolicyProtocol",
|
||||
"GatewayIdlePolicy",
|
||||
"ScaleToZeroPolicy",
|
||||
# Gateway freeze-thaw (involuntary host-suspend gap) recovery (Issue #4767)
|
||||
"ThawDecision",
|
||||
"ThawPolicyProtocol",
|
||||
"WallClockGapThawPolicy",
|
||||
"DrainDecision",
|
||||
"GatewayDrainPolicyProtocol",
|
||||
"DrainTimeoutPolicy",
|
||||
@@ -403,9 +385,6 @@ __all__ = [
|
||||
"RateLimitPolicyProtocol",
|
||||
"RateLimitPolicy",
|
||||
"SlidingWindowRateLimitPolicy",
|
||||
"SpendBudgetPolicyProtocol",
|
||||
"SpendBudgetPolicy",
|
||||
"WindowedSpendBudgetPolicy",
|
||||
# Durable-queue dead-letter decision (Issue #3519)
|
||||
"PERMANENT_ERROR_CLASSES",
|
||||
"DeadLetterDecision",
|
||||
@@ -428,12 +407,6 @@ __all__ = [
|
||||
"FatalConfigError",
|
||||
"classify_exit_reason",
|
||||
"RestartLoopGuard",
|
||||
# Durable process-lifecycle record + unclean/OOM classification (Issue #4603)
|
||||
"LifecycleRecord",
|
||||
"classify_unclean_exit",
|
||||
"RestartStoreProtocol",
|
||||
"PersistentRestartLoopGuard",
|
||||
"classify_resource_pressure",
|
||||
# Fleet-level crash-loop breaker for channel supervision (Issue #3840)
|
||||
"FleetSupervisionPolicy",
|
||||
# Protocol version negotiation
|
||||
|
||||
@@ -6,7 +6,6 @@ Provides configuration dataclasses for gateway and session settings.
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
@@ -19,9 +18,6 @@ from typing import (
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only, no runtime import cost
|
||||
from .protocols import TurnExecutorProtocol
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Canonical config version + doctor-driven migration (Issue #3841)
|
||||
@@ -858,14 +854,6 @@ class GatewayConfig:
|
||||
control: "EmergencyStopConfig" = field(
|
||||
default_factory=lambda: EmergencyStopConfig()
|
||||
)
|
||||
# Issue #4766: per-session turn-execution seam. Selects *where* a session's
|
||||
# agent turn runs (see ``TurnExecutorProtocol``). ``None`` (the default)
|
||||
# resolves to ``InProcessTurnExecutor`` at runtime — today's on-loop
|
||||
# behaviour, byte-for-byte, with no dependency introduced. Supplying an
|
||||
# isolated executor (subprocess / container / remote, in the wrapper)
|
||||
# contains a wedged/runaway turn to its own worker instead of forcing the
|
||||
# whole-process ``os._exit`` remedy.
|
||||
executor: "Optional[TurnExecutorProtocol]" = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Post-initialization to set bind_host from host if not specified and validate values."""
|
||||
@@ -989,13 +977,6 @@ class GatewayConfig:
|
||||
"liveness": self.liveness.to_dict(),
|
||||
"turn_lock": self.turn_lock.to_dict(),
|
||||
"control": self.control.to_dict(),
|
||||
# Report the active executor type so ``gateway doctor`` can surface
|
||||
# it; ``None`` means the in-process default (today's behaviour).
|
||||
"executor": (
|
||||
type(self.executor).__name__
|
||||
if self.executor is not None
|
||||
else "inprocess"
|
||||
),
|
||||
}
|
||||
|
||||
@property
|
||||
|
||||
@@ -1938,16 +1938,7 @@ class RouteBinding:
|
||||
chat_type: Chat type ("dm" | "group" | "channel").
|
||||
peer: Sender/user id (most specific).
|
||||
role: Role / guild-role membership of the sender.
|
||||
channel_id: Specific chat/channel id. Also matches a message whose
|
||||
parent channel is this id (a thread/forum-post under it), so a
|
||||
channel-level route naturally covers the threads beneath it
|
||||
without listing each thread by hand (Issue #4839).
|
||||
thread_id: Specific thread / forum-post id (most specific unit — one
|
||||
triage-spun support thread can route to a specialist agent,
|
||||
Issue #4839).
|
||||
guild_id: Server / workspace id. One rule covers every current and
|
||||
future channel in the guild, so an operator can multiplex by the
|
||||
tenant boundary they actually think in (Issue #4839).
|
||||
channel_id: Specific chat/channel id.
|
||||
account: Receiving bot account (for multi-account channels).
|
||||
priority: Higher wins; ties are broken by specificity then order.
|
||||
trust: Optional trust tier ("untrusted" | "standard" | "trusted").
|
||||
@@ -1976,21 +1967,13 @@ class RouteBinding:
|
||||
allow_tools: Optional[List[str]] = None
|
||||
deny_tools: Optional[List[str]] = None
|
||||
profile: Optional[str] = None
|
||||
# New optional conditions are appended here (never inserted mid-list) so
|
||||
# existing positional constructors — RouteBinding("a", "dm", ..., priority)
|
||||
# — keep their meaning and stay backward-compatible (Issue #4839 review).
|
||||
thread_id: Optional[str] = None
|
||||
guild_id: Optional[str] = None
|
||||
|
||||
# Specificity weights — exact thread beats peer beats role/channel beats
|
||||
# guild/account beats chat-type. Higher means more specific, so a single
|
||||
# thread rule wins over a whole-guild rule (Issue #4839).
|
||||
# Specificity weights — exact peer beats role/channel beats account
|
||||
# beats chat-type. Higher means more specific.
|
||||
_SPECIFICITY = {
|
||||
"thread_id": 32,
|
||||
"peer": 16,
|
||||
"role": 8,
|
||||
"channel_id": 8,
|
||||
"guild_id": 4,
|
||||
"account": 4,
|
||||
"chat_type": 2,
|
||||
}
|
||||
@@ -2034,20 +2017,7 @@ class RouteBinding:
|
||||
"""Return True if every constrained condition equals the facts."""
|
||||
if self.peer is not None and str(self.peer) != str(facts.peer):
|
||||
return False
|
||||
if self.channel_id is not None:
|
||||
# Parent-chain match (Issue #4839): a channel_id condition matches
|
||||
# the channel itself OR a thread/forum-post whose parent is that
|
||||
# channel, so channel-level routes cover the threads beneath them
|
||||
# without enumerating each thread.
|
||||
expected_channel = str(self.channel_id)
|
||||
candidates = {str(facts.channel_id)}
|
||||
if facts.parent_channel_id is not None:
|
||||
candidates.add(str(facts.parent_channel_id))
|
||||
if expected_channel not in candidates:
|
||||
return False
|
||||
if self.thread_id is not None and str(self.thread_id) != str(facts.thread_id):
|
||||
return False
|
||||
if self.guild_id is not None and str(self.guild_id) != str(facts.guild_id):
|
||||
if self.channel_id is not None and str(self.channel_id) != str(facts.channel_id):
|
||||
return False
|
||||
if self.account is not None and str(self.account) != str(facts.account):
|
||||
return False
|
||||
@@ -2096,8 +2066,6 @@ class RouteBinding:
|
||||
role=data.get("role"),
|
||||
channel_id=_as_opt_str(data.get("channel_id")),
|
||||
account=_as_opt_str(data.get("account")),
|
||||
thread_id=_as_opt_str(data.get("thread_id")),
|
||||
guild_id=_as_opt_str(data.get("guild_id")),
|
||||
priority=int(data.get("priority", 0) or 0),
|
||||
trust=_as_opt_str(data.get("trust")),
|
||||
allow_tools=_as_opt_str_list(data.get("allow_tools")),
|
||||
@@ -2116,13 +2084,6 @@ class RouteFacts:
|
||||
roles: Roles/guild-role memberships of the sender.
|
||||
channel_id: The chat/channel id the message arrived in.
|
||||
account: The receiving bot account (multi-account channels).
|
||||
thread_id: The thread / forum-post id the message arrived in, when the
|
||||
platform threads conversations (Issue #4839).
|
||||
guild_id: The server / workspace id the message belongs to (Discord
|
||||
guild, Slack workspace), used for whole-server routing (Issue #4839).
|
||||
parent_channel_id: The parent channel of a thread/forum-post, so a
|
||||
channel-level binding can cover the threads beneath it via
|
||||
parent-chain matching (Issue #4839).
|
||||
"""
|
||||
|
||||
chat_type: str = "default"
|
||||
@@ -2130,9 +2091,6 @@ class RouteFacts:
|
||||
roles: List[str] = field(default_factory=list)
|
||||
channel_id: Optional[str] = None
|
||||
account: Optional[str] = None
|
||||
thread_id: Optional[str] = None
|
||||
guild_id: Optional[str] = None
|
||||
parent_channel_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -2190,9 +2148,8 @@ def resolve_route(
|
||||
"""Resolve the handling agent from priority-ordered bindings.
|
||||
|
||||
Bindings are evaluated most-specific-first: the matching binding with the
|
||||
highest ``priority`` wins; ties are broken by specificity (exact thread →
|
||||
peer → role/channel → guild/account → chat-type), then by declaration
|
||||
order.
|
||||
highest ``priority`` wins; ties are broken by specificity (exact peer →
|
||||
role/channel → account → chat-type), then by declaration order.
|
||||
|
||||
Args:
|
||||
bindings: Candidate route bindings (any order).
|
||||
@@ -3424,150 +3381,6 @@ class ScaleToZeroPolicy:
|
||||
GatewayIdlePolicy = GatewayIdlePolicyProtocol
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway freeze-thaw (involuntary host-suspend gap) recovery (Issue #4767)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThawDecision:
|
||||
"""Result of a freeze-thaw (host-suspend gap) evaluation.
|
||||
|
||||
Attributes:
|
||||
suspended: Whether an involuntary host-suspend gap was detected
|
||||
since the previous observation.
|
||||
gap_seconds: Estimated duration the host was frozen (0.0 when not
|
||||
suspended).
|
||||
restart_transports: Whether the wrapper should re-probe / restart
|
||||
outbound channel sockets (idle-gated) because they may be
|
||||
silently half-dead after the freeze.
|
||||
reconcile_schedule: Whether the wrapper should reconcile the
|
||||
scheduler (coalesce missed cron fires) against the gap instead
|
||||
of letting wall-clock catch-up storm on resume.
|
||||
"""
|
||||
|
||||
suspended: bool
|
||||
gap_seconds: float = 0.0
|
||||
restart_transports: bool = False
|
||||
reconcile_schedule: bool = False
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ThawPolicyProtocol(Protocol):
|
||||
"""Protocol for freeze-thaw (involuntary host-suspend) detection.
|
||||
|
||||
Pure, import-free decision contract consumed by the wrapper's
|
||||
``BotOS`` run-loop. The wrapper ticks the policy with a matched pair
|
||||
of ``time.monotonic()`` and ``time.time()`` readings; the policy
|
||||
compares the elapsed monotonic delta against the elapsed wall-clock
|
||||
delta and returns a :class:`ThawDecision`. On Linux ``CLOCK_MONOTONIC``
|
||||
does not advance while the host is suspended, so a wall-clock delta
|
||||
that runs far ahead of the monotonic delta is a positive signature of
|
||||
a freeze the process could not otherwise observe. Concrete recovery
|
||||
(restart channel sockets when idle, refresh presence, reconcile the
|
||||
scheduler) lives in the wrapper; this contract keeps the *detection*
|
||||
testable in isolation.
|
||||
"""
|
||||
|
||||
def observe(self, *, monotonic_now: float, wall_now: float) -> ThawDecision:
|
||||
"""Return a :class:`ThawDecision` for the supplied clock readings."""
|
||||
...
|
||||
|
||||
|
||||
class WallClockGapThawPolicy:
|
||||
"""Config-driven default freeze-thaw detector.
|
||||
|
||||
The default referenced by ``gateway.thaw:`` blocks in ``gateway.yaml``
|
||||
and the ``BotOS(..., thaw_policy=...)`` Python surface. It is
|
||||
intentionally minimal and dependency-free so the decision lives in
|
||||
core and is provable in isolation; the wrapper owns the side effects
|
||||
(restart channel sockets, refresh presence, reconcile the scheduler).
|
||||
|
||||
A suspend gap is detected when, between two consecutive
|
||||
:meth:`observe` calls, the wall-clock delta ran ahead of the monotonic
|
||||
delta by more than ``gap_threshold_s`` *and* the monotonic clock itself
|
||||
stayed near-frozen (advanced by no more than one tick plus a small
|
||||
tolerance). Both conditions are the true signature of a frozen host:
|
||||
wall-clock jumps forward while ``CLOCK_MONOTONIC`` stalls. The reported
|
||||
``gap_seconds`` is the wall-vs-monotonic divergence — the time the host
|
||||
was actually frozen.
|
||||
|
||||
Requiring the monotonic delta to stay near-frozen distinguishes a real
|
||||
host suspend from a forward *wall-clock correction* (NTP step / manual
|
||||
clock set) that occurs while the process is running normally: in the
|
||||
correction case monotonic keeps advancing on schedule, so no recovery
|
||||
is requested and healthy transports/schedule are left untouched.
|
||||
|
||||
The first observation only seeds the baseline and never reports a gap,
|
||||
so the default preserves current behaviour when no freeze occurs.
|
||||
|
||||
Example::
|
||||
|
||||
WallClockGapThawPolicy(tick_interval_s=15.0, gap_threshold_s=60.0)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tick_interval_s: float = 15.0,
|
||||
gap_threshold_s: float = 60.0,
|
||||
enabled: bool = True,
|
||||
):
|
||||
if tick_interval_s <= 0:
|
||||
raise ValueError(
|
||||
f"tick_interval_s must be > 0, got {tick_interval_s!r}"
|
||||
)
|
||||
if gap_threshold_s <= 0:
|
||||
raise ValueError(
|
||||
f"gap_threshold_s must be > 0, got {gap_threshold_s!r}"
|
||||
)
|
||||
self.tick_interval_s = float(tick_interval_s)
|
||||
self.gap_threshold_s = float(gap_threshold_s)
|
||||
self.enabled = bool(enabled)
|
||||
self._last_monotonic: Optional[float] = None
|
||||
self._last_wall: Optional[float] = None
|
||||
|
||||
def observe(self, *, monotonic_now: float, wall_now: float) -> ThawDecision:
|
||||
if not self.enabled:
|
||||
return ThawDecision(suspended=False)
|
||||
|
||||
prev_monotonic = self._last_monotonic
|
||||
prev_wall = self._last_wall
|
||||
self._last_monotonic = monotonic_now
|
||||
self._last_wall = wall_now
|
||||
|
||||
# First tick only seeds the baseline; nothing to compare against.
|
||||
if prev_monotonic is None or prev_wall is None:
|
||||
return ThawDecision(suspended=False)
|
||||
|
||||
monotonic_delta = monotonic_now - prev_monotonic
|
||||
wall_delta = wall_now - prev_wall
|
||||
# Divergence: how far wall-clock ran ahead of the process's own
|
||||
# elapsed (monotonic) time — i.e. the frozen window.
|
||||
divergence = wall_delta - monotonic_delta
|
||||
|
||||
# A real host suspend freezes CLOCK_MONOTONIC: it stalls, so across
|
||||
# the gap it advances by less than one scheduled tick. A forward
|
||||
# wall-clock *correction* (NTP step / manual set) instead leaves
|
||||
# monotonic advancing in step with the loop — a full tick or more —
|
||||
# proving the process kept running. Requiring monotonic to have
|
||||
# stalled below one tick rejects that false positive so healthy
|
||||
# transports and the scheduler are left untouched.
|
||||
monotonic_frozen = monotonic_delta < self.tick_interval_s
|
||||
gap_detected = (
|
||||
divergence > self.gap_threshold_s
|
||||
and monotonic_frozen
|
||||
)
|
||||
if not gap_detected:
|
||||
return ThawDecision(suspended=False)
|
||||
|
||||
return ThawDecision(
|
||||
suspended=True,
|
||||
gap_seconds=divergence,
|
||||
restart_transports=True,
|
||||
reconcile_schedule=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway graceful-drain on shutdown (Issue #2375)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -4315,193 +4128,6 @@ class SlidingWindowRateLimitPolicy:
|
||||
RateLimitPolicy = RateLimitPolicyProtocol
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway cost-aware spend-budget admission (Issue #4894)
|
||||
#
|
||||
# Rate limiting gates on *request count*; a public multi-user gateway also
|
||||
# needs to gate on *cumulative cost per identity*. This mirrors
|
||||
# :class:`RateLimitPolicyProtocol` exactly — a pure, import-free decision over
|
||||
# typed facts (an identity, a scope, the amount already ``spent_usd`` in the
|
||||
# window and a timestamp) returning the same closed :class:`RateLimitDecision`.
|
||||
# The wrapper supplies ``spent_usd`` from a durable
|
||||
# :class:`~praisonaiagents.telemetry.protocols.TokenUsageSinkProtocol`
|
||||
# (e.g. :class:`~praisonaiagents.telemetry.durable_sink.SqliteTokenUsageSink`)
|
||||
# and consults this policy at the same admit decision as concurrency /
|
||||
# rate-limit, *before* the LLM call. Concrete state and storage live outside
|
||||
# the decision, keeping it injectable and testable in isolation.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SpendBudgetPolicyProtocol(Protocol):
|
||||
"""Protocol for gateway cumulative-spend admission decisions.
|
||||
|
||||
Pure, import-free decision contract symmetric with
|
||||
:class:`RateLimitPolicyProtocol`. The wrapper supplies typed facts — the
|
||||
caller ``identity``, the ``scope`` (channel / tenant token), the amount
|
||||
already ``spent_usd`` for that identity+scope in the current window, the
|
||||
current ``now`` timestamp, and (optionally) the ``pending_usd`` estimated
|
||||
cost of the turn being admitted and the ``oldest_spend_ts`` of the spend in
|
||||
the window — and the policy decides whether the turn is allowed or
|
||||
rejected/throttled with a ``retry_after_seconds`` hint. Storage of the
|
||||
spend ledger lives in a
|
||||
:class:`~praisonaiagents.telemetry.protocols.TokenUsageSinkProtocol`; this
|
||||
contract keeps only the *decision* injectable and provable in isolation.
|
||||
|
||||
A config-driven default (:class:`WindowedSpendBudgetPolicy`) is provided
|
||||
for the common "cap $X per identity per rolling window" case.
|
||||
"""
|
||||
|
||||
def check(
|
||||
self,
|
||||
*,
|
||||
identity: str,
|
||||
scope: str,
|
||||
spent_usd: float,
|
||||
now: float,
|
||||
pending_usd: float = 0.0,
|
||||
oldest_spend_ts: Optional[float] = None,
|
||||
) -> RateLimitDecision:
|
||||
"""Return a :class:`RateLimitDecision` for the supplied facts."""
|
||||
...
|
||||
|
||||
|
||||
class WindowedSpendBudgetPolicy:
|
||||
"""Config-driven cumulative per-identity spend-budget policy.
|
||||
|
||||
Intended as the default the wrapper will wire onto a forthcoming
|
||||
``gateway.budget`` config block and a ``spend_budget_policy=`` gateway
|
||||
constructor argument (neither of which exists in the repo yet — this PR
|
||||
ships only the core decision primitive; the CLI/YAML and constructor
|
||||
surfaces are a follow-up wrapper change). It is intentionally minimal and
|
||||
dependency-free so the decision lives in core and is provable in isolation,
|
||||
exactly like :class:`SlidingWindowRateLimitPolicy`.
|
||||
|
||||
The decision, given the amount ``spent_usd`` already accumulated for the
|
||||
identity+scope in the current window and the ``pending_usd`` estimated cost
|
||||
of the turn being admitted:
|
||||
|
||||
* ``allowed`` while ``spent_usd + pending_usd`` is below ``limit_usd``.
|
||||
* Otherwise the turn is denied with a ``retry_after_seconds`` hint equal to
|
||||
the time until enough spend rolls out of the window. When
|
||||
``oldest_spend_ts`` is supplied the hint is computed precisely
|
||||
(``oldest_spend_ts + window_seconds - now``); without it the policy falls
|
||||
back to the full ``window_seconds`` as a safe upper bound.
|
||||
|
||||
Passing ``pending_usd`` lets a caller reserve budget for the turn's
|
||||
estimated cost *before* the LLM call so a single expensive turn cannot
|
||||
overshoot the cap; leaving it at the default ``0.0`` preserves the simpler
|
||||
"gate on spend so far" behaviour.
|
||||
|
||||
A ``limit_usd`` of ``0`` (or negative) disables the budget entirely (every
|
||||
turn is allowed) — the legacy default when no budget is configured.
|
||||
|
||||
Unlike :class:`SlidingWindowRateLimitPolicy` this policy is *stateless*: the
|
||||
cumulative spend is owned by a durable
|
||||
:class:`~praisonaiagents.telemetry.protocols.TokenUsageSinkProtocol`, so the
|
||||
same budget survives restarts and is shared across processes.
|
||||
|
||||
Example::
|
||||
|
||||
WindowedSpendBudgetPolicy(limit_usd=2.00, window_seconds=86_400)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
limit_usd: float = 0.0,
|
||||
window_seconds: float = 86_400.0,
|
||||
):
|
||||
try:
|
||||
limit = float(limit_usd)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise ValueError(
|
||||
f"limit_usd must be a number, got {limit_usd!r}"
|
||||
) from err
|
||||
try:
|
||||
window = float(window_seconds)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise ValueError(
|
||||
f"window_seconds must be a number, got {window_seconds!r}"
|
||||
) from err
|
||||
if window <= 0:
|
||||
raise ValueError(
|
||||
f"window_seconds must be > 0, got {window_seconds!r}"
|
||||
)
|
||||
self.limit_usd = limit
|
||||
self.window_seconds = window
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Whether budgeting is active (a positive limit is set)."""
|
||||
return self.limit_usd > 0
|
||||
|
||||
def window_start(self, now: float) -> float:
|
||||
"""Start timestamp of the current window for a ``now`` timestamp.
|
||||
|
||||
Helper for the wrapper so it can query the durable sink for
|
||||
``spent_usd`` over the same window the policy enforces.
|
||||
"""
|
||||
return float(now) - self.window_seconds
|
||||
|
||||
def check(
|
||||
self,
|
||||
*,
|
||||
identity: str,
|
||||
scope: str,
|
||||
spent_usd: float,
|
||||
now: float,
|
||||
pending_usd: float = 0.0,
|
||||
oldest_spend_ts: Optional[float] = None,
|
||||
) -> RateLimitDecision:
|
||||
# Disabled: preserve legacy always-allow behaviour.
|
||||
if self.limit_usd <= 0:
|
||||
return RateLimitDecision(allowed=True)
|
||||
try:
|
||||
spent = float(spent_usd)
|
||||
except (TypeError, ValueError):
|
||||
spent = 0.0
|
||||
try:
|
||||
pending = float(pending_usd)
|
||||
except (TypeError, ValueError):
|
||||
pending = 0.0
|
||||
if pending < 0:
|
||||
pending = 0.0
|
||||
# Reserve budget for the pending turn's estimated cost so a single
|
||||
# expensive turn cannot overshoot the cap before its cost is recorded.
|
||||
if spent + pending < self.limit_usd:
|
||||
return RateLimitDecision(allowed=True)
|
||||
return RateLimitDecision(
|
||||
allowed=False,
|
||||
retry_after_seconds=self._retry_after(now, oldest_spend_ts),
|
||||
)
|
||||
|
||||
def _retry_after(
|
||||
self, now: float, oldest_spend_ts: Optional[float]
|
||||
) -> float:
|
||||
"""Seconds until enough spend rolls out of the rolling window.
|
||||
|
||||
With ``oldest_spend_ts`` the earliest in-window charge expires at
|
||||
``oldest_spend_ts + window_seconds``; the hint is the time until then,
|
||||
clamped to ``(0, window_seconds]``. Without it, fall back to the full
|
||||
window as a safe upper bound.
|
||||
"""
|
||||
if oldest_spend_ts is None:
|
||||
return self.window_seconds
|
||||
try:
|
||||
remaining = (float(oldest_spend_ts) + self.window_seconds) - float(now)
|
||||
except (TypeError, ValueError):
|
||||
return self.window_seconds
|
||||
if remaining <= 0:
|
||||
# Spend has already aged out; retry effectively immediately.
|
||||
return 0.0
|
||||
return min(remaining, self.window_seconds)
|
||||
|
||||
|
||||
# Backward-compatible alias following the repo's ``*Protocol`` convention.
|
||||
SpendBudgetPolicy = SpendBudgetPolicyProtocol
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Durable-queue dead-letter decision (Issue #3519)
|
||||
#
|
||||
@@ -5177,308 +4803,6 @@ class RestartLoopGuard:
|
||||
self._events = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process-lifecycle record + unclean/OOM classification (Issue #4603)
|
||||
#
|
||||
# ``RestartLoopGuard`` above tracks the *rate* of restart-worthy boots in
|
||||
# memory, and ``classify_exit_reason`` maps a caught exception to an exit code.
|
||||
# Neither survives a ``SIGKILL``/OOM-kill — no in-process hook runs for those —
|
||||
# so a hosted gateway cannot tell on its next boot that its previous process
|
||||
# died uncleanly. These three pure primitives close that gap: a small durable
|
||||
# lifecycle *record* the runtime restamps on boot/shutdown, a pure predicate
|
||||
# that classifies "was the last exit clean?", and a restart-loop guard whose
|
||||
# events load from / flush to an injected durable store so the breaker survives
|
||||
# the very death it guards. All I/O — the ``/proc``/cgroup sampling and the
|
||||
# durable sentinel — lives in the ``praisonai-bot`` runtime; core stays
|
||||
# protocol + pure default policy, symmetric with the block above.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LifecycleRecord:
|
||||
"""Durable, boot-scoped snapshot of the gateway process's lifecycle.
|
||||
|
||||
Written by the runtime at boot (``phase="running"``) and on a graceful stop
|
||||
(``phase="exited"``), with a memory sample folded in by the heartbeat. On
|
||||
the *next* boot the runtime reads the prior record and runs
|
||||
:func:`classify_unclean_exit`: a record still marked ``running`` means the
|
||||
previous process never reached its graceful-stop path — an unclean exit,
|
||||
and (given a high last memory sample) a suspected OOM-kill.
|
||||
|
||||
Pure and JSON-round-trippable so both the durable store and the health
|
||||
surface share one shape; the wrapper owns persistence.
|
||||
|
||||
Attributes:
|
||||
phase: ``"running"`` | ``"draining"`` | ``"exited"`` — the last phase
|
||||
the process recorded for itself.
|
||||
exit_kind: ``"clean"`` | ``"unclean"`` | ``"unknown"`` — set by
|
||||
:func:`classify_unclean_exit` on the *next* boot (never
|
||||
``"unknown"``-treated-as-healthy downstream).
|
||||
suspected_oom: ``True`` when an unclean exit coincided with a high last
|
||||
memory sample (memory pressure at death).
|
||||
last_mem_fraction: Last sampled memory-budget fraction in ``[0, 1]``, or
|
||||
``None`` when the platform could not report it.
|
||||
restart_events: Persisted monotonic-independent wall-clock timestamps of
|
||||
restart-worthy boots, for :class:`PersistentRestartLoopGuard`.
|
||||
"""
|
||||
|
||||
phase: Literal["running", "draining", "exited"] = "running"
|
||||
exit_kind: Literal["clean", "unclean", "unknown"] = "unknown"
|
||||
suspected_oom: bool = False
|
||||
last_mem_fraction: Optional[float] = None
|
||||
restart_events: List[float] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a JSON-serializable dict for the durable sentinel."""
|
||||
return {
|
||||
"phase": self.phase,
|
||||
"exit_kind": self.exit_kind,
|
||||
"suspected_oom": self.suspected_oom,
|
||||
"last_mem_fraction": self.last_mem_fraction,
|
||||
"restart_events": list(self.restart_events),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Mapping[str, Any]]) -> "LifecycleRecord":
|
||||
"""Rebuild from a persisted dict, tolerating partial/legacy shapes."""
|
||||
if not isinstance(data, Mapping):
|
||||
return cls()
|
||||
|
||||
phase = data.get("phase")
|
||||
if phase not in ("running", "draining", "exited"):
|
||||
phase = "running"
|
||||
exit_kind = data.get("exit_kind")
|
||||
if exit_kind not in ("clean", "unclean", "unknown"):
|
||||
exit_kind = "unknown"
|
||||
|
||||
frac = data.get("last_mem_fraction")
|
||||
try:
|
||||
frac_val: Optional[float] = None if frac is None else float(frac)
|
||||
except (TypeError, ValueError):
|
||||
frac_val = None
|
||||
|
||||
raw_events = data.get("restart_events") or []
|
||||
events: List[float] = []
|
||||
if isinstance(raw_events, (list, tuple)):
|
||||
for item in raw_events:
|
||||
try:
|
||||
events.append(float(item))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
return cls(
|
||||
phase=phase, # type: ignore[arg-type]
|
||||
exit_kind=exit_kind, # type: ignore[arg-type]
|
||||
suspected_oom=bool(data.get("suspected_oom", False)),
|
||||
last_mem_fraction=frac_val,
|
||||
restart_events=events,
|
||||
)
|
||||
|
||||
|
||||
def classify_unclean_exit(
|
||||
prev: Optional[LifecycleRecord],
|
||||
*,
|
||||
oom_fraction: float = 0.9,
|
||||
) -> LifecycleRecord:
|
||||
"""Classify the previous boot's exit and return a fresh boot record (pure).
|
||||
|
||||
Given the record persisted by the *previous* process, decide whether that
|
||||
process exited cleanly and stamp a new ``phase="running"`` record for *this*
|
||||
boot. Side-effect free so it is provable in isolation, mirroring
|
||||
:func:`classify_exit_reason`.
|
||||
|
||||
Rules:
|
||||
|
||||
* No prior record ⇒ first boot / fresh store: ``exit_kind="unknown"`` (the
|
||||
caller must never treat ``"unknown"`` as healthy, but there is nothing to
|
||||
resume).
|
||||
* Prior ``phase == "exited"`` ⇒ the previous process reached its graceful
|
||||
stop: ``exit_kind="clean"``.
|
||||
* Prior ``phase in ("running", "draining")`` ⇒ the previous process died
|
||||
before recording a clean exit (SIGKILL/OOM/segfault/orchestrator kill):
|
||||
``exit_kind="unclean"``, and ``suspected_oom`` when its last memory sample
|
||||
was at/above ``oom_fraction`` of the budget.
|
||||
|
||||
The returned record carries forward the prior ``restart_events`` so the
|
||||
persistent restart-loop guard sees the full cross-boot history.
|
||||
|
||||
Args:
|
||||
prev: The previous process's persisted :class:`LifecycleRecord`, or
|
||||
``None`` on a fresh store.
|
||||
oom_fraction: Memory-budget fraction at/above which an unclean exit is
|
||||
flagged as a suspected OOM-kill.
|
||||
|
||||
Returns:
|
||||
A fresh ``phase="running"`` :class:`LifecycleRecord` for this boot.
|
||||
"""
|
||||
carried = list(prev.restart_events) if prev is not None else []
|
||||
if prev is None:
|
||||
return LifecycleRecord(
|
||||
phase="running", exit_kind="unknown", restart_events=carried
|
||||
)
|
||||
if prev.phase == "exited":
|
||||
return LifecycleRecord(
|
||||
phase="running", exit_kind="clean", restart_events=carried
|
||||
)
|
||||
# Still "running"/"draining" in the prior record ⇒ never reached graceful stop.
|
||||
frac = prev.last_mem_fraction
|
||||
suspected_oom = frac is not None and frac >= oom_fraction
|
||||
return LifecycleRecord(
|
||||
phase="running",
|
||||
exit_kind="unclean",
|
||||
suspected_oom=suspected_oom,
|
||||
last_mem_fraction=frac,
|
||||
restart_events=carried,
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RestartStoreProtocol(Protocol):
|
||||
"""Durable-store contract for cross-boot restart timestamps (Issue #4603).
|
||||
|
||||
The pure decision (:class:`PersistentRestartLoopGuard`) lives in core; the
|
||||
wrapper injects a concrete store that reads/writes the timestamps to a small
|
||||
sentinel file. Both methods are best-effort: an implementation that cannot
|
||||
reach its backing store returns ``[]`` from :meth:`load_events` and silently
|
||||
drops :meth:`save_events`, so the breaker degrades to in-memory behaviour
|
||||
rather than crashing the gateway it protects.
|
||||
"""
|
||||
|
||||
def load_events(self) -> List[float]:
|
||||
"""Return the persisted restart timestamps (``[]`` when unavailable)."""
|
||||
...
|
||||
|
||||
def save_events(self, events: List[float]) -> None:
|
||||
"""Persist the restart timestamps (best-effort, never raises)."""
|
||||
...
|
||||
|
||||
|
||||
class PersistentRestartLoopGuard(RestartLoopGuard):
|
||||
"""A :class:`RestartLoopGuard` whose events survive a process restart.
|
||||
|
||||
The in-memory guard is reconstructed fresh on every boot, so a persisted
|
||||
turn that hard-crashes the *whole* process (OOM, segfault) can drive an
|
||||
infinite ``supervisor → boot → auto-resume → crash`` loop the breaker never
|
||||
sees. This subclass loads its event history from an injected
|
||||
:class:`RestartStoreProtocol` at construction and flushes back on every
|
||||
mutation, so the breaker observes the true cross-boot restart rate and can
|
||||
trip on a crash cycle *slower* than a single in-process window.
|
||||
|
||||
The window defaults to one hour (vs. the in-process 60s) because a
|
||||
cross-boot crash loop is paced by process (re)start latency, not by an
|
||||
in-loop retry. Pure decision preserved: all persistence is delegated to the
|
||||
injected store, so the trip logic remains provable in isolation.
|
||||
|
||||
Example::
|
||||
|
||||
guard = PersistentRestartLoopGuard(store, max_restarts=3,
|
||||
window_seconds=3600)
|
||||
if guard.record(now=time.time()):
|
||||
# too many restart-interrupted boots — stop auto-resuming the turn
|
||||
...
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: RestartStoreProtocol,
|
||||
*,
|
||||
max_restarts: int = 3,
|
||||
window_seconds: float = 3600.0,
|
||||
):
|
||||
super().__init__(max_restarts=max_restarts, window_seconds=window_seconds)
|
||||
self._store = store
|
||||
try:
|
||||
loaded = store.load_events()
|
||||
except Exception:
|
||||
loaded = []
|
||||
if loaded:
|
||||
self._events = [float(t) for t in loaded]
|
||||
|
||||
def _flush(self) -> None:
|
||||
try:
|
||||
self._store.save_events(list(self._events))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def record(self, now: float) -> bool:
|
||||
"""Record a restart at wall-clock ``now`` and flush to the store.
|
||||
|
||||
Unlike the base class this expects a *wall-clock* timestamp
|
||||
(``time.time()``), because a monotonic clock resets across process
|
||||
boots and would make persisted events meaningless.
|
||||
"""
|
||||
tripped = super().record(now)
|
||||
self._flush()
|
||||
return tripped
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear the history in memory *and* in the durable store."""
|
||||
super().reset()
|
||||
self._flush()
|
||||
|
||||
|
||||
def classify_resource_pressure(
|
||||
*,
|
||||
mem_fraction: Optional[float] = None,
|
||||
disk_free_mb: Optional[float] = None,
|
||||
mem_elevated: float = 0.75,
|
||||
mem_critical: float = 0.9,
|
||||
disk_min_free_mb: float = 512.0,
|
||||
) -> Dict[str, str]:
|
||||
"""Classify coarse memory + disk pressure for the health surface (pure).
|
||||
|
||||
A tiny, redaction-safe classifier that folds the wrapper's ``/proc``/cgroup
|
||||
memory-budget fraction and free-disk sample into a closed ``ok`` /
|
||||
``elevated`` / ``critical`` / ``unknown`` vocabulary. Lives in core so the
|
||||
SDK and the bot classify identically; the wrapper owns the sampling.
|
||||
|
||||
``unknown`` is returned when a signal is missing and — per the issue — must
|
||||
never be treated as healthy by callers (it is deliberately *not* ``ok``).
|
||||
|
||||
Args:
|
||||
mem_fraction: Anonymous-RSS / memory-budget fraction in ``[0, ∞)``, or
|
||||
``None`` when the platform has no cgroup limit to report against.
|
||||
disk_free_mb: Free disk headroom in MiB for the gateway's state dir, or
|
||||
``None`` when it could not be sampled.
|
||||
mem_elevated: Fraction at/above which memory is ``"elevated"``.
|
||||
mem_critical: Fraction at/above which memory is ``"critical"``.
|
||||
disk_min_free_mb: Free-MiB floor below which disk is ``"critical"``;
|
||||
``"elevated"`` below twice that.
|
||||
|
||||
Returns:
|
||||
``{"memory": <level>, "disk": <level>}`` with values drawn from the
|
||||
closed vocabulary above.
|
||||
"""
|
||||
|
||||
def _mem(frac: Optional[float]) -> str:
|
||||
if frac is None:
|
||||
return "unknown"
|
||||
try:
|
||||
f = float(frac)
|
||||
except (TypeError, ValueError):
|
||||
return "unknown"
|
||||
if f >= mem_critical:
|
||||
return "critical"
|
||||
if f >= mem_elevated:
|
||||
return "elevated"
|
||||
return "ok"
|
||||
|
||||
def _disk(free_mb: Optional[float]) -> str:
|
||||
if free_mb is None:
|
||||
return "unknown"
|
||||
try:
|
||||
mb = float(free_mb)
|
||||
except (TypeError, ValueError):
|
||||
return "unknown"
|
||||
if mb < disk_min_free_mb:
|
||||
return "critical"
|
||||
if mb < disk_min_free_mb * 2:
|
||||
return "elevated"
|
||||
return "ok"
|
||||
|
||||
return {"memory": _mem(mem_fraction), "disk": _disk(disk_free_mb)}
|
||||
|
||||
|
||||
class FleetSupervisionPolicy:
|
||||
"""Pure fleet-level crash-loop breaker for channel supervision (Issue #3840).
|
||||
|
||||
|
||||
@@ -14,14 +14,6 @@ from .protocols import (
|
||||
is_guardrail_object,
|
||||
)
|
||||
from .chain import GuardrailChain
|
||||
from .retry import GuardrailRetry
|
||||
from .tool_guardrails import (
|
||||
ToolInputGuardrail,
|
||||
ToolOutputGuardrail,
|
||||
ToolGuardrailChain,
|
||||
build_tool_guardrails,
|
||||
get_tool_guardrail_chain,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"GuardrailResult",
|
||||
@@ -30,12 +22,5 @@ __all__ = [
|
||||
"StructuralGuardrailProtocol",
|
||||
"PolicyGuardrailProtocol",
|
||||
"GuardrailChain",
|
||||
"GuardrailRetry",
|
||||
"is_guardrail_object",
|
||||
# Per-tool guardrails (declared via @tool(input_guardrails=/output_guardrails=))
|
||||
"ToolInputGuardrail",
|
||||
"ToolOutputGuardrail",
|
||||
"ToolGuardrailChain",
|
||||
"build_tool_guardrails",
|
||||
"get_tool_guardrail_chain",
|
||||
]
|
||||
@@ -6,7 +6,6 @@ separated from the protocol definitions for clean architecture.
|
||||
"""
|
||||
from typing import List, Dict, Any, Tuple
|
||||
from .protocols import GuardrailProtocol
|
||||
from .retry import GuardrailRetry
|
||||
|
||||
|
||||
class GuardrailChain:
|
||||
@@ -79,22 +78,7 @@ class GuardrailChain:
|
||||
return False, arguments # Fail closed on error
|
||||
|
||||
return True, arguments
|
||||
|
||||
def validate_tool_result(self, tool_name: str, result: Any, **kwargs) -> Tuple[bool, Any]:
|
||||
"""Validate a tool result through all guardrails (fail-closed)."""
|
||||
for guardrail in self.guardrails:
|
||||
if hasattr(guardrail, 'validate_tool_result'):
|
||||
try:
|
||||
is_valid, processed_result = guardrail.validate_tool_result(tool_name, result, **kwargs)
|
||||
if not is_valid:
|
||||
return False, result # Failed validation
|
||||
result = processed_result # Update result with processing (e.g. redaction)
|
||||
except Exception as e:
|
||||
if not self.fail_open:
|
||||
return False, result # Fail closed on error
|
||||
|
||||
return True, result
|
||||
|
||||
|
||||
def _validate_through_chain(self, method_name: str, content: str, **kwargs) -> Tuple[bool, str]:
|
||||
"""Internal helper to validate content through the guardrail chain."""
|
||||
for guardrail in self.guardrails:
|
||||
@@ -105,11 +89,6 @@ class GuardrailChain:
|
||||
if not is_valid:
|
||||
return False, processed_content # Failed validation
|
||||
content = processed_content # Update content with processing
|
||||
except GuardrailRetry as e:
|
||||
# Deliberate "not acceptable, here is why" signal: surface the
|
||||
# author's message verbatim so it can be handed to the model,
|
||||
# instead of burying it under the generic error wrapper below.
|
||||
return False, e.feedback
|
||||
except Exception as e:
|
||||
if not self.fail_open:
|
||||
return False, f"Guardrail error: {str(e)}" # Fail closed on error
|
||||
|
||||
@@ -194,26 +194,7 @@ Your response:"""
|
||||
|
||||
is_valid, result = self._llm_validate(tool_text, tool_description)
|
||||
return is_valid, arguments # Return original arguments (LLM doesn't modify them)
|
||||
|
||||
def validate_tool_result(self, tool_name: str, result: Any, **kwargs) -> Tuple[bool, Any]:
|
||||
"""
|
||||
Validate a tool's result using LLM reasoning before it re-enters context.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool that produced the result
|
||||
result: Tool result to validate
|
||||
**kwargs: Additional context
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid: bool, processed_result: Any)
|
||||
"""
|
||||
# Convert tool result to text for LLM validation
|
||||
result_text = f"Tool: {tool_name}, Result: {result}"
|
||||
result_description = f"Validate this tool result: {self.description}"
|
||||
|
||||
is_valid, _ = self._llm_validate(result_text, result_description)
|
||||
return is_valid, result # Return original result (LLM doesn't modify it)
|
||||
|
||||
|
||||
def _llm_validate(self, content: str, description: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Internal method to perform LLM validation with custom description.
|
||||
|
||||
@@ -16,7 +16,6 @@ class GuardrailProtocol(Protocol):
|
||||
Guardrails can validate:
|
||||
- Input prompts (before processing)
|
||||
- Tool call arguments (before execution)
|
||||
- Tool results (before they re-enter the LLM context)
|
||||
- Agent outputs (before returning to user)
|
||||
|
||||
Example:
|
||||
@@ -77,29 +76,6 @@ class GuardrailProtocol(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def validate_tool_result(self, tool_name: str, result: Any, **kwargs) -> Tuple[bool, Any]:
|
||||
"""
|
||||
Validate/redact a tool's result before it re-enters the LLM context.
|
||||
|
||||
Runs after the tool executes and before its output is handed back to
|
||||
the model, trace, or downstream channel. Lets a guardrail inspect or
|
||||
rewrite raw tool output (e.g. redact a leaked secret, strip a
|
||||
prompt-injection payload) that ``validate_output`` never sees because
|
||||
it only ever receives the model's paraphrase.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool that produced the result
|
||||
result: The tool's raw result to validate
|
||||
**kwargs: Additional context (agent_name, etc.)
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid: bool, processed_result: Any)
|
||||
- If is_valid=False, the result is rejected (fail-closed)
|
||||
- If is_valid=True, processed_result may be the original or a
|
||||
redacted/rewritten value
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class StructuralGuardrailProtocol(Protocol):
|
||||
@@ -202,7 +178,7 @@ class PolicyGuardrailProtocol(Protocol):
|
||||
|
||||
# Method names that make an object a guardrail. An object exposing at least one
|
||||
# of these is a protocol-conforming guardrail even though it is not ``callable``.
|
||||
GUARDRAIL_PROTOCOL_METHODS = ("validate_input", "validate_output", "validate_tool_call", "validate_tool_result")
|
||||
GUARDRAIL_PROTOCOL_METHODS = ("validate_input", "validate_output", "validate_tool_call")
|
||||
|
||||
|
||||
def is_guardrail_object(obj: Any) -> bool:
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""In-run guardrail retry signalling.
|
||||
|
||||
A guardrail normally reports failure by returning ``(False, "why")``. That works
|
||||
when the validator *is* the thing doing the checking, but not when the check
|
||||
lives several frames down - a Pydantic model that fails to parse, a lookup that
|
||||
comes back empty, a helper that already raises. ``GuardrailRetry`` lets any of
|
||||
those hand their reason straight back to the model:
|
||||
|
||||
from praisonaiagents import Agent, GuardrailRetry
|
||||
|
||||
def in_customer_country(output):
|
||||
if not output.raw.startswith("SW1"):
|
||||
raise GuardrailRetry("the postcode must be in the customer's country")
|
||||
return True, output
|
||||
|
||||
agent = Agent(instructions="...", guardrails=in_customer_country)
|
||||
|
||||
The message is appended to the *same* conversation as a user turn, so the model
|
||||
patches its previous answer in place instead of restarting the task, bounded by
|
||||
``max_guardrail_retries`` (``GuardrailConfig(max_retries=...)``).
|
||||
|
||||
Any other exception raised by a guardrail is still treated as a validation
|
||||
*error* and reported as such; ``GuardrailRetry`` is the deliberate "not
|
||||
acceptable, here is why" signal.
|
||||
"""
|
||||
|
||||
__all__ = ["GuardrailRetry"]
|
||||
|
||||
|
||||
class GuardrailRetry(Exception):
|
||||
"""Raised by a guardrail to send its reason back to the model for another attempt.
|
||||
|
||||
Args:
|
||||
feedback: Plain-language reason the output was rejected. It is shown to
|
||||
the model verbatim, so write it as an instruction to the model
|
||||
("the postcode must be in the customer's country"), not as a stack
|
||||
trace.
|
||||
|
||||
Attributes:
|
||||
feedback: The same string, kept as a named attribute so callers do not
|
||||
have to ``str()`` the exception.
|
||||
"""
|
||||
|
||||
def __init__(self, feedback: str = "Output failed validation"):
|
||||
feedback = str(feedback) if feedback else "Output failed validation"
|
||||
super().__init__(feedback)
|
||||
self.feedback = feedback
|
||||
@@ -1,316 +0,0 @@
|
||||
"""Per-tool guardrails: validate the arguments and the result of ONE tool.
|
||||
|
||||
PraisonAI already had guardrails at two scopes and nothing in between:
|
||||
|
||||
* ``Agent(guardrail=...)`` / ``Task(guardrail=...)`` validate the **final
|
||||
output** of a whole run.
|
||||
* The agent-wide tool surface (``_tool_call_guardrails`` /
|
||||
``_tool_result_guardrails``, fed from a ``GuardrailProtocol`` object passed to
|
||||
``Agent(guardrails=...)``) fires for **every tool** the agent can reach.
|
||||
* ``approval`` gates a tool call on a **human**, interactively.
|
||||
|
||||
None of those let a tool author say "*this one* tool must never be called with a
|
||||
recipient outside the company domain". Doing it today meant policing every tool
|
||||
through interactive approval, or hand-wrapping the function body. This module
|
||||
adds the missing scope, declared on the tool itself next to ``approval`` and
|
||||
``restart_safe``::
|
||||
|
||||
def internal_recipients_only(arguments: dict):
|
||||
if not arguments.get("to", "").endswith("@corp.com"):
|
||||
return False, "Recipient is outside the company domain."
|
||||
return True, arguments
|
||||
|
||||
@tool(input_guardrails=[internal_recipients_only])
|
||||
def send_email(to: str, body: str) -> str:
|
||||
...
|
||||
|
||||
The guardrail fires on every invocation of ``send_email`` and never for any
|
||||
other tool.
|
||||
|
||||
What a guardrail function receives and returns
|
||||
----------------------------------------------
|
||||
An **input** guardrail takes one positional argument, the ``arguments`` dict the
|
||||
model proposed. An **output** guardrail takes one positional argument, the raw
|
||||
result the tool returned. Both use the ``(success, value)`` tuple convention
|
||||
already used everywhere else in this package (``Agent(guardrail=fn)``,
|
||||
``GuardrailProtocol``):
|
||||
|
||||
* ``(True, value)`` - allow; ``value`` replaces the arguments/result, so a
|
||||
guardrail can **rewrite** (sanitise arguments, redact a secret out of a
|
||||
result). ``(True, None)`` allows the original through unchanged.
|
||||
* ``(False, "reason")`` - block. The reason is handed back to the **model** as
|
||||
the tool result so it can react and try something else; it is never raised at
|
||||
the user.
|
||||
* A bare ``True`` / ``False`` works too, as does a
|
||||
:class:`~praisonaiagents.guardrails.GuardrailResult`.
|
||||
* ``None`` (a validator that only ever returns ``False`` to object, and falls
|
||||
off the end otherwise) means "no opinion" - allow unchanged. The deny path is
|
||||
explicit, so nothing is lost by being permissive here.
|
||||
* Anything else is a contract violation and **fails closed**: an unreadable
|
||||
verdict is not an approval.
|
||||
|
||||
A guardrail that raises also fails closed, matching ``GuardrailChain``'s
|
||||
default. Pass an object exposing ``validate_tool_call`` / ``validate_tool_result``
|
||||
(any ``GuardrailProtocol`` implementation, including a ``GuardrailChain``) to
|
||||
reuse an existing guardrail unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from .chain import GuardrailChain
|
||||
from .guardrail_result import GuardrailResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Direction identifiers. An input guardrail gates the arguments before dispatch;
|
||||
# an output guardrail gates the raw result before it re-enters the LLM context.
|
||||
INPUT = "input"
|
||||
OUTPUT = "output"
|
||||
|
||||
# The protocol method each direction drives. Per-tool guardrails deliberately
|
||||
# speak the SAME protocol as agent-wide ones so a guardrail written for one
|
||||
# scope drops into the other with no adaptation.
|
||||
_METHOD_FOR = {INPUT: "validate_tool_call", OUTPUT: "validate_tool_result"}
|
||||
|
||||
_DEFAULT_REASON = {
|
||||
INPUT: "rejected by an input guardrail",
|
||||
OUTPUT: "rejected by an output guardrail",
|
||||
}
|
||||
|
||||
# Attribute a tool declares its guardrails on, per direction.
|
||||
ATTR_FOR = {INPUT: "input_guardrails", OUTPUT: "output_guardrails"}
|
||||
|
||||
# Where a coerced chain is memoised on a tool object, so a tool that declared a
|
||||
# raw list (e.g. a ``BaseTool`` subclass with ``input_guardrails = [fn]`` as a
|
||||
# class attribute) is only coerced once rather than on every invocation.
|
||||
_CACHE_ATTR = "_praison_tool_guardrail_chains"
|
||||
|
||||
|
||||
def _interpret(outcome: Any, original: Any, default_reason: str) -> Tuple[bool, Any, str]:
|
||||
"""Normalise a user guardrail's return value into ``(ok, value, reason)``.
|
||||
|
||||
Accepts every shape documented in the module docstring. Unknown shapes fail
|
||||
closed - a verdict we cannot read must not be treated as an approval.
|
||||
"""
|
||||
if outcome is None:
|
||||
# "No opinion": a validator that objects explicitly (returns False or
|
||||
# raises) and simply falls off the end on the happy path.
|
||||
return True, original, ""
|
||||
|
||||
if isinstance(outcome, GuardrailResult):
|
||||
if outcome.success:
|
||||
# ``result=None`` means "unchanged" - keep the caller's value rather
|
||||
# than replacing it with None.
|
||||
return True, original if outcome.result is None else outcome.result, ""
|
||||
return False, original, outcome.error or default_reason
|
||||
|
||||
# bool must be checked before tuple; it is neither, but ordering keeps the
|
||||
# intent obvious next to the tuple branch.
|
||||
if isinstance(outcome, bool):
|
||||
return (True, original, "") if outcome else (False, original, default_reason)
|
||||
|
||||
if isinstance(outcome, tuple) and len(outcome) == 2:
|
||||
ok, data = outcome
|
||||
if ok:
|
||||
return True, original if data is None else data, ""
|
||||
return False, original, str(data) if data else default_reason
|
||||
|
||||
return False, original, (
|
||||
f"guardrail returned {type(outcome).__name__}; expected (bool, value), "
|
||||
"a bool, a GuardrailResult, or None"
|
||||
)
|
||||
|
||||
|
||||
class _CallableToolGuardrail:
|
||||
"""Adapts a plain ``fn(value) -> (ok, value)`` into the guardrail protocol.
|
||||
|
||||
Keeping the adapter protocol-shaped (rather than special-casing callables in
|
||||
the executor) is what lets per-tool and agent-wide guardrails share one
|
||||
chain implementation and one call path.
|
||||
"""
|
||||
|
||||
direction = INPUT
|
||||
|
||||
def __init__(self, fn, name: Optional[str] = None):
|
||||
if not callable(fn):
|
||||
raise TypeError(
|
||||
f"A tool guardrail must be callable or expose "
|
||||
f"{_METHOD_FOR[self.direction]}(); got {type(fn).__name__}."
|
||||
)
|
||||
self._fn = fn
|
||||
self.name = name or getattr(fn, "__name__", None) or type(fn).__name__
|
||||
|
||||
def _run(self, value: Any) -> Tuple[bool, Any, str]:
|
||||
return _interpret(self._fn(value), value, _DEFAULT_REASON[self.direction])
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
||||
return f"<{type(self).__name__} {self.name}>"
|
||||
|
||||
|
||||
class ToolInputGuardrail(_CallableToolGuardrail):
|
||||
"""Gates a single tool's arguments before the tool runs.
|
||||
|
||||
Wraps a ``fn(arguments: dict)`` validator. A rewrite must still be a mapping
|
||||
of keyword arguments - returning anything else is treated as a contract
|
||||
violation and blocks, because handing a non-mapping to the tool would raise
|
||||
inside user code instead of producing a message the model can act on.
|
||||
"""
|
||||
|
||||
direction = INPUT
|
||||
|
||||
def validate_tool_call(self, tool_name: str, arguments: Dict[str, Any], **kwargs):
|
||||
ok, value, reason = self._run(arguments)
|
||||
if not ok:
|
||||
return False, reason
|
||||
if not isinstance(value, dict):
|
||||
return False, (
|
||||
f"input guardrail '{self.name}' rewrote the arguments to "
|
||||
f"{type(value).__name__}; expected a dict of keyword arguments"
|
||||
)
|
||||
return True, value
|
||||
|
||||
|
||||
class ToolOutputGuardrail(_CallableToolGuardrail):
|
||||
"""Gates a single tool's raw result before it re-enters the LLM context."""
|
||||
|
||||
direction = OUTPUT
|
||||
|
||||
def validate_tool_result(self, tool_name: str, result: Any, **kwargs):
|
||||
ok, value, reason = self._run(result)
|
||||
if not ok:
|
||||
return False, reason
|
||||
return True, value
|
||||
|
||||
|
||||
class ToolGuardrailChain(GuardrailChain):
|
||||
"""A :class:`GuardrailChain` that reports *why* a tool call was rejected.
|
||||
|
||||
The base chain deliberately returns the **unchanged input** on failure
|
||||
(``return False, arguments``) because its agent-wide callers only need the
|
||||
boolean and want to keep their arguments intact. A per-tool guardrail has to
|
||||
hand a message back to the model so it can react, so this subclass
|
||||
propagates the guardrail's reason as the second element instead.
|
||||
|
||||
Everything else - ordering, short-circuit on first failure, fail-closed on a
|
||||
raising guardrail, the ``fail_open`` escape hatch - is inherited unchanged.
|
||||
A second element that is not a non-empty string is not a reason (a raw
|
||||
``GuardrailProtocol`` object echoes the arguments back), so the direction's
|
||||
default reason is used in that case.
|
||||
"""
|
||||
|
||||
def __init__(self, guardrails: List[Any], fail_open: bool = False, direction: str = INPUT):
|
||||
super().__init__(guardrails, fail_open=fail_open)
|
||||
self.direction = direction
|
||||
|
||||
def validate_tool_call(self, tool_name: str, arguments: Dict[str, Any], **kwargs):
|
||||
return self._run_direction(INPUT, tool_name, arguments, **kwargs)
|
||||
|
||||
def validate_tool_result(self, tool_name: str, result: Any, **kwargs):
|
||||
return self._run_direction(OUTPUT, tool_name, result, **kwargs)
|
||||
|
||||
def _run_direction(self, direction: str, tool_name: str, value: Any, **kwargs):
|
||||
method_name = _METHOD_FOR[direction]
|
||||
default_reason = _DEFAULT_REASON[direction]
|
||||
for guardrail in self.guardrails:
|
||||
method = getattr(guardrail, method_name, None)
|
||||
if method is None:
|
||||
continue
|
||||
try:
|
||||
ok, processed = method(tool_name, value, **kwargs)
|
||||
except Exception as e: # noqa: BLE001
|
||||
name = getattr(guardrail, "name", type(guardrail).__name__)
|
||||
if self.fail_open:
|
||||
logger.warning(
|
||||
"Tool guardrail '%s' raised on '%s' and fail_open is set; "
|
||||
"allowing through: %s", name, tool_name, e
|
||||
)
|
||||
continue
|
||||
# Fail closed: a broken guardrail must block, not permit.
|
||||
return False, f"guardrail '{name}' failed: {e}"
|
||||
if not ok:
|
||||
reason = processed if isinstance(processed, str) and processed else default_reason
|
||||
return False, reason
|
||||
value = processed
|
||||
return True, value
|
||||
|
||||
|
||||
def _coerce_one(item: Any, direction: str):
|
||||
"""Turn one user-supplied entry into a protocol-conforming guardrail."""
|
||||
method_name = _METHOD_FOR[direction]
|
||||
if callable(getattr(item, method_name, None)):
|
||||
# Already speaks the protocol (a GuardrailProtocol object, a
|
||||
# GuardrailChain, another ToolGuardrailChain) - reuse it verbatim.
|
||||
return item
|
||||
if callable(item):
|
||||
adapter = ToolInputGuardrail if direction == INPUT else ToolOutputGuardrail
|
||||
return adapter(item)
|
||||
raise TypeError(
|
||||
f"{ATTR_FOR[direction]} entries must be callable or expose "
|
||||
f"{method_name}(); got {type(item).__name__}."
|
||||
)
|
||||
|
||||
|
||||
def build_tool_guardrails(spec: Any, direction: str) -> Optional[ToolGuardrailChain]:
|
||||
"""Coerce an ``input_guardrails=`` / ``output_guardrails=`` value into a chain.
|
||||
|
||||
Accepts a single guardrail or a sequence of them; each entry may be a plain
|
||||
callable or any object already exposing the matching protocol method.
|
||||
Returns ``None`` when nothing was declared, which is what keeps the
|
||||
executor's fast path free of any per-call work for unguarded tools.
|
||||
"""
|
||||
if spec is None:
|
||||
return None
|
||||
if isinstance(spec, ToolGuardrailChain):
|
||||
return spec
|
||||
if isinstance(spec, GuardrailChain):
|
||||
# Reuse the chain's contents and its fail_open posture; only the
|
||||
# reason-reporting behaviour differs.
|
||||
return ToolGuardrailChain(list(spec.guardrails), fail_open=spec.fail_open,
|
||||
direction=direction)
|
||||
if isinstance(spec, (str, bytes)) or not isinstance(spec, (list, tuple, set)):
|
||||
spec = [spec]
|
||||
items = [_coerce_one(item, direction) for item in spec]
|
||||
if not items:
|
||||
return None
|
||||
return ToolGuardrailChain(items, direction=direction)
|
||||
|
||||
|
||||
def get_tool_guardrail_chain(tool_obj: Any, direction: str) -> Optional[ToolGuardrailChain]:
|
||||
"""Return the guardrail chain declared on ``tool_obj``, or ``None``.
|
||||
|
||||
``@tool(...)`` coerces at definition time, so this is usually a single
|
||||
attribute read. A ``BaseTool`` subclass may instead declare a raw list as a
|
||||
class attribute; that is coerced on first use and memoised on the instance.
|
||||
A malformed declaration is logged and treated as "no guardrail" rather than
|
||||
breaking every call to the tool - the declaration error surfaces at import
|
||||
time for the ``@tool`` path, which is where it belongs.
|
||||
"""
|
||||
if tool_obj is None:
|
||||
return None
|
||||
declared = getattr(tool_obj, ATTR_FOR[direction], None)
|
||||
if declared is None:
|
||||
return None
|
||||
if isinstance(declared, ToolGuardrailChain):
|
||||
return declared
|
||||
cache = getattr(tool_obj, _CACHE_ATTR, None)
|
||||
if isinstance(cache, dict) and direction in cache:
|
||||
return cache[direction]
|
||||
try:
|
||||
chain = build_tool_guardrails(declared, direction)
|
||||
except TypeError as e:
|
||||
logger.warning(
|
||||
"Ignoring malformed %s on tool '%s': %s",
|
||||
ATTR_FOR[direction], getattr(tool_obj, "name", tool_obj), e
|
||||
)
|
||||
chain = None
|
||||
try:
|
||||
if not isinstance(cache, dict):
|
||||
cache = {}
|
||||
setattr(tool_obj, _CACHE_ATTR, cache)
|
||||
cache[direction] = chain
|
||||
except Exception: # noqa: BLE001 - e.g. a slotted or immutable tool object
|
||||
pass
|
||||
return chain
|
||||
@@ -46,7 +46,6 @@ class InvocationContext:
|
||||
tool_name: Optional[str] = None
|
||||
model_name: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
cancel_event: Optional[Any] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -53,11 +53,6 @@ class HookRegistry:
|
||||
}
|
||||
self._enabled = enabled
|
||||
self._global_timeout = 60.0
|
||||
# Guard mutation/iteration of the per-event hook lists. The default
|
||||
# registry is a cross-agent process-wide singleton, so a plugin
|
||||
# (un)registering a hook on one thread can race a get_hooks() iteration
|
||||
# on another. RLock keeps re-entrant calls (register -> register_*) safe.
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
@@ -79,8 +74,7 @@ class HookRegistry:
|
||||
Returns:
|
||||
The hook ID
|
||||
"""
|
||||
with self._lock:
|
||||
self._hooks[hook.event].append(hook)
|
||||
self._hooks[hook.event].append(hook)
|
||||
logger.debug(f"Registered hook '{hook.name}' for event '{hook.event.value}'")
|
||||
return hook.id
|
||||
|
||||
@@ -220,13 +214,12 @@ class HookRegistry:
|
||||
Returns:
|
||||
True if found and removed, False otherwise
|
||||
"""
|
||||
with self._lock:
|
||||
for event in HookEvent:
|
||||
for i, hook in enumerate(self._hooks[event]):
|
||||
if hook.id == hook_id:
|
||||
self._hooks[event].pop(i)
|
||||
logger.debug(f"Unregistered hook '{hook_id}'")
|
||||
return True
|
||||
for event in HookEvent:
|
||||
for i, hook in enumerate(self._hooks[event]):
|
||||
if hook.id == hook_id:
|
||||
self._hooks[event].pop(i)
|
||||
logger.debug(f"Unregistered hook '{hook_id}'")
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_hooks(
|
||||
@@ -247,11 +240,7 @@ class HookRegistry:
|
||||
if not self._enabled:
|
||||
return []
|
||||
|
||||
# Snapshot under the lock so callers never iterate the live, mutable
|
||||
# list (a concurrent unregister()/clear() could otherwise raise
|
||||
# "list changed size during iteration" or skip/duplicate a hook).
|
||||
with self._lock:
|
||||
hooks = list(self._hooks.get(event, []))
|
||||
hooks = self._hooks.get(event, [])
|
||||
|
||||
if target is None:
|
||||
return [h for h in hooks if h.enabled]
|
||||
@@ -269,12 +258,11 @@ class HookRegistry:
|
||||
Args:
|
||||
event: Optional event to clear hooks for
|
||||
"""
|
||||
with self._lock:
|
||||
if event is None:
|
||||
for e in HookEvent:
|
||||
self._hooks[e] = []
|
||||
else:
|
||||
self._hooks[event] = []
|
||||
if event is None:
|
||||
for e in HookEvent:
|
||||
self._hooks[e] = []
|
||||
else:
|
||||
self._hooks[event] = []
|
||||
|
||||
def list_hooks(self) -> Dict[str, List[Dict]]:
|
||||
"""
|
||||
@@ -302,22 +290,20 @@ class HookRegistry:
|
||||
|
||||
def enable_hook(self, hook_id: str) -> bool:
|
||||
"""Enable a specific hook."""
|
||||
with self._lock:
|
||||
for event in HookEvent:
|
||||
for hook in self._hooks[event]:
|
||||
if hook.id == hook_id:
|
||||
hook.enabled = True
|
||||
return True
|
||||
for event in HookEvent:
|
||||
for hook in self._hooks[event]:
|
||||
if hook.id == hook_id:
|
||||
hook.enabled = True
|
||||
return True
|
||||
return False
|
||||
|
||||
def disable_hook(self, hook_id: str) -> bool:
|
||||
"""Disable a specific hook."""
|
||||
with self._lock:
|
||||
for event in HookEvent:
|
||||
for hook in self._hooks[event]:
|
||||
if hook.id == hook_id:
|
||||
hook.enabled = False
|
||||
return True
|
||||
for event in HookEvent:
|
||||
for hook in self._hooks[event]:
|
||||
if hook.id == hook_id:
|
||||
hook.enabled = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_global_timeout(self, timeout: float):
|
||||
|
||||
@@ -354,18 +354,13 @@ class HookRunner:
|
||||
"""Execute a function hook."""
|
||||
if hook.func is None:
|
||||
duration = (time.time() - start_time) * 1000
|
||||
# Fail closed: a hook with no callable cannot produce a verdict, so a
|
||||
# gating event (BEFORE_TOOL) must block rather than silently allow.
|
||||
# Without an ``output`` here ``is_blocked`` returned False and the tool
|
||||
# ran — the same fail-open the exception/timeout paths already fix.
|
||||
return HookExecutionResult(
|
||||
hook_id=hook.id,
|
||||
hook_name=hook.name or "unknown",
|
||||
event=event,
|
||||
success=False,
|
||||
error="Hook function is None",
|
||||
duration_ms=duration,
|
||||
output=HookResult(decision="deny", reason=f"Hook '{hook.name}' has no callable")
|
||||
duration_ms=duration
|
||||
)
|
||||
|
||||
timeout = hook.timeout or self._default_timeout
|
||||
@@ -455,11 +450,6 @@ class HookRunner:
|
||||
exit_code: int
|
||||
) -> HookResult:
|
||||
"""Parse command output into HookResult."""
|
||||
# Whether the process exited with a code we understand. Any other code
|
||||
# (127 command-not-found, 126 not-executable, etc.) means the hook could
|
||||
# not render a trustworthy verdict, so JSON stdout must not override it.
|
||||
expected_exit = exit_code in (EXIT_CODE_SUCCESS, EXIT_CODE_BLOCKING_ERROR)
|
||||
|
||||
# Try to parse JSON from stdout
|
||||
if stdout.strip():
|
||||
try:
|
||||
@@ -469,22 +459,8 @@ class HookRunner:
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
|
||||
decision = data.get("decision", "allow")
|
||||
# Fail closed: valid JSON with an ``allow`` decision must not
|
||||
# bypass an unexpected exit code. A gating hook (BEFORE_TOOL)
|
||||
# that prints allow-JSON but then crashes (e.g. exit 127) has
|
||||
# not actually approved the call, so deny instead of allowing.
|
||||
if decision == "allow" and not expected_exit:
|
||||
return HookResult(
|
||||
decision="deny",
|
||||
reason=(
|
||||
f"Hook exited with unexpected code {exit_code} "
|
||||
f"after emitting an allow decision: "
|
||||
f"{stderr.strip() or 'no stderr'}"
|
||||
)
|
||||
)
|
||||
return HookResult(
|
||||
decision=decision,
|
||||
decision=data.get("decision", "allow"),
|
||||
reason=data.get("reason"),
|
||||
modified_input=data.get("modified_input"),
|
||||
additional_context=data.get("additional_context"),
|
||||
@@ -505,19 +481,9 @@ class HookRunner:
|
||||
reason=stderr.strip() or stdout.strip() or "Blocked by hook"
|
||||
)
|
||||
else:
|
||||
# Fail closed: any *unexpected* exit code (e.g. 127 command-not-found,
|
||||
# 126 not-executable, or any other non-zero, non-blocking code) means
|
||||
# the hook could not render a verdict. Treating that as ``allow`` would
|
||||
# silently bypass a gating hook (BEFORE_TOOL). Deny so the gate matches
|
||||
# the fail-closed posture of the exception/timeout paths and
|
||||
# GuardrailChain. Emitting a real HookResult (decision + reason) also
|
||||
# keeps ``get_blocking_reason`` informative for the audit trail.
|
||||
return HookResult(
|
||||
decision="deny",
|
||||
reason=(
|
||||
f"Hook exited with unexpected code {exit_code}: "
|
||||
f"{stderr.strip() or stdout.strip() or 'no output'}"
|
||||
)
|
||||
decision="allow",
|
||||
additional_context=f"Warning: {stderr.strip() or stdout.strip()}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -135,11 +135,6 @@ class ChromaKnowledgeAdapter:
|
||||
config = kwargs.get("config", {})
|
||||
vector_config = config.get("vector_store", {}).get("config", {})
|
||||
|
||||
# The configured embedder. Reading only OPENAI_EMBEDDING_MODEL here meant
|
||||
# an agent that had explicitly selected a local embedder still sent every
|
||||
# document and query to OpenAI -- silently, and with a 200 back.
|
||||
self._embedder = config.get("embedder") or {}
|
||||
|
||||
collection_name = vector_config.get("collection_name", "praisonai_knowledge")
|
||||
persist_dir = vector_config.get("path") or _default_chroma_path()
|
||||
os.makedirs(persist_dir, exist_ok=True)
|
||||
@@ -174,34 +169,6 @@ class ChromaKnowledgeAdapter:
|
||||
metadata={"hnsw:space": "cosine"}
|
||||
)
|
||||
|
||||
def _embedding_call(self):
|
||||
"""Resolve (model, kwargs) for the configured embedder.
|
||||
|
||||
Precedence: the embedder block the agent configured > the
|
||||
OPENAI_EMBEDDING_MODEL env var > OpenAI's default. Returns kwargs
|
||||
carrying api_base when the embedder points at a local server, so the
|
||||
request cannot escape to a remote provider.
|
||||
"""
|
||||
block = self._embedder or {}
|
||||
provider = (block.get("provider") or "").strip().lower()
|
||||
cfg = block.get("config") or {}
|
||||
model = cfg.get("model")
|
||||
if not model:
|
||||
return os.environ.get("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"), {}
|
||||
|
||||
extra = {}
|
||||
base = (cfg.get("ollama_base_url") or cfg.get("openai_base_url")
|
||||
or cfg.get("api_base") or cfg.get("base_url"))
|
||||
if provider == "ollama":
|
||||
if not model.startswith("ollama/"):
|
||||
model = f"ollama/{model}"
|
||||
if base:
|
||||
extra["api_base"] = base.rstrip("/")
|
||||
elif base:
|
||||
extra["api_base"] = base.rstrip("/")
|
||||
extra.setdefault("api_key", cfg.get("api_key") or "local")
|
||||
return model, extra
|
||||
|
||||
def search(self, query: str, *, user_id: Optional[str] = None, agent_id: Optional[str] = None,
|
||||
run_id: Optional[str] = None, limit: int = 10, filters: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any):
|
||||
@@ -209,10 +176,10 @@ class ChromaKnowledgeAdapter:
|
||||
from ..models import SearchResult, SearchResultItem
|
||||
|
||||
# Get embedding for query
|
||||
embedding_model, embedding_kwargs = self._embedding_call()
|
||||
embedding_model = os.environ.get("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
|
||||
try:
|
||||
from praisonaiagents.embedding import embedding
|
||||
result = embedding(query, model=embedding_model, **embedding_kwargs)
|
||||
result = embedding(query, model=embedding_model)
|
||||
query_embedding = result.embeddings[0] if result.embeddings else None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
@@ -290,11 +257,11 @@ class ChromaKnowledgeAdapter:
|
||||
content_str = str(content)
|
||||
|
||||
# Get embedding
|
||||
embedding_model, embedding_kwargs = self._embedding_call()
|
||||
embedding_model = os.environ.get("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
|
||||
embedding_error = None
|
||||
try:
|
||||
from praisonaiagents.embedding import embedding
|
||||
result = embedding(content_str, model=embedding_model, **embedding_kwargs)
|
||||
result = embedding(content_str, model=embedding_model)
|
||||
content_embedding = result.embeddings[0] if result.embeddings else None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
|
||||
@@ -356,19 +356,11 @@ class Knowledge:
|
||||
except Exception:
|
||||
pass # Silent fail - tracing should never break knowledge operations
|
||||
|
||||
def store(self, content, user_id=None, agent_id=None, run_id=None, metadata=None, is_content=False):
|
||||
"""Store a memory.
|
||||
|
||||
Args:
|
||||
is_content: When True, ``content`` is treated as literal text and the
|
||||
file-path heuristic is skipped. Callers that have already read a
|
||||
file's contents (e.g. chunks from ``_process_single_input``) must
|
||||
set this so a chunk ending in ``.txt``/``.pdf``/``.doc``/``.docx``
|
||||
is not mistaken for a path and re-dispatched to ``add()``.
|
||||
"""
|
||||
def store(self, content, user_id=None, agent_id=None, run_id=None, metadata=None):
|
||||
"""Store a memory."""
|
||||
try:
|
||||
if isinstance(content, str):
|
||||
if not is_content and any(content.lower().endswith(ext) for ext in ['.pdf', '.doc', '.docx', '.txt']):
|
||||
if any(content.lower().endswith(ext) for ext in ['.pdf', '.doc', '.docx', '.txt']):
|
||||
self._log(f"Content appears to be a file path, processing file: {content}")
|
||||
return self.add(content, user_id=user_id, agent_id=agent_id, run_id=run_id, metadata=metadata)
|
||||
|
||||
@@ -494,15 +486,10 @@ class Knowledge:
|
||||
"""
|
||||
if isinstance(file_path, (list, tuple)):
|
||||
results = []
|
||||
errors = []
|
||||
for path in file_path:
|
||||
result = self._process_single_input(path, user_id, agent_id, run_id, metadata)
|
||||
results.extend(result.get('results', []))
|
||||
# Per-path failures (e.g. a directory whose files all failed to
|
||||
# embed) must survive aggregation; otherwise a list input hides
|
||||
# them behind the old success-shaped response.
|
||||
errors.extend(result.get('errors', []))
|
||||
return {'results': results, 'relations': [], 'errors': errors}
|
||||
return {'results': results, 'relations': []}
|
||||
|
||||
return self._process_single_input(file_path, user_id, agent_id, run_id, metadata)
|
||||
|
||||
@@ -535,12 +522,6 @@ class Knowledge:
|
||||
if os.path.isdir(input_path):
|
||||
self._log(f"Processing directory: {input_path}")
|
||||
all_results = []
|
||||
# Per-file failures were logged at WARNING and dropped, so a
|
||||
# directory whose every file failed to embed still returned a
|
||||
# success-shaped {'results': [], 'relations': []} and the caller
|
||||
# believed the knowledge base was populated. Collect them and
|
||||
# hand them back.
|
||||
all_errors = []
|
||||
|
||||
# Walk through directory and process all supported files
|
||||
for root, dirs, files in os.walk(input_path):
|
||||
@@ -555,7 +536,6 @@ class Knowledge:
|
||||
all_results.extend(result.get('results', []))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to process file {file_path}: {e}")
|
||||
all_errors.append({'file': file_path, 'error': str(e)})
|
||||
except (KeyboardInterrupt, SystemExit, GeneratorExit):
|
||||
# Interpreter control-flow signals must propagate
|
||||
# untouched so a Ctrl+C / interpreter shutdown is
|
||||
@@ -576,18 +556,9 @@ class Knowledge:
|
||||
) from e
|
||||
|
||||
if not all_results:
|
||||
if all_errors:
|
||||
# Nothing was indexed and we know why. Saying "no
|
||||
# supported files found" here would send the user
|
||||
# looking for the wrong problem entirely.
|
||||
logger.error(
|
||||
"Indexed nothing from %s: all %d file(s) failed; first error: %s",
|
||||
input_path, len(all_errors), all_errors[0]['error'],
|
||||
)
|
||||
else:
|
||||
logger.warning(f"No supported files found in directory: {input_path}")
|
||||
logger.warning(f"No supported files found in directory: {input_path}")
|
||||
|
||||
return {'results': all_results, 'relations': [], 'errors': all_errors}
|
||||
return {'results': all_results, 'relations': []}
|
||||
|
||||
# Check if input ends with any supported extension
|
||||
is_supported_file = any(input_path.lower().endswith(ext)
|
||||
@@ -608,17 +579,7 @@ class Knowledge:
|
||||
content = file.read().strip()
|
||||
if not content:
|
||||
raise ValueError("Empty text file")
|
||||
# Chunk like every other branch. This one used to store the
|
||||
# whole file as a single memory and lowercase it, so
|
||||
# chunk_size/chunk_overlap were inert on .md/.txt/.csv/.json
|
||||
# /.xml/.html -- the commonest sources -- while .pdf and an
|
||||
# unlisted extension both chunked correctly. A 20 KB guide
|
||||
# became one 20 KB memory that no retrieval could rank, and
|
||||
# the case of every identifier and proper noun was lost on
|
||||
# the way to the model.
|
||||
chunks = self.chunker.chunk(content)
|
||||
memories = [chunk.text.strip() if hasattr(chunk, 'text') else str(chunk).strip()
|
||||
for chunk in chunks if chunk] or [content]
|
||||
memories = [self.normalize_content(content)]
|
||||
else:
|
||||
# Use MarkItDown for documents and media
|
||||
result = self.markdown.convert(input_path)
|
||||
@@ -634,35 +595,8 @@ class Knowledge:
|
||||
metadata = {}
|
||||
metadata['file_type'] = file_ext.lstrip('.')
|
||||
metadata['filename'] = os.path.basename(input_path)
|
||||
elif os.path.isfile(input_path):
|
||||
# An existing file whose extension is not in the table above --
|
||||
# every source file: .py .js .ts .go .rs .java .yaml .toml .sh
|
||||
# .ipynb .rst. This used to fall through to the raw-text branch
|
||||
# below, which stored the PATH STRING as if it were content and
|
||||
# reported success, so indexing a codebase produced a knowledge
|
||||
# base of lowercased filenames. Read and chunk it like any other
|
||||
# text instead; a file that is not decodable as UTF-8 is skipped
|
||||
# loudly rather than stored as its own name.
|
||||
self._log(f"Processing as text file (unlisted extension): {input_path}")
|
||||
try:
|
||||
with open(input_path, 'r', encoding='utf-8') as file:
|
||||
content = file.read().strip()
|
||||
except (UnicodeDecodeError, OSError) as read_error:
|
||||
raise ValueError(
|
||||
f"Cannot read {input_path} as UTF-8 text: {read_error}"
|
||||
) from read_error
|
||||
if not content:
|
||||
raise ValueError("Empty text file")
|
||||
chunks = self.chunker.chunk(content)
|
||||
memories = [chunk.text.strip() if hasattr(chunk, 'text') else str(chunk).strip()
|
||||
for chunk in chunks if chunk] or [content]
|
||||
if not metadata:
|
||||
metadata = {}
|
||||
suffix = os.path.splitext(input_path)[1]
|
||||
metadata['file_type'] = suffix.lstrip('.') if suffix else ''
|
||||
metadata['filename'] = os.path.basename(input_path)
|
||||
else:
|
||||
# Genuinely raw text handed in instead of a path.
|
||||
# Treat as raw text content only if no file extension
|
||||
memories = [self.normalize_content(input_path)]
|
||||
|
||||
# Create progress display
|
||||
@@ -677,20 +611,12 @@ class Knowledge:
|
||||
|
||||
# Store memories with progress bar
|
||||
all_results = []
|
||||
attempted = 0
|
||||
failed_chunks = 0
|
||||
with progress:
|
||||
store_task = progress.add_task(f"Adding to Knowledge from {os.path.basename(input_path)}", total=len(memories))
|
||||
for memory in memories:
|
||||
if memory:
|
||||
attempted += 1
|
||||
# memories here are already-read file contents or literal
|
||||
# text the caller handed in -- never a path to re-open, so
|
||||
# bypass store()'s file-path heuristic (a chunk ending in
|
||||
# ".txt"/".pdf"/".doc"/".docx" would otherwise be mistaken
|
||||
# for a filename and silently dropped).
|
||||
memory_result = self.store(memory, user_id=user_id, agent_id=agent_id,
|
||||
run_id=run_id, metadata=metadata, is_content=True)
|
||||
run_id=run_id, metadata=metadata)
|
||||
if memory_result:
|
||||
# Handle both dict and list formats for backward compatibility
|
||||
if isinstance(memory_result, dict):
|
||||
@@ -712,25 +638,6 @@ class Knowledge:
|
||||
import logging
|
||||
logging.warning(f"Unexpected memory_result type: {type(memory_result)}, skipping")
|
||||
progress.advance(store_task)
|
||||
else:
|
||||
# store() catches embedding/persistence exceptions and
|
||||
# returns a falsy empty list rather than raising, so a
|
||||
# falsy result is the one reliable signal that this
|
||||
# chunk did not land -- distinct from a truthy result
|
||||
# that simply carries no 'results' ids (e.g. mem0's
|
||||
# {'ok': True} or a backend that echoes a bare id).
|
||||
failed_chunks += 1
|
||||
|
||||
# If we tried to store real content and every chunk was swallowed,
|
||||
# the file failed; raise so the directory walk records it in
|
||||
# ``errors`` and a single-file caller sees the failure instead of a
|
||||
# false success. This is the exact silent drop this change exists to
|
||||
# prevent (a wrong model, revoked key, or exhausted quota).
|
||||
if attempted and failed_chunks == attempted:
|
||||
raise RuntimeError(
|
||||
f"Stored nothing from {input_path}: all {attempted} chunk(s) "
|
||||
f"failed (embedding/vector-store backend). See earlier logs."
|
||||
)
|
||||
|
||||
# Emit trace event for knowledge add
|
||||
self._emit_knowledge_event("add", source=input_path, chunk_count=len(memories),
|
||||
|
||||
@@ -91,7 +91,7 @@ class LiteAgent:
|
||||
import openai
|
||||
client = openai.OpenAI()
|
||||
response = client.chat.completions.create(
|
||||
model=default_auxiliary_model(),
|
||||
model="gpt-4o-mini",
|
||||
messages=messages
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
@@ -235,7 +235,7 @@ def create_openai_llm_fn(
|
||||
LLM function compatible with LiteAgent
|
||||
|
||||
Example:
|
||||
llm_fn = create_openai_llm_fn(model=default_auxiliary_model())
|
||||
llm_fn = create_openai_llm_fn(model="gpt-4o-mini")
|
||||
agent = LiteAgent(llm_fn=llm_fn)
|
||||
"""
|
||||
def llm_fn(messages: List[Dict]) -> str:
|
||||
@@ -312,7 +312,6 @@ def create_anthropic_llm_fn(
|
||||
from ..tools.tools import Tools
|
||||
from ..tools.base import BaseTool, ToolResult
|
||||
from ..tools.decorator import tool
|
||||
from ..llm.model_providers import default_auxiliary_model
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -219,10 +219,6 @@ def __getattr__(name):
|
||||
from .error_classifier import classify_error
|
||||
_lazy_cache[name] = classify_error
|
||||
return classify_error
|
||||
elif name == "resolve_provider":
|
||||
from .model_providers import resolve_provider
|
||||
_lazy_cache[name] = resolve_provider
|
||||
return resolve_provider
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -282,7 +278,5 @@ __all__ = [
|
||||
"sanitize_text",
|
||||
# Error Classification (G5)
|
||||
"ErrorCategory",
|
||||
"classify_error",
|
||||
# Model-name → provider-id resolution
|
||||
"resolve_provider",
|
||||
"classify_error"
|
||||
]
|
||||
@@ -9,48 +9,11 @@ and integrates with Gap 2 (parallel tool execution).
|
||||
"""
|
||||
|
||||
from ..protocols import LLMProviderAdapterProtocol
|
||||
import json
|
||||
from ..model_capabilities import GEMINI_INTERNAL_TOOLS
|
||||
from ..streaming_protocol import StreamingCapableAdapter, get_streaming_adapter
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
|
||||
def _recover_json_tool_calls(response_text: str, tools: List[Dict[str, Any]]) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Recover a tool call a local model emitted as JSON text.
|
||||
|
||||
Shared by every locally-served engine: small models routinely answer with
|
||||
the call as content instead of using the tool_calls field. Deliberately NOT
|
||||
on DefaultAdapter -- a hosted model returning JSON prose must never have it
|
||||
parsed as a tool call.
|
||||
"""
|
||||
if not response_text or not tools:
|
||||
return None
|
||||
|
||||
try:
|
||||
import json
|
||||
response_json = json.loads(response_text.strip())
|
||||
|
||||
# Normalize to list so both single and multi-tool payloads are supported
|
||||
if isinstance(response_json, dict):
|
||||
response_json = [response_json]
|
||||
|
||||
if isinstance(response_json, list):
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
for idx, tool_json in enumerate(response_json):
|
||||
if isinstance(tool_json, dict) and "name" in tool_json:
|
||||
tool_calls.append({
|
||||
"id": f"call_{tool_json['name']}_{idx}_{hash(response_text) % 10000}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_json["name"],
|
||||
"arguments": json.dumps(tool_json.get("arguments", {}))
|
||||
}
|
||||
})
|
||||
return tool_calls if tool_calls else None
|
||||
except (json.JSONDecodeError, TypeError, KeyError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class DefaultAdapter:
|
||||
"""Default provider adapter with sensible fallbacks."""
|
||||
|
||||
@@ -60,8 +23,14 @@ class DefaultAdapter:
|
||||
def should_summarize_tools(self, iter_count: int) -> bool:
|
||||
return iter_count >= 5 # Conservative default
|
||||
|
||||
def format_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
return tools # No special formatting by default
|
||||
|
||||
def post_tool_iteration(self, state: Dict[str, Any]) -> None:
|
||||
pass # No post-processing by default
|
||||
|
||||
def supports_structured_output(self) -> bool:
|
||||
return False # Conservative default
|
||||
|
||||
def supports_streaming(self) -> bool:
|
||||
return True # Most providers support streaming
|
||||
@@ -69,35 +38,26 @@ class DefaultAdapter:
|
||||
def supports_streaming_with_tools(self) -> bool:
|
||||
return True # Most providers support streaming with tools
|
||||
|
||||
def get_streaming_adapter(self) -> StreamingCapableAdapter:
|
||||
"""Get the streaming adapter for this provider."""
|
||||
# Default providers use the default streaming adapter
|
||||
return get_streaming_adapter("default")
|
||||
|
||||
def get_max_iteration_threshold(self) -> int:
|
||||
return 10 # Conservative default
|
||||
|
||||
def format_tool_result_message(self, function_name: str, tool_result: Any, tool_call_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Standard OpenAI-shaped tool result message.
|
||||
|
||||
This is the union of five inline copies that had drifted apart in llm.py:
|
||||
it uses the fuller error sentence, reports a list-of-errors result as an
|
||||
error rather than dumping it as data, and guards json.dumps so a tool
|
||||
returning a set or a datetime does not crash the turn.
|
||||
"""
|
||||
if tool_result is None:
|
||||
content = "Function returned an empty output"
|
||||
elif isinstance(tool_result, dict) and 'error' in tool_result:
|
||||
content = (f"Error: {tool_result.get('error', 'Unknown error')}. "
|
||||
"Please inform the user that the operation could not be completed.")
|
||||
elif (isinstance(tool_result, list) and tool_result
|
||||
and isinstance(tool_result[0], dict) and 'error' in tool_result[0]):
|
||||
content = (f"Error: {tool_result[0].get('error', 'Unknown error')}. "
|
||||
"Please inform the user that the operation could not be completed.")
|
||||
else:
|
||||
try:
|
||||
content = json.dumps(tool_result)
|
||||
except (TypeError, ValueError):
|
||||
content = str(tool_result)
|
||||
return {
|
||||
# Standard OpenAI-style tool result message
|
||||
message = {
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id if tool_call_id is not None else f"call_{function_name}",
|
||||
"content": content,
|
||||
"content": str(tool_result),
|
||||
}
|
||||
if tool_call_id is not None:
|
||||
message["tool_call_id"] = tool_call_id
|
||||
else:
|
||||
# Fallback for backward compatibility
|
||||
message["tool_call_id"] = f"call_{function_name}"
|
||||
return message
|
||||
|
||||
def handle_empty_response_with_tools(self, state: Dict[str, Any]) -> bool:
|
||||
return False # No special handling by default
|
||||
@@ -105,12 +65,24 @@ class DefaultAdapter:
|
||||
def get_default_settings(self) -> Dict[str, Any]:
|
||||
return {} # No provider-specific defaults
|
||||
|
||||
def parse_tool_calls(self, raw_response: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Default tool call parsing - use OpenAI-style format."""
|
||||
if "choices" in raw_response and len(raw_response["choices"]) > 0:
|
||||
message = raw_response["choices"][0].get("message", {})
|
||||
return message.get("tool_calls")
|
||||
return None
|
||||
|
||||
def should_skip_streaming_with_tools(self) -> bool:
|
||||
return False # Most providers support streaming with tools
|
||||
|
||||
def recover_tool_calls_from_text(self, response_text: str, tools: List[Dict[str, Any]]) -> Optional[List[Dict[str, Any]]]:
|
||||
return None # No text recovery by default
|
||||
|
||||
def inject_cache_control(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
return messages # No cache control by default
|
||||
|
||||
def extract_reasoning_tokens(self, response: Dict[str, Any]) -> int:
|
||||
return 0 # No reasoning tokens by default
|
||||
|
||||
|
||||
class OllamaAdapter(DefaultAdapter):
|
||||
@@ -133,7 +105,12 @@ class OllamaAdapter(DefaultAdapter):
|
||||
# Ollama doesn't reliably support streaming with tools
|
||||
return False
|
||||
|
||||
def get_streaming_adapter(self) -> StreamingCapableAdapter:
|
||||
"""Get Ollama-specific streaming adapter."""
|
||||
return get_streaming_adapter("ollama")
|
||||
|
||||
def get_max_iteration_threshold(self) -> int:
|
||||
return 1 # Ollama-specific threshold
|
||||
|
||||
def format_tool_result_message(self, function_name: str, tool_result: Any, tool_call_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
# Ollama uses natural language format for tool results.
|
||||
@@ -182,8 +159,42 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
|
||||
def recover_tool_calls_from_text(self, response_text: str, tools: List[Dict[str, Any]]) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Ollama-specific tool call recovery from response text."""
|
||||
return _recover_json_tool_calls(response_text, tools)
|
||||
if not response_text or not tools:
|
||||
return None
|
||||
|
||||
try:
|
||||
import json
|
||||
response_json = json.loads(response_text.strip())
|
||||
|
||||
# Normalize to list so both single and multi-tool payloads are supported
|
||||
if isinstance(response_json, dict):
|
||||
response_json = [response_json]
|
||||
|
||||
if isinstance(response_json, list):
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
for idx, tool_json in enumerate(response_json):
|
||||
if isinstance(tool_json, dict) and "name" in tool_json:
|
||||
tool_calls.append({
|
||||
"id": f"call_{tool_json['name']}_{idx}_{hash(response_text) % 10000}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_json["name"],
|
||||
"arguments": json.dumps(tool_json.get("arguments", {}))
|
||||
}
|
||||
})
|
||||
return tool_calls if tool_calls else None
|
||||
except (json.JSONDecodeError, TypeError, KeyError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def post_tool_iteration(self, state: Dict[str, Any]) -> None:
|
||||
# Replaces: Ollama-specific post-tool summary branches
|
||||
if (not state.get('response_text', '').strip() and
|
||||
state.get('formatted_tools') and
|
||||
state.get('iteration_count') == 0):
|
||||
# Add Ollama-specific summary logic here
|
||||
state['needs_summary'] = True
|
||||
|
||||
def get_default_settings(self) -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -192,41 +203,14 @@ Now provide your final answer using this result. Summarize the information natur
|
||||
}
|
||||
|
||||
|
||||
class LocalOpenAIAdapter(DefaultAdapter):
|
||||
"""Adapter for local servers that speak real OpenAI over HTTP.
|
||||
|
||||
Covers LM Studio, vLLM and llama.cpp's ``llama-server``. These differ from
|
||||
Ollama in the way that matters most here: they implement the standard tool
|
||||
protocol correctly, so tool results stay ``role: "tool"`` and streaming with
|
||||
tools works. Inheriting ``DefaultAdapter``'s message handling is therefore
|
||||
deliberate -- applying Ollama's natural-language ``role: "user"`` rewrite
|
||||
would corrupt a conversation these servers handle properly.
|
||||
|
||||
What they share with Ollama is the model: locally-served weights are often
|
||||
small and emit a malformed tool call now and then. So the one thing this
|
||||
adapter adds is a repair budget.
|
||||
|
||||
``force_tool_usage`` is deliberately NOT set. It injects prompts into every
|
||||
conversation, and vLLM commonly serves large, highly capable models where
|
||||
that is unwanted noise. ``max_tool_repairs`` costs nothing unless a tool
|
||||
call actually arrives malformed.
|
||||
"""
|
||||
|
||||
def get_default_settings(self) -> Dict[str, Any]:
|
||||
return {'max_tool_repairs': 2}
|
||||
|
||||
def recover_tool_calls_from_text(self, response_text: str, tools: List[Dict[str, Any]]) -> Optional[List[Dict[str, Any]]]:
|
||||
# Same reason as Ollama: these servers front small local models that
|
||||
# answer with the tool call as text, especially after a repair prompt.
|
||||
return _recover_json_tool_calls(response_text, tools)
|
||||
|
||||
|
||||
class AnthropicAdapter(DefaultAdapter):
|
||||
"""Anthropic/Claude provider adapter."""
|
||||
|
||||
def supports_prompt_caching(self) -> bool:
|
||||
return True # Claude supports prompt caching
|
||||
|
||||
def supports_structured_output(self) -> bool:
|
||||
return True
|
||||
|
||||
def supports_streaming(self) -> bool:
|
||||
# litellm.acompletion with stream=True returns a ModelResponse (not async generator)
|
||||
@@ -236,6 +220,9 @@ class AnthropicAdapter(DefaultAdapter):
|
||||
def supports_streaming_with_tools(self) -> bool:
|
||||
return False
|
||||
|
||||
def get_streaming_adapter(self) -> StreamingCapableAdapter:
|
||||
"""Get Anthropic-specific streaming adapter."""
|
||||
return get_streaming_adapter("anthropic")
|
||||
|
||||
|
||||
class GeminiAdapter(DefaultAdapter):
|
||||
@@ -248,13 +235,35 @@ class GeminiAdapter(DefaultAdapter):
|
||||
- Supports structured output
|
||||
"""
|
||||
|
||||
def should_skip_streaming_with_tools(self) -> bool:
|
||||
"""Gemini should skip streaming when tools are present."""
|
||||
return True
|
||||
|
||||
def supports_structured_output(self) -> bool:
|
||||
return True
|
||||
|
||||
def format_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
# Replaces: gemini_internal_tools handling in llm.py
|
||||
# Internal tool names match GEMINI_INTERNAL_TOOLS: {'googleSearch', 'urlContext', 'codeExecution'}
|
||||
formatted = []
|
||||
for tool in tools:
|
||||
if tool.get('name') in GEMINI_INTERNAL_TOOLS:
|
||||
# Convert to Gemini internal tool format
|
||||
formatted.append({
|
||||
'type': 'function',
|
||||
'function': tool
|
||||
})
|
||||
else:
|
||||
formatted.append(tool)
|
||||
return formatted
|
||||
|
||||
def supports_streaming_with_tools(self) -> bool:
|
||||
# Gemini has issues with streaming + tools
|
||||
return False
|
||||
|
||||
def get_streaming_adapter(self) -> StreamingCapableAdapter:
|
||||
"""Get Gemini-specific streaming adapter."""
|
||||
return get_streaming_adapter("gemini")
|
||||
|
||||
|
||||
# Provider adapter registry - public for extension
|
||||
@@ -264,7 +273,6 @@ _provider_adapters: Dict[str, LLMProviderAdapterProtocol] = {}
|
||||
_default_adapter = DefaultAdapter()
|
||||
_provider_adapters['default'] = _default_adapter
|
||||
_provider_adapters['ollama'] = OllamaAdapter()
|
||||
_provider_adapters['local'] = LocalOpenAIAdapter()
|
||||
_provider_adapters['anthropic'] = AnthropicAdapter()
|
||||
_provider_adapters['claude'] = AnthropicAdapter() # Alias
|
||||
_provider_adapters['gemini'] = GeminiAdapter()
|
||||
@@ -302,8 +310,6 @@ def get_provider_adapter(name: str) -> LLMProviderAdapterProtocol:
|
||||
# Provider prefixes or substrings
|
||||
if "ollama" in name_lower:
|
||||
return _provider_adapters["ollama"]
|
||||
if name_lower in {"local", "lm_studio", "lmstudio", "vllm", "hosted_vllm", "llamacpp", "llama_cpp"}:
|
||||
return _provider_adapters["local"]
|
||||
if "claude" in name_lower or "anthropic" in name_lower:
|
||||
return _provider_adapters["anthropic"]
|
||||
if "gemini" in name_lower:
|
||||
@@ -312,12 +318,23 @@ def get_provider_adapter(name: str) -> LLMProviderAdapterProtocol:
|
||||
return _provider_adapters["default"]
|
||||
|
||||
|
||||
def list_provider_adapters() -> List[str]:
|
||||
"""List all registered provider adapter names."""
|
||||
return sorted(_provider_adapters.keys())
|
||||
|
||||
|
||||
def has_provider_adapter(name: str) -> bool:
|
||||
"""Check if a provider adapter is registered."""
|
||||
return name in _provider_adapters
|
||||
|
||||
|
||||
__all__ = [
|
||||
'DefaultAdapter',
|
||||
'OllamaAdapter',
|
||||
'LocalOpenAIAdapter',
|
||||
'AnthropicAdapter',
|
||||
'GeminiAdapter',
|
||||
'get_provider_adapter',
|
||||
'add_provider_adapter',
|
||||
'list_provider_adapters',
|
||||
'has_provider_adapter',
|
||||
]
|
||||
@@ -23,7 +23,6 @@ import time
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
from ..errors import AgentErrorKind, FailoverDecision, IdleTimeoutBreaker, ToolExecutionError
|
||||
from ..model_harness.guard import check_model_request
|
||||
# Gap 2: Tool call execution imports
|
||||
from ..tools.call_executor import ToolCall, create_tool_call_executor
|
||||
from ..tools.schema import build_tool_definition
|
||||
@@ -251,14 +250,6 @@ class LLM:
|
||||
"results already gathered."
|
||||
)
|
||||
STEP_LIMIT_FALLBACK_MESSAGE = "Reached the step limit before finishing this task."
|
||||
|
||||
# Stalled-tool-loop finalisation: used when a provider re-emits an
|
||||
# identical tool call with no prose, so the loop cannot make progress.
|
||||
TOOL_LOOP_STALL_PROMPT = (
|
||||
"The tools have already been called and their results are above. "
|
||||
"Do not call any tools. Answer the original question now using "
|
||||
"those results."
|
||||
)
|
||||
|
||||
# Force tool usage prompt template (used when model ignores tools)
|
||||
FORCE_TOOL_USAGE_PROMPT = """You MUST use one of the available tools to answer this question.
|
||||
@@ -495,12 +486,6 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
self._last_stop_reason = "completed"
|
||||
self._idle_timeout_breaker = IdleTimeoutBreaker() # Circuit breaker for idle timeouts
|
||||
self.chat_history = []
|
||||
# Optional Agent-supplied thread-safe append for deferred re-injection.
|
||||
# When an Agent owns this LLM it wires its own history append here so a
|
||||
# background-resolved defer(...) result lands on the list follow-up
|
||||
# turns actually replay (the LLM's own chat_history has no readers in
|
||||
# that case). Left None for standalone LLM usage.
|
||||
self._agent_history_append = None
|
||||
self.verbose = extra_settings.get('verbose', True)
|
||||
self.markdown = extra_settings.get('markdown', True)
|
||||
self.self_reflect = extra_settings.get('self_reflect', False)
|
||||
@@ -645,18 +630,9 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
# Parse route prefix for explicit provider routing
|
||||
provider_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else None
|
||||
|
||||
# Explicit provider prefixes take priority.
|
||||
# "ollama_chat" is litellm's recommended prefix for Ollama tool calling
|
||||
# and must not be treated as a different provider than "ollama".
|
||||
if provider_prefix in {"ollama", "ollama_chat"}:
|
||||
# Explicit provider prefixes take priority
|
||||
if provider_prefix == "ollama":
|
||||
return "ollama"
|
||||
# Local servers that speak real OpenAI over HTTP. They keep the standard
|
||||
# tool-message shape (unlike Ollama) but serve locally-hosted weights
|
||||
# that benefit from a tool-call repair budget. "huggingface" is
|
||||
# deliberately absent: that prefix is the hosted Inference API.
|
||||
if provider_prefix in {"lm_studio", "lmstudio", "vllm", "hosted_vllm",
|
||||
"llamacpp", "llama_cpp"}:
|
||||
return "local"
|
||||
if provider_prefix in {"anthropic", "claude"}:
|
||||
return "anthropic"
|
||||
if provider_prefix in {"gemini", "google"} and "gemini" in model_lower:
|
||||
@@ -673,12 +649,11 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
if model_lower.startswith("gemini") or model_lower.startswith("google/gemini"):
|
||||
return "gemini"
|
||||
|
||||
# NOTE: the base_url hint that used to live here checked exactly the
|
||||
# same three URLs for exactly the same two patterns as
|
||||
# _is_ollama_provider() above, so it was an unguarded duplicate -- and
|
||||
# it re-captured hosted models that _is_ollama_provider() had just
|
||||
# correctly declined. Detection now happens once, in one place.
|
||||
|
||||
# Check base_url for provider hints
|
||||
base_urls = [self.base_url, os.getenv("OPENAI_BASE_URL", ""), os.getenv("OPENAI_API_BASE", "")]
|
||||
if any(url and ("ollama" in url.lower() or ":11434" in url) for url in base_urls):
|
||||
return "ollama"
|
||||
|
||||
# Substring fallback for routed models whose provider is not the first
|
||||
# path segment (e.g. bedrock/anthropic.claude-*, vertex_ai/claude-*,
|
||||
# openrouter/anthropic/*, vertex_ai/gemini-*). Mirrors the substring
|
||||
@@ -722,25 +697,10 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
if not self.model:
|
||||
return False
|
||||
|
||||
# An explicit Ollama route prefix -- the user speaking, always wins.
|
||||
# "ollama_chat/" is litellm's recommended prefix for Ollama tool calling
|
||||
# and resolves to provider "ollama" in _detect_provider, so omitting it
|
||||
# here made this predicate disagree with the adapter that gets selected.
|
||||
if self.model.startswith(("ollama/", "ollama_chat/")):
|
||||
# Direct ollama/ prefix
|
||||
if self.model.startswith("ollama/"):
|
||||
return True
|
||||
|
||||
# A base_url says WHERE the server is, not WHAT it serves. Only let it
|
||||
# imply Ollama for a model that could plausibly be running locally.
|
||||
# Without this, the README's own local-model recipe
|
||||
# (OPENAI_BASE_URL=http://localhost:11434/v1) gave gpt-4o and
|
||||
# claude-* the Ollama adapter: tool results downgraded to
|
||||
# natural-language user turns, streaming-with-tools disabled, and
|
||||
# forced-tool prompts injected into a conversation that never needed
|
||||
# them.
|
||||
from .model_providers import is_hosted_only_model
|
||||
if is_hosted_only_model(self.model):
|
||||
return False
|
||||
|
||||
|
||||
# Check base_url if provided
|
||||
if self.base_url and "ollama" in self.base_url.lower():
|
||||
return True
|
||||
@@ -756,17 +716,6 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
|
||||
return False
|
||||
|
||||
def _is_local_openai_provider(self) -> bool:
|
||||
"""Detect local OpenAI-compatible servers (LM Studio, vLLM, llama.cpp).
|
||||
|
||||
These speak the standard OpenAI protocol, so well-formed tool calls
|
||||
arrive as ``message.tool_calls``. But after a tool-call *repair* prompt
|
||||
the model is asked to reply with a bare JSON tool call as text content;
|
||||
like Ollama, that text must be reconstructed into a dispatchable tool
|
||||
call rather than returned as the final answer.
|
||||
"""
|
||||
return self._detect_provider() == "local"
|
||||
|
||||
def _is_qwen_provider(self) -> bool:
|
||||
"""Detect if this is a Qwen provider"""
|
||||
if not self.model:
|
||||
@@ -831,20 +780,13 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
error_str = str(error).lower()
|
||||
error_type = type(error).__name__.lower()
|
||||
|
||||
# Check for common rate limit indicators. This helper has no production
|
||||
# callers (the runtime retry path uses classify_error_kind() /
|
||||
# resolve_failover_decision()); it exists only for tests and is kept in
|
||||
# step with error_classifier.classify_error() via
|
||||
# test_rate_limit_backward_compat. The two silently diverged when that
|
||||
# classifier learned 'quota exceeded' (Gemini's phrasing for a
|
||||
# per-minute limit) and this list did not.
|
||||
# Check for common rate limit indicators
|
||||
indicators = [
|
||||
'429',
|
||||
'rate limit',
|
||||
'ratelimit',
|
||||
'too many request',
|
||||
'resource_exhausted',
|
||||
'quota exceeded',
|
||||
'tokens per minute',
|
||||
]
|
||||
|
||||
@@ -1511,14 +1453,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
|
||||
Returns:
|
||||
The completion response from litellm
|
||||
|
||||
Raises:
|
||||
ModelRequestBlocked: If a test suite turned real model requests off
|
||||
via ``praisonaiagents.model_harness.allow_model_requests(False)``.
|
||||
"""
|
||||
# Last gate before the network. A ScriptedModel overrides this method
|
||||
# entirely, so scripted turns never reach (or trip) the guard.
|
||||
check_model_request(self.model, "litellm.completion")
|
||||
import litellm
|
||||
response = self._call_with_retry(litellm.completion, **completion_params)
|
||||
if not completion_params.get("stream"):
|
||||
@@ -1536,12 +1471,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
|
||||
Returns:
|
||||
The completion response from litellm
|
||||
|
||||
Raises:
|
||||
ModelRequestBlocked: If a test suite turned real model requests off
|
||||
via ``praisonaiagents.model_harness.allow_model_requests(False)``.
|
||||
"""
|
||||
check_model_request(self.model, "litellm.acompletion")
|
||||
import litellm
|
||||
response = await self._call_with_retry_async(
|
||||
litellm.acompletion,
|
||||
@@ -2090,22 +2020,14 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
|
||||
def _reinject(handle_id: str, value: Any, session_id: Optional[str]) -> None:
|
||||
# Best-effort: this closure is invoked from a background worker
|
||||
# thread. When driven by an Agent, follow-up turns replay the
|
||||
# Agent's own history (passed as a parameter into
|
||||
# _build_messages), not this LLM's ``chat_history`` — so we
|
||||
# append via the Agent's thread-safe callback when one has been
|
||||
# wired, and fall back to the LLM's own list only for standalone
|
||||
# LLM usage.
|
||||
message = {
|
||||
# thread; appending to the LLM's persistent ``chat_history``
|
||||
# (the same list follow-up turns replay via _build_messages)
|
||||
# makes the resolved value available on the next turn.
|
||||
self.chat_history.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": f"[deferred:{function_name}] {value}",
|
||||
}
|
||||
history_append = getattr(self, "_agent_history_append", None)
|
||||
if history_append is not None:
|
||||
history_append(message)
|
||||
else:
|
||||
self.chat_history.append(message)
|
||||
})
|
||||
|
||||
# Atomic: only register when not already pending, so a gateway that
|
||||
# registered its own resolver for this handle first is never
|
||||
@@ -2115,8 +2037,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
logging.debug("Deferred registration skipped (non-fatal): %s", e)
|
||||
|
||||
def _finalise_on_limit(self, messages: List[Dict], response_text: str,
|
||||
temperature: float = 0.2,
|
||||
wrap_up_prompt: Optional[str] = None, **kwargs) -> str:
|
||||
temperature: float = 0.2, **kwargs) -> str:
|
||||
"""
|
||||
Force a final tools-disabled answer when the tool-calling loop hits max_iter.
|
||||
|
||||
@@ -2136,8 +2057,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
Returns:
|
||||
The synthesised final answer, or a safe fallback string.
|
||||
"""
|
||||
wrap_up = {"role": "user",
|
||||
"content": wrap_up_prompt or self.STEP_LIMIT_WRAP_UP_PROMPT}
|
||||
wrap_up = {"role": "user", "content": self.STEP_LIMIT_WRAP_UP_PROMPT}
|
||||
try:
|
||||
safe_kwargs = {k: v for k, v in kwargs.items() if k != 'reasoning_steps'}
|
||||
final_params = self._build_completion_params(
|
||||
@@ -2830,28 +2750,13 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
)
|
||||
|
||||
if stream:
|
||||
response_text, tool_calls, stream_response = self._stream_responses_api(
|
||||
response_text, tool_calls = self._stream_responses_api(
|
||||
responses_params,
|
||||
verbose=verbose,
|
||||
stream_callback=stream_callback,
|
||||
emit_events=emit_events,
|
||||
current_time=current_time,
|
||||
)
|
||||
# Streamed calls still incur real token cost; track
|
||||
# usage from the final response.completed event so
|
||||
# the public collector isn't silently zeroed.
|
||||
if stream_response is not None:
|
||||
self._track_token_usage(stream_response, self.model)
|
||||
if self.metrics and getattr(stream_response, 'usage', None):
|
||||
usage = stream_response.usage
|
||||
tokens_in = getattr(usage, 'input_tokens', 0) or 0
|
||||
tokens_out = getattr(usage, 'output_tokens', 0) or 0
|
||||
llm_latency_ms = (time.time() - current_time) * 1000
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'llm_end', model=self.model,
|
||||
tokens_in=tokens_in, tokens_out=tokens_out,
|
||||
cost=None, latency_ms=llm_latency_ms,
|
||||
)
|
||||
else:
|
||||
resp = self._call_responses_api(**responses_params)
|
||||
response_text, tool_calls, _reasoning = self._extract_from_responses_output(resp)
|
||||
@@ -3020,9 +2925,19 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
)
|
||||
|
||||
# Append tool result as standard tool message
|
||||
messages.append(self._create_tool_message(
|
||||
function_name, tool_result,
|
||||
tool_result_obj.tool_call_id, is_ollama=False))
|
||||
if tool_result is None:
|
||||
content = "Function returned an empty output"
|
||||
elif isinstance(tool_result, dict) and 'error' in tool_result:
|
||||
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user."
|
||||
elif isinstance(tool_result, list) and tool_result and isinstance(tool_result[0], dict) and 'error' in tool_result[0]:
|
||||
content = f"Error: {tool_result[0].get('error', 'Unknown error')}. Please inform the user."
|
||||
else:
|
||||
content = json.dumps(tool_result)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_result_obj.tool_call_id,
|
||||
"content": content,
|
||||
})
|
||||
|
||||
# Report any unparseable tool-call arguments so the
|
||||
# model can re-emit them (never dispatched with {}).
|
||||
@@ -3181,7 +3096,12 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
if formatted_tools and not self._supports_streaming_tools():
|
||||
# Provider doesn't support streaming with tools, use non-streaming
|
||||
use_streaming = False
|
||||
|
||||
|
||||
# Gemini has issues with streaming + tools, disable streaming for Gemini when tools are present
|
||||
if use_streaming and formatted_tools and self._is_gemini_model():
|
||||
logging.debug("Disabling streaming for Gemini model with tools due to JSON parsing issues")
|
||||
use_streaming = False
|
||||
|
||||
# Track whether fallback was successful to avoid duplicate API calls
|
||||
fallback_completed = False
|
||||
|
||||
@@ -3524,16 +3444,39 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
tool_calls = final_response["choices"][0]["message"].get("tool_calls")
|
||||
|
||||
|
||||
# Recover a tool call the provider emitted as response text
|
||||
# instead of in the tool_calls field. Which providers do this
|
||||
# is the adapter's business, not this loop's; DefaultAdapter
|
||||
# returns None so no other provider is affected.
|
||||
if not tool_calls and response_text and formatted_tools:
|
||||
recovered = self._provider_adapter.recover_tool_calls_from_text(
|
||||
response_text, formatted_tools)
|
||||
if recovered:
|
||||
tool_calls = recovered
|
||||
logging.debug(f"Recovered tool calls from response text: {tool_calls}")
|
||||
# For Ollama, parse tool calls from response text if not in tool_calls field
|
||||
if self._is_ollama_provider() and not tool_calls and response_text and formatted_tools:
|
||||
# Try to parse JSON tool call from response text
|
||||
try:
|
||||
response_json = json.loads(response_text.strip())
|
||||
if isinstance(response_json, dict) and "name" in response_json:
|
||||
# Convert Ollama format to standard tool_calls format
|
||||
tool_calls = [{
|
||||
"id": f"tool_{iteration_count}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": response_json["name"],
|
||||
"arguments": json.dumps(response_json.get("arguments", {}))
|
||||
}
|
||||
}]
|
||||
logging.debug(f"Parsed Ollama tool call from response: {tool_calls}")
|
||||
elif isinstance(response_json, list):
|
||||
# Handle multiple tool calls
|
||||
tool_calls = []
|
||||
for idx, tool_json in enumerate(response_json):
|
||||
if isinstance(tool_json, dict) and "name" in tool_json:
|
||||
tool_calls.append({
|
||||
"id": f"tool_{iteration_count}_{idx}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_json["name"],
|
||||
"arguments": json.dumps(tool_json.get("arguments", {}))
|
||||
}
|
||||
})
|
||||
if tool_calls:
|
||||
logging.debug(f"Parsed multiple Ollama tool calls from response: {tool_calls}")
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
logging.debug(f"Could not parse Ollama tool call from response: {e}")
|
||||
|
||||
# Parse tool calls from XML format in response text
|
||||
# Try for known XML models first, or fallback for any model that might output XML
|
||||
@@ -3805,8 +3748,20 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
pass
|
||||
else:
|
||||
# For other providers, use tool role with tool_call_id
|
||||
messages.append(self._create_tool_message(
|
||||
function_name, tool_result, tool_call_id, is_ollama=False))
|
||||
# Format error results more clearly
|
||||
if tool_result is None:
|
||||
content = "Function returned an empty output"
|
||||
elif isinstance(tool_result, dict) and 'error' in tool_result:
|
||||
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user that the operation could not be completed."
|
||||
elif isinstance(tool_result, list) and len(tool_result) > 0 and isinstance(tool_result[0], dict) and 'error' in tool_result[0]:
|
||||
content = f"Error: {tool_result[0].get('error', 'Unknown error')}. Please inform the user that the operation could not be completed."
|
||||
else:
|
||||
content = json.dumps(tool_result)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": content
|
||||
})
|
||||
|
||||
# Check if we should continue (for tools like sequential thinking)
|
||||
# This mimics the logic from agent.py lines 1004-1007
|
||||
@@ -3865,11 +3820,8 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
|
||||
# Special early stopping logic for Ollama when tool results are available
|
||||
# Ollama often provides empty responses after successful tool execution
|
||||
if self._provider_adapter.handle_empty_response_with_tools({
|
||||
'iteration_count': iteration_count,
|
||||
'accumulated_tool_results': accumulated_tool_results,
|
||||
'response_text': response_text or '',
|
||||
}):
|
||||
if (self._is_ollama_provider() and accumulated_tool_results and iteration_count >= 1 and
|
||||
(not response_text or response_text.strip() == "")):
|
||||
# Generate coherent response from tool results
|
||||
tool_summary = self._generate_ollama_tool_summary(accumulated_tool_results, response_text)
|
||||
if tool_summary:
|
||||
@@ -4458,15 +4410,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._tool_parse_error_message(function_name, tool_call_id)
|
||||
)
|
||||
continue
|
||||
# Validate and filter arguments for Ollama provider.
|
||||
# Pre-dispatch, so this is streaming-safe: nothing has
|
||||
# been yielded that would need retracting. Weak local
|
||||
# models routinely emit arguments belonging to a
|
||||
# different function, and dispatching those calls the
|
||||
# user's tool with parameters it never declared.
|
||||
if is_ollama and tools:
|
||||
arguments = self._validate_and_filter_ollama_arguments(
|
||||
function_name, arguments, tools)
|
||||
tool_calls_batch.append(ToolCall(
|
||||
function_name=function_name,
|
||||
arguments=arguments,
|
||||
@@ -4480,46 +4423,15 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Execute batch and add results to conversation
|
||||
# (forward optional per-tool timeout)
|
||||
# ``time`` is re-imported locally further down this
|
||||
# function, which makes the module-level name unbound
|
||||
# here; use a distinct alias.
|
||||
from time import perf_counter as _perf_counter
|
||||
_batch_started = _perf_counter()
|
||||
tool_results = executor.execute_batch(
|
||||
tool_calls_batch, execute_tool_fn,
|
||||
timeout_ms=self.tool_timeout_ms,
|
||||
)
|
||||
_batch_elapsed = _perf_counter() - _batch_started
|
||||
|
||||
for tool_result in tool_results:
|
||||
# Register any deferred handle so its eventual
|
||||
# background result is re-injected, not lost.
|
||||
self._register_deferred_if_any(tool_result)
|
||||
# Fire the 'tool_call' display callback exactly as
|
||||
# the non-streaming loop does, so streaming UIs
|
||||
# see tool activity (Issue #4716). Best-effort: a
|
||||
# callback error must never break the stream.
|
||||
try:
|
||||
if tool_result.result is None:
|
||||
_result_str = None
|
||||
else:
|
||||
try:
|
||||
_result_str = json.dumps(tool_result.result)
|
||||
except (TypeError, ValueError):
|
||||
_result_str = str(tool_result.result)
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'tool_call',
|
||||
message=f"Calling function: {tool_result.function_name}",
|
||||
tool_name=tool_result.function_name,
|
||||
tool_input=tool_result.arguments,
|
||||
tool_output=_result_str[:200] if _result_str else None,
|
||||
# Only the batch is timed; per-tool timing
|
||||
# is not available from the executor.
|
||||
elapsed_time=_batch_elapsed if len(tool_results) == 1 else None,
|
||||
success=tool_result.error is None,
|
||||
)
|
||||
except Exception as _cb_error:
|
||||
logging.debug(f"tool_call callback failed for '{tool_result.function_name}': {_cb_error}")
|
||||
if tool_result.error is None:
|
||||
# Successful execution
|
||||
tool_message = self._create_tool_message(
|
||||
@@ -4587,170 +4499,26 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
use_streaming = False
|
||||
|
||||
if not use_streaming:
|
||||
# Fall back to non-streaming. This branch is taken whenever the
|
||||
# provider cannot stream WITH tools -- Anthropic, Gemini and
|
||||
# Ollama all report False from _supports_streaming_tools() --
|
||||
# and also whenever the streaming attempt above failed.
|
||||
#
|
||||
# It used to read message.content and nothing else. The request
|
||||
# carried `tools`, so the model answered with tool_calls and an
|
||||
# empty content, and every one of those calls was dropped: the
|
||||
# tool never ran, nothing was yielded, and no warning was
|
||||
# raised. A turn that needed a tool produced an empty stream.
|
||||
#
|
||||
# Run the tool loop here too, bounded by max_iter the same way
|
||||
# the non-streaming path is.
|
||||
# Fall back to non-streaming and yield the complete response
|
||||
try:
|
||||
is_ollama = self._is_ollama_provider()
|
||||
fallback_iterations = 0
|
||||
tool_call_count = 0
|
||||
last_tool_call_fingerprint = None
|
||||
stall_reason = None
|
||||
max_fallback_iterations = kwargs.pop("max_iterations", self.max_iter)
|
||||
while fallback_iterations < max_fallback_iterations:
|
||||
fallback_iterations += 1
|
||||
response = self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
stream=False,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
**kwargs
|
||||
)
|
||||
response = self._completion_with_retry(
|
||||
**self._build_completion_params(
|
||||
messages=messages,
|
||||
tools=formatted_tools,
|
||||
temperature=temperature,
|
||||
stream=False,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
if not (response and response.choices):
|
||||
break
|
||||
|
||||
message = response.choices[0].message
|
||||
content = getattr(message, "content", None)
|
||||
tool_calls = getattr(message, "tool_calls", None)
|
||||
|
||||
if not tool_calls or not execute_tool_fn:
|
||||
# Nothing to dispatch: emit whatever prose we have.
|
||||
if content:
|
||||
yield content
|
||||
elif tool_call_count:
|
||||
# Tools ran, but the provider ended the turn
|
||||
# without writing an answer.
|
||||
stall_reason = "empty final answer"
|
||||
break
|
||||
|
||||
# A provider that re-emits the identical tool call with
|
||||
# no prose is not making progress: those results are
|
||||
# already in `messages`, so running the tool again
|
||||
# cannot add information. Stop before dispatching.
|
||||
fingerprint = self._tool_call_fingerprint(tool_calls, is_ollama)
|
||||
if (not content and tool_call_count
|
||||
and fingerprint == last_tool_call_fingerprint):
|
||||
logging.warning(
|
||||
"Provider repeated the same tool call with no answer at "
|
||||
f"fallback iteration {fallback_iterations}; finalising "
|
||||
"with tools disabled.")
|
||||
stall_reason = "repeated tool call"
|
||||
break
|
||||
last_tool_call_fingerprint = fingerprint
|
||||
|
||||
# Record the assistant turn before the tool replies, so
|
||||
# the follow-up request is a valid conversation.
|
||||
if is_ollama:
|
||||
messages.append({"role": "assistant", "content": content or ""})
|
||||
else:
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": content or "",
|
||||
"tool_calls": self._serialize_tool_calls(tool_calls),
|
||||
})
|
||||
|
||||
# Any prose the model emitted alongside the calls is
|
||||
# still part of the answer.
|
||||
)
|
||||
|
||||
if response and response.choices:
|
||||
content = response.choices[0].message.content
|
||||
if content:
|
||||
# Yield the complete response as a single chunk
|
||||
yield content
|
||||
|
||||
# Enforce the same per-turn guardrail the streaming and
|
||||
# non-streaming loops apply, so the fallback cannot run
|
||||
# tools past the caller's configured limit.
|
||||
if tool_call_count >= max_tool_calls_per_turn:
|
||||
logging.warning(
|
||||
f"Tool call limit reached ({max_tool_calls_per_turn}). "
|
||||
"Stopping to prevent infinite loop.")
|
||||
stall_reason = "tool call limit"
|
||||
break
|
||||
if tool_call_count + len(tool_calls) > max_tool_calls_per_turn:
|
||||
remaining_calls = max_tool_calls_per_turn - tool_call_count
|
||||
tool_calls = tool_calls[:remaining_calls]
|
||||
logging.warning(
|
||||
f"Limiting batch to {remaining_calls} tool calls to stay "
|
||||
f"within limit of {max_tool_calls_per_turn}.")
|
||||
|
||||
for tool_call in tool_calls:
|
||||
tool_call_count += 1
|
||||
function_name, arguments, tool_call_id = (
|
||||
self._extract_tool_call_info(tool_call, is_ollama))
|
||||
if self._tool_arguments_parse_failed(arguments):
|
||||
messages.append(
|
||||
self._tool_parse_error_message(function_name, tool_call_id))
|
||||
continue
|
||||
# Validate and filter arguments for Ollama provider.
|
||||
# Pre-dispatch, so this is streaming-safe: nothing has
|
||||
# been yielded that would need retracting. Weak local
|
||||
# models routinely emit arguments belonging to a
|
||||
# different function, and dispatching those calls the
|
||||
# user's tool with parameters it never declared.
|
||||
if is_ollama and tools:
|
||||
arguments = self._validate_and_filter_ollama_arguments(
|
||||
function_name, arguments, tools)
|
||||
try:
|
||||
tool_result = execute_tool_fn(function_name, arguments)
|
||||
except Exception as tool_error: # noqa: BLE001
|
||||
# Report the failure to the model rather than
|
||||
# aborting the run, matching the streaming loop.
|
||||
logging.warning(
|
||||
f"Tool '{function_name}' failed: {tool_error}")
|
||||
tool_result = {"error": str(tool_error)}
|
||||
try:
|
||||
_get_display_functions()['execute_sync_callback'](
|
||||
'tool_call',
|
||||
message=f"Calling function: {function_name}",
|
||||
tool_name=function_name,
|
||||
tool_input=arguments,
|
||||
tool_output=str(tool_result)[:200],
|
||||
success=not (isinstance(tool_result, dict)
|
||||
and 'error' in tool_result),
|
||||
)
|
||||
except Exception as cb_error: # noqa: BLE001
|
||||
logging.debug(
|
||||
f"tool_call callback failed for '{function_name}': {cb_error}")
|
||||
messages.append(self._create_tool_message(
|
||||
function_name, tool_result, tool_call_id, is_ollama))
|
||||
# Loop: ask again now the results are in the messages.
|
||||
else:
|
||||
logging.warning(
|
||||
f"Iteration limit reached ({max_fallback_iterations}) on the "
|
||||
"non-streaming fallback.")
|
||||
stall_reason = "iteration limit"
|
||||
|
||||
# The loop ended without the provider ever writing an answer,
|
||||
# but every tool result is in `messages`. Ask once more with
|
||||
# tools disabled: the provider cannot emit another tool call,
|
||||
# so it has to answer. This is the same bounded finalisation
|
||||
# get_response and get_response_async already perform -- the
|
||||
# stream path was the only one that skipped it.
|
||||
if stall_reason and tool_call_count:
|
||||
final_text = self._finalise_on_limit(
|
||||
messages, "", temperature=temperature,
|
||||
wrap_up_prompt=self.TOOL_LOOP_STALL_PROMPT,
|
||||
**kwargs)
|
||||
if (final_text and final_text.strip()
|
||||
and final_text != self.STEP_LIMIT_FALLBACK_MESSAGE):
|
||||
yield final_text
|
||||
else:
|
||||
raise LLMResponseError(
|
||||
f"Provider produced no answer after {tool_call_count} "
|
||||
f"tool call(s) on the streaming path ({stall_reason}).")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Non-streaming fallback failed: {e}")
|
||||
raise
|
||||
@@ -4812,7 +4580,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
execute_tool_fn: Optional[Callable] = None,
|
||||
max_tool_calls_per_turn: int = 10, # Loop guardrails
|
||||
stream: bool = True,
|
||||
parallel_tool_calls: bool = False,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Async version of get_response with identical functionality."""
|
||||
@@ -4839,36 +4606,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
except Exception as _steer_err:
|
||||
logging.debug(f"Steering drain failed (ignored): {_steer_err}")
|
||||
|
||||
async def _dispatch_tool_batch(dispatch_specs, iteration_index):
|
||||
"""Run a batch of (function_name, arguments, tool_call_id) dispatches.
|
||||
|
||||
When ``parallel_tool_calls`` is set, the independent tool coroutines
|
||||
are awaited concurrently via ``asyncio.gather`` (mirroring the sync
|
||||
path's ``create_tool_call_executor(parallel=True)``); otherwise they
|
||||
run one-at-a-time. Results are always returned in the original call
|
||||
order so the existing message-building loop stays unchanged.
|
||||
"""
|
||||
if parallel_tool_calls and len(dispatch_specs) > 1:
|
||||
# return_exceptions=True so a single failing tool does not cancel
|
||||
# its still-in-flight siblings and orphan their coroutines (the
|
||||
# exact leak class this PR fixes). We then re-raise the first
|
||||
# error after every sibling has settled, preserving the
|
||||
# sequential path's fail-fast propagation semantics.
|
||||
gathered = await asyncio.gather(*(
|
||||
_dispatch_async_tool(execute_tool_fn, fn, args, tc_id, iteration_index)
|
||||
for (fn, args, tc_id) in dispatch_specs
|
||||
), return_exceptions=True)
|
||||
for item in gathered:
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
return gathered
|
||||
results = []
|
||||
for (fn, args, tc_id) in dispatch_specs:
|
||||
results.append(await _dispatch_async_tool(
|
||||
execute_tool_fn, fn, args, tc_id, iteration_index
|
||||
))
|
||||
return results
|
||||
|
||||
try:
|
||||
import litellm
|
||||
logging.debug(f"Getting async response from {self.model}")
|
||||
@@ -4982,15 +4719,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
current_time = time.time()
|
||||
|
||||
if stream:
|
||||
response_text, tool_calls, stream_response = await self._stream_responses_api_async(
|
||||
response_text, tool_calls = await self._stream_responses_api_async(
|
||||
responses_params,
|
||||
stream_callback=kwargs.get('stream_callback'),
|
||||
emit_events=kwargs.get('emit_events', False),
|
||||
)
|
||||
# Streamed calls still incur real token cost; track
|
||||
# usage from the final response.completed event.
|
||||
if stream_response is not None:
|
||||
self._track_token_usage(stream_response, self.model)
|
||||
else:
|
||||
resp = await self._call_responses_api_async(**responses_params)
|
||||
response_text, tool_calls, _reasoning = self._extract_from_responses_output(resp)
|
||||
@@ -5056,10 +4789,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
tool_calls = tool_calls[:remaining_calls]
|
||||
logging.warning(f"Limiting batch to {remaining_calls} tool calls to stay within limit of {max_tool_calls_per_turn}.")
|
||||
|
||||
# Pre-resolve each tool call, then dispatch the valid ones
|
||||
# as a batch (concurrently when parallel_tool_calls is set).
|
||||
_parsed_calls = []
|
||||
_dispatch_specs = []
|
||||
for tool_call in tool_calls:
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
|
||||
if self._tool_arguments_parse_failed(arguments):
|
||||
@@ -5067,10 +4796,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
|
||||
continue
|
||||
logging.debug(f"[RESPONSES_API_ASYNC] Executing tool {function_name}")
|
||||
_parsed_calls.append((function_name, arguments, tool_call_id))
|
||||
_dispatch_specs.append((function_name, arguments, tool_call_id))
|
||||
_batch_results = await _dispatch_tool_batch(_dispatch_specs, iteration_count)
|
||||
for (function_name, arguments, tool_call_id), tool_result in zip(_parsed_calls, _batch_results):
|
||||
tool_result = await _dispatch_async_tool(
|
||||
execute_tool_fn,
|
||||
function_name,
|
||||
arguments,
|
||||
tool_call_id,
|
||||
iteration_count,
|
||||
)
|
||||
tool_call_count += 1 # Increment tool call counter for guardrails
|
||||
accumulated_tool_results.append(tool_result)
|
||||
|
||||
@@ -5080,8 +4812,17 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
_get_display_functions()['display_tool_call'](display_message, console=self.console)
|
||||
|
||||
# Append tool result
|
||||
messages.append(self._create_tool_message(
|
||||
function_name, tool_result, tool_call_id, is_ollama=False))
|
||||
if tool_result is None:
|
||||
content = "Function returned an empty output"
|
||||
elif isinstance(tool_result, dict) and 'error' in tool_result:
|
||||
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user."
|
||||
else:
|
||||
content = json.dumps(tool_result)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": content,
|
||||
})
|
||||
|
||||
if iteration_count + 1 >= max_iterations:
|
||||
self._last_stop_reason = "max_steps"
|
||||
@@ -5300,11 +5041,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# flushed only AFTER every tool reply for this assistant
|
||||
# turn, keeping all tool replies consecutive (provider contract).
|
||||
_deferred_media_followups: List[Dict[str, Any]] = []
|
||||
# First pass: parse/validate every call and record a plan so
|
||||
# the valid dispatches can be batched (concurrently when
|
||||
# parallel_tool_calls is set) while preserving call order.
|
||||
_call_plan = [] # ('error', msg) | ('call', fn, args, tc_id)
|
||||
_dispatch_specs = []
|
||||
for tool_call in tool_calls:
|
||||
# Handle both object and dict access patterns
|
||||
is_ollama = self._is_ollama_provider()
|
||||
@@ -5312,24 +5048,20 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
if self._tool_arguments_parse_failed(arguments):
|
||||
# Do NOT dispatch with {}: surface a retryable error.
|
||||
_call_plan.append(('error', self._tool_parse_error_message(function_name, tool_call_id)))
|
||||
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
|
||||
continue
|
||||
|
||||
# Validate and filter arguments for Ollama provider
|
||||
if is_ollama and tools:
|
||||
arguments = self._validate_and_filter_ollama_arguments(function_name, arguments, tools)
|
||||
|
||||
_call_plan.append(('call', function_name, arguments, tool_call_id))
|
||||
_dispatch_specs.append((function_name, arguments, tool_call_id))
|
||||
|
||||
_batch_results = await _dispatch_tool_batch(_dispatch_specs, iteration_count)
|
||||
_result_iter = iter(_batch_results)
|
||||
for _plan in _call_plan:
|
||||
if _plan[0] == 'error':
|
||||
messages.append(_plan[1])
|
||||
continue
|
||||
_, function_name, arguments, tool_call_id = _plan
|
||||
tool_result = next(_result_iter)
|
||||
tool_result = await _dispatch_async_tool(
|
||||
execute_tool_fn,
|
||||
function_name,
|
||||
arguments,
|
||||
tool_call_id,
|
||||
iteration_count,
|
||||
)
|
||||
tool_call_count += 1 # Increment tool call counter for guardrails
|
||||
tool_results.append(tool_result) # Store the result
|
||||
accumulated_tool_results.append(tool_result) # Accumulate across iterations
|
||||
@@ -5354,8 +5086,20 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
pass
|
||||
else:
|
||||
# For other providers, use tool role with tool_call_id
|
||||
messages.append(self._create_tool_message(
|
||||
function_name, tool_result, tool_call_id, is_ollama=False))
|
||||
# Format error results more clearly
|
||||
if tool_result is None:
|
||||
content = "Function returned an empty output"
|
||||
elif isinstance(tool_result, dict) and 'error' in tool_result:
|
||||
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user that the operation could not be completed."
|
||||
elif isinstance(tool_result, list) and len(tool_result) > 0 and isinstance(tool_result[0], dict) and 'error' in tool_result[0]:
|
||||
content = f"Error: {tool_result[0].get('error', 'Unknown error')}. Please inform the user that the operation could not be completed."
|
||||
else:
|
||||
content = json.dumps(tool_result)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": content
|
||||
})
|
||||
|
||||
# Flush deferred media follow-ups after all tool replies
|
||||
# for this turn have been appended (provider contract).
|
||||
@@ -5532,11 +5276,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Special early stopping logic for Ollama when tool results are available
|
||||
# Ollama often provides empty responses after successful tool execution
|
||||
if self._provider_adapter.handle_empty_response_with_tools({
|
||||
'iteration_count': iteration_count,
|
||||
'accumulated_tool_results': accumulated_tool_results,
|
||||
'response_text': response_text or '',
|
||||
}):
|
||||
if (self._is_ollama_provider() and accumulated_tool_results and iteration_count >= 1 and
|
||||
(not response_text or response_text.strip() == "")):
|
||||
# Generate coherent response from tool results
|
||||
tool_summary = self._generate_ollama_tool_summary(accumulated_tool_results, response_text)
|
||||
if tool_summary:
|
||||
@@ -6005,49 +5746,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
"""Set the current agent name for token tracking."""
|
||||
self.current_agent_name = agent_name
|
||||
|
||||
def _resolve_openai_compatible_model(self) -> str:
|
||||
"""Route a bare model name through the OpenAI-compatible client.
|
||||
|
||||
When a custom ``base_url`` is set but the model has no ``provider/``
|
||||
prefix (e.g. ``deepseek-v4-flash`` against an OpenAI-compatible host),
|
||||
LiteLLM cannot infer a provider and raises "LLM Provider NOT provided".
|
||||
Prefixing ``openai/`` makes LiteLLM use its OpenAI Chat Completions
|
||||
client against the supplied ``base_url``. Anthropic/Gemini/Ollama models
|
||||
are left untouched so their native adapters still apply.
|
||||
|
||||
The effective base_url is either the instance ``base_url`` or one
|
||||
injected by a subscription auth provider (e.g. qwen-cli/codex resolve a
|
||||
Chat-Completions host at request time). Both paths need routing so a
|
||||
bare model under subscription auth does not reach LiteLLM unprefixed.
|
||||
"""
|
||||
model = self.model
|
||||
if not isinstance(model, str):
|
||||
return model
|
||||
# llama.cpp's llama-server speaks OpenAI, but litellm knows no
|
||||
# "llama_cpp/" provider -- it raised "LLM Provider NOT provided" and
|
||||
# then retried four times before returning None. We accept the prefix
|
||||
# because it names a real local engine, so we must also make it
|
||||
# routable: strip it and let the OpenAI client handle the base_url.
|
||||
for _local_prefix in ("llama_cpp/", "llamacpp/"):
|
||||
if model.startswith(_local_prefix):
|
||||
return f"openai/{model[len(_local_prefix):]}"
|
||||
if "/" in model:
|
||||
return model
|
||||
effective_base_url = self.base_url
|
||||
if not effective_base_url:
|
||||
creds = self._resolve_subscription_creds()
|
||||
if creds and getattr(creds, "base_url", None):
|
||||
effective_base_url = creds.base_url
|
||||
if not effective_base_url:
|
||||
return model
|
||||
if self._detect_provider() != "openai":
|
||||
return model
|
||||
return f"openai/{model}"
|
||||
|
||||
def _build_completion_params(self, **override_params) -> Dict[str, Any]:
|
||||
"""Build parameters for litellm completion calls with all necessary config"""
|
||||
params = {
|
||||
"model": self._resolve_openai_compatible_model(),
|
||||
"model": self.model,
|
||||
}
|
||||
|
||||
# Add optional parameters if they exist
|
||||
@@ -6359,7 +6061,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
- tools → ``tools`` (Responses API uses same function schema)
|
||||
- temperature, max_tokens, etc. → direct pass-through
|
||||
"""
|
||||
params: Dict[str, Any] = {"model": self._resolve_openai_compatible_model()}
|
||||
params: Dict[str, Any] = {"model": self.model}
|
||||
|
||||
# ── Extract system instructions ─────────────────────────────────
|
||||
instructions = None
|
||||
@@ -6510,12 +6212,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
"""
|
||||
Call ``litellm.responses()`` synchronously with retry support.
|
||||
Returns a ``ResponsesAPIResponse`` object.
|
||||
|
||||
Raises:
|
||||
ModelRequestBlocked: If a test suite turned real model requests off
|
||||
via ``praisonaiagents.model_harness.allow_model_requests(False)``.
|
||||
"""
|
||||
check_model_request(self.model, "litellm.responses")
|
||||
import litellm
|
||||
return self._call_with_retry(litellm.responses, **params)
|
||||
|
||||
@@ -6523,12 +6220,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
"""
|
||||
Call ``litellm.aresponses()`` asynchronously with retry support.
|
||||
Returns a ``ResponsesAPIResponse`` object.
|
||||
|
||||
Raises:
|
||||
ModelRequestBlocked: If a test suite turned real model requests off
|
||||
via ``praisonaiagents.model_harness.allow_model_requests(False)``.
|
||||
"""
|
||||
check_model_request(self.model, "litellm.aresponses")
|
||||
import litellm
|
||||
response = await self._call_with_retry_async(litellm.aresponses, **params)
|
||||
self._track_token_usage(response, self._response_model_for_tracking(response))
|
||||
@@ -6620,12 +6312,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
Returns the same (response_text, tool_calls) tuple as the Chat
|
||||
Completions streaming path. Emits StreamEvents when configured.
|
||||
|
||||
Raises:
|
||||
ModelRequestBlocked: If a test suite turned real model requests off
|
||||
via ``praisonaiagents.model_harness.allow_model_requests(False)``.
|
||||
"""
|
||||
check_model_request(self.model, "litellm.responses")
|
||||
import litellm
|
||||
|
||||
params["stream"] = True
|
||||
@@ -6633,7 +6320,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# tool_calls accumulator: {output_index: {"id":..., "name":..., "arguments":...}}
|
||||
_pending_tools: Dict[int, Dict[str, Any]] = {}
|
||||
_emit = emit_events and stream_callback is not None
|
||||
_final_response = None
|
||||
|
||||
if _emit:
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
@@ -6645,11 +6331,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
else:
|
||||
evt_type = getattr(event, "type", "")
|
||||
|
||||
# ── Completed — capture the final response for usage accounting ──
|
||||
if evt_type == "response.completed":
|
||||
_final_response = (event.get("response") if isinstance(event, dict)
|
||||
else getattr(event, "response", None))
|
||||
|
||||
# ── Text delta ──────────────────────────────────────────
|
||||
if evt_type == "response.output_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
@@ -6741,7 +6422,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
},
|
||||
})
|
||||
|
||||
return response_text, tool_calls if tool_calls else None, _final_response
|
||||
return response_text, tool_calls if tool_calls else None
|
||||
|
||||
async def _stream_responses_api_async(
|
||||
self,
|
||||
@@ -6752,19 +6433,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
"""
|
||||
Async streaming for Responses API.
|
||||
Returns (response_text, tool_calls).
|
||||
|
||||
Raises:
|
||||
ModelRequestBlocked: If a test suite turned real model requests off
|
||||
via ``praisonaiagents.model_harness.allow_model_requests(False)``.
|
||||
"""
|
||||
check_model_request(self.model, "litellm.aresponses")
|
||||
import litellm
|
||||
|
||||
params["stream"] = True
|
||||
response_text = ""
|
||||
_pending_tools: Dict[int, Dict[str, Any]] = {}
|
||||
_emit = emit_events and stream_callback is not None
|
||||
_final_response = None
|
||||
|
||||
if _emit:
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
@@ -6775,10 +6450,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
else:
|
||||
evt_type = getattr(event, "type", "")
|
||||
|
||||
if evt_type == "response.completed":
|
||||
_final_response = (event.get("response") if isinstance(event, dict)
|
||||
else getattr(event, "response", None))
|
||||
|
||||
if evt_type == "response.output_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
@@ -6853,7 +6524,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
"function": {"name": tc["name"], "arguments": tc["arguments"]},
|
||||
})
|
||||
|
||||
return response_text, tool_calls if tool_calls else None, _final_response
|
||||
return response_text, tool_calls if tool_calls else None
|
||||
|
||||
def _process_streaming_chunk(self, chunk) -> Optional[str]:
|
||||
"""Extract content from a streaming chunk"""
|
||||
@@ -6881,33 +6552,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
tool_calls[tc.index]["function"]["arguments"] += tc.function.arguments
|
||||
return tool_calls
|
||||
|
||||
def _create_tool_message(self, function_name: str, result: Any,
|
||||
tool_call_id: Optional[str], is_ollama: bool = False) -> Dict[str, Any]:
|
||||
"""Build the message that carries a tool's result back to the model.
|
||||
|
||||
get_response_stream called this from its streaming tool loop and it was
|
||||
never defined, so the first tool result raised AttributeError. The
|
||||
broad `except Exception` around that loop swallowed it, set
|
||||
use_streaming = False, and dropped through to the non-streaming branch
|
||||
-- which discarded the tool calls too. The net effect was that tool
|
||||
calling appeared to do nothing on every streaming turn, with the real
|
||||
cause visible only as a logged warning.
|
||||
|
||||
Two unit tests exercised this loop by assigning `_create_tool_message`
|
||||
onto the instance themselves, so they passed against a method
|
||||
production never had.
|
||||
|
||||
Mirrors the formatting the non-streaming loop uses, including Ollama's
|
||||
natural-language variant.
|
||||
"""
|
||||
if is_ollama:
|
||||
return self._format_ollama_tool_result_message(function_name, result)
|
||||
adapter = getattr(self, '_provider_adapter', None)
|
||||
if adapter is None:
|
||||
from .adapters import DefaultAdapter
|
||||
adapter = DefaultAdapter()
|
||||
return adapter.format_tool_result_message(function_name, result, tool_call_id)
|
||||
|
||||
def _serialize_tool_calls(self, tool_calls) -> List[Dict]:
|
||||
"""Convert tool calls to a serializable format for all providers."""
|
||||
serializable_tool_calls = []
|
||||
@@ -6926,25 +6570,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
})
|
||||
return serializable_tool_calls
|
||||
|
||||
def _tool_call_fingerprint(self, tool_calls, is_ollama: bool = False) -> tuple:
|
||||
"""Stable identity for a batch of tool calls, ignoring provider call ids.
|
||||
|
||||
Two consecutive batches with the same fingerprint and no prose between
|
||||
them mean the provider is repeating itself, not making progress. Call
|
||||
ids are excluded deliberately: Ollama mints a fresh one every turn, so
|
||||
an id-sensitive comparison would never match.
|
||||
"""
|
||||
fingerprint = []
|
||||
for tool_call in tool_calls:
|
||||
function_name, arguments, _ = self._extract_tool_call_info(
|
||||
tool_call, is_ollama)
|
||||
try:
|
||||
rendered = json.dumps(arguments, sort_keys=True, default=str)
|
||||
except (TypeError, ValueError):
|
||||
rendered = str(arguments)
|
||||
fingerprint.append((function_name, rendered))
|
||||
return tuple(fingerprint)
|
||||
|
||||
def _extract_tool_call_info(self, tool_call, is_ollama: bool = False) -> tuple:
|
||||
"""Extract function name, arguments, and tool_call_id from a tool call.
|
||||
|
||||
|
||||
@@ -102,50 +102,6 @@ def _fallback_supports_prompt_caching(model_name: str) -> bool:
|
||||
return any(p in name for p in ("claude-3", "claude-sonnet", "claude-opus", "claude-haiku", "gpt-4o", "gpt-4.1", "gpt-5", "deepseek"))
|
||||
|
||||
|
||||
def _fallback_supports_vision(model_name: str) -> bool:
|
||||
"""Static heuristic used only when litellm is unavailable."""
|
||||
name = _base_model_name(model_name)
|
||||
return any(
|
||||
p in name
|
||||
for p in (
|
||||
"gpt-4o", "gpt-4.1", "gpt-4-turbo", "gpt-4-vision", "gpt-5",
|
||||
"o1", "o3", "o4", "claude-3", "claude-sonnet", "claude-opus",
|
||||
"claude-haiku", "gemini", "llava", "pixtral", "grok-2", "grok-4",
|
||||
"qwen-vl", "qwen2-vl", "internvl", "vision",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _fallback_supports_pdf_input(model_name: str) -> bool:
|
||||
"""Static heuristic used only when litellm is unavailable.
|
||||
|
||||
Narrower than vision: a model may read images and still reject a raw PDF
|
||||
document part. Kept to the families that accept PDFs natively today
|
||||
(Anthropic Claude 3.5+/4, OpenAI gpt-4o/4.1/5 file inputs, Gemini).
|
||||
"""
|
||||
name = _base_model_name(model_name)
|
||||
return any(
|
||||
p in name
|
||||
for p in (
|
||||
"claude-3-5", "claude-3-7", "claude-sonnet", "claude-opus",
|
||||
"claude-haiku", "gpt-4o", "gpt-4.1", "gpt-5", "gemini",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _fallback_supports_audio_input(model_name: str) -> bool:
|
||||
"""Static heuristic used only when litellm is unavailable.
|
||||
|
||||
Very narrow on purpose: most chat models reject an ``input_audio`` part
|
||||
outright, so guessing ``True`` would trade a silent drop for a hard API
|
||||
error. Only the explicitly audio-capable families are reported.
|
||||
"""
|
||||
name = _base_model_name(model_name)
|
||||
if "audio" in name: # gpt-4o-audio-preview, gpt-audio, ...
|
||||
return True
|
||||
return any(p in name for p in ("gemini-1.5", "gemini-2", "gemini-3", "qwen-audio"))
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def supports_structured_outputs(model_name: str) -> bool:
|
||||
"""
|
||||
@@ -345,89 +301,6 @@ def supports_prompt_caching(model_name: str) -> bool:
|
||||
return _fallback_supports_prompt_caching(model_name)
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def supports_vision(model_name: str) -> bool:
|
||||
"""Check if a model can accept image parts.
|
||||
|
||||
Uses LiteLLM's ``supports_vision()`` as the primary check, falling back to
|
||||
a static heuristic when litellm is not installed.
|
||||
"""
|
||||
if not model_name:
|
||||
return False
|
||||
|
||||
litellm = None
|
||||
try:
|
||||
litellm = _get_litellm()
|
||||
if litellm is None:
|
||||
return _fallback_supports_vision(model_name)
|
||||
if hasattr(litellm, "supports_vision"):
|
||||
return litellm.supports_vision(model=model_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if litellm is not None:
|
||||
return False
|
||||
return _fallback_supports_vision(model_name)
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def supports_pdf_input(model_name: str) -> bool:
|
||||
"""Check if a model can accept a PDF document part natively.
|
||||
|
||||
Uses LiteLLM's ``supports_pdf_input()`` (top level or ``litellm.utils``)
|
||||
as the primary check, falling back to a static heuristic when litellm is
|
||||
not installed. Callers that get ``False`` must degrade *visibly* (e.g.
|
||||
extract the PDF's text) rather than dropping the attachment.
|
||||
"""
|
||||
if not model_name:
|
||||
return False
|
||||
|
||||
litellm = None
|
||||
try:
|
||||
litellm = _get_litellm()
|
||||
if litellm is None:
|
||||
return _fallback_supports_pdf_input(model_name)
|
||||
fn = getattr(litellm, "supports_pdf_input", None)
|
||||
if fn is None and hasattr(litellm, "utils"):
|
||||
fn = getattr(litellm.utils, "supports_pdf_input", None)
|
||||
if fn is not None:
|
||||
return fn(model=model_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if litellm is not None:
|
||||
return False
|
||||
return _fallback_supports_pdf_input(model_name)
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def supports_audio_input(model_name: str) -> bool:
|
||||
"""Check if a model can accept an ``input_audio`` part.
|
||||
|
||||
Uses LiteLLM's ``supports_audio_input()`` as the primary check, falling
|
||||
back to a static heuristic when litellm is not installed.
|
||||
"""
|
||||
if not model_name:
|
||||
return False
|
||||
|
||||
litellm = None
|
||||
try:
|
||||
litellm = _get_litellm()
|
||||
if litellm is None:
|
||||
return _fallback_supports_audio_input(model_name)
|
||||
fn = getattr(litellm, "supports_audio_input", None)
|
||||
if fn is None and hasattr(litellm, "utils"):
|
||||
fn = getattr(litellm.utils, "supports_audio_input", None)
|
||||
if fn is not None:
|
||||
return fn(model=model_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if litellm is not None:
|
||||
return False
|
||||
return _fallback_supports_audio_input(model_name)
|
||||
|
||||
|
||||
# Models that support web fetch via LiteLLM (Anthropic only)
|
||||
# Web fetch retrieves full content from specific URLs (web pages and PDFs)
|
||||
# Source: https://docs.litellm.ai/docs/completion/web_fetch
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
"""Model-name → provider-id resolution.
|
||||
|
||||
Maps a bare model name (e.g. ``claude-sonnet-4-6``, ``llama-3.1-70b``) to its
|
||||
canonical provider id (``anthropic``, ``groq``, ...). This is the single source
|
||||
of truth for provider inference so callers no longer hand-code a 3-branch
|
||||
``if/elif`` that silently ignores every other vendor.
|
||||
|
||||
Third parties add a vendor by publishing an entry point under the
|
||||
``praisonaiagents.model_providers`` group. Each entry point loads a callable
|
||||
``(model_name_lowercased: str) -> bool``; the entry-point *name* is the provider
|
||||
id returned on a match. Registered matchers take precedence over the built-ins,
|
||||
so a plugin can also override built-in detection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
# Built-in model-name → provider matchers. Kept intentionally small and data
|
||||
# driven; extend via the entry-point group rather than editing callers.
|
||||
_BUILTINS: Dict[str, Callable[[str], bool]] = {
|
||||
# ``claude`` is matched as a substring so cloud-vendored forms
|
||||
# (``us.anthropic.claude-3-5-sonnet``, ``anthropic.claude-...``) still
|
||||
# resolve, preserving the pre-refactor behaviour that keyed off
|
||||
# ``'claude' in model``.
|
||||
"anthropic": lambda m: "claude" in m,
|
||||
"openai": lambda m: m.startswith(("gpt-", "o1-", "o3-", "o4-", "chatgpt-")),
|
||||
"google": lambda m: m.startswith(("gemini-", "gemma-")),
|
||||
"groq": lambda m: m.startswith(("llama-", "mixtral-", "gemma2-")) and "groq" in m,
|
||||
"cohere": lambda m: m.startswith(("command-", "c4ai-")),
|
||||
"mistral": lambda m: m.startswith(("mistral-", "codestral-", "open-mistral-", "open-mixtral-")),
|
||||
"ollama": lambda m: m.startswith("ollama/"),
|
||||
"deepseek": lambda m: m.startswith("deepseek-"),
|
||||
"xai": lambda m: m.startswith("grok-"),
|
||||
"perplexity": lambda m: m.startswith(("pplx-", "sonar-")),
|
||||
}
|
||||
|
||||
|
||||
# Closed-weights model families that no local runtime can serve. Used to stop a
|
||||
# local ``base_url`` (an OpenAI-compatible proxy on :11434, say) from being read
|
||||
# as "this is an Ollama model".
|
||||
#
|
||||
# Open-weights families are deliberately ABSENT -- gemma, llama, mistral, qwen,
|
||||
# deepseek and phi are all routinely served by Ollama, so they must stay
|
||||
# eligible for URL-based local detection. That is why this is a separate, much
|
||||
# narrower predicate than ``resolve_provider``: the latter maps ``gemma-`` to
|
||||
# "google" and ``mistral-`` to "mistral", which is correct for provider routing
|
||||
# and wrong for deciding whether a model could be running locally.
|
||||
HOSTED_ONLY_PREFIXES = ("gpt-", "o1-", "o3-", "o4-", "chatgpt-", "claude", "gemini-")
|
||||
|
||||
|
||||
def is_hosted_only_model(model_name: str) -> bool:
|
||||
"""True if ``model_name`` names a closed-weights model no local server hosts.
|
||||
|
||||
Every route segment is tested, so single-prefix (``openai/gpt-4o``), nested
|
||||
(``openrouter/openai/gpt-4o``, ``openrouter/anthropic/claude-*``) and
|
||||
vendor-qualified (``bedrock/anthropic.claude-*``, ``us.anthropic.claude-*``)
|
||||
forms all resolve to the hosted family they name -- mirroring the substring
|
||||
fallback that ``_detect_provider`` uses for the same routed models. An
|
||||
open-weights model reached over the OpenAI-compatible route
|
||||
(``openai/qwen3:0.6b``) is not hosted-only and keeps its local treatment,
|
||||
because the prefixes tested are themselves closed-weights families no local
|
||||
runtime serves.
|
||||
"""
|
||||
if not model_name:
|
||||
return False
|
||||
lower = model_name.lower()
|
||||
# Split on both route ("/") and vendor-qualifier (".") boundaries so a
|
||||
# family name anywhere in the path is seen: bedrock/anthropic.claude-3 ->
|
||||
# ["bedrock", "anthropic", "claude-3"].
|
||||
for segment in lower.replace(".", "/").split("/"):
|
||||
if segment.startswith(HOSTED_ONLY_PREFIXES):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# The small helper model used for internal auxiliary calls -- memory quality
|
||||
# scoring, context compaction, session titling, workflow routing. Historically
|
||||
# about a dozen sites resolved this from OPENAI_MODEL_NAME while about twenty
|
||||
# more hardcoded the same string, so half of them could not be pointed anywhere
|
||||
# else. That, not the endpoint, is what blocks running fully locally: litellm and
|
||||
# the openai SDK both honour OPENAI_BASE_URL, so those sites do reach a local
|
||||
# server -- and then ask it for a model no local server serves.
|
||||
DEFAULT_AUXILIARY_MODEL = "gpt-4o-mini"
|
||||
|
||||
|
||||
def default_auxiliary_model(explicit: Optional[str] = None) -> str:
|
||||
"""Resolve the model to use for an internal auxiliary LLM call.
|
||||
|
||||
Precedence: an explicit argument, then PRAISONAI_AUXILIARY_MODEL, then
|
||||
OPENAI_MODEL_NAME, then DEFAULT_AUXILIARY_MODEL.
|
||||
|
||||
PRAISONAI_AUXILIARY_MODEL exists because a local setup often wants a
|
||||
*smaller* model for helper calls than for the agent itself -- pointing
|
||||
OPENAI_MODEL_NAME at a 70B local model should not make every internal
|
||||
summarisation call use it.
|
||||
"""
|
||||
if explicit:
|
||||
return explicit
|
||||
import os
|
||||
for var in ("PRAISONAI_AUXILIARY_MODEL", "OPENAI_MODEL_NAME"):
|
||||
value = (os.environ.get(var) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return DEFAULT_AUXILIARY_MODEL
|
||||
|
||||
|
||||
def _entry_point_matchers():
|
||||
"""Yield (provider_id, matcher) pairs registered by third-party plugins.
|
||||
|
||||
Isolated so a broken plugin cannot take down provider resolution: any
|
||||
import/metadata failure yields nothing and falls back to built-ins.
|
||||
"""
|
||||
try:
|
||||
from importlib.metadata import entry_points
|
||||
except Exception: # pragma: no cover - importlib.metadata always present on 3.8+
|
||||
return
|
||||
|
||||
try:
|
||||
eps = entry_points(group="praisonaiagents.model_providers")
|
||||
except TypeError:
|
||||
# Python 3.9 returns a dict-like object without the group kwarg.
|
||||
eps = entry_points().get("praisonaiagents.model_providers", [])
|
||||
except Exception:
|
||||
return
|
||||
|
||||
for ep in eps:
|
||||
try:
|
||||
matcher = ep.load()
|
||||
except Exception:
|
||||
continue
|
||||
yield ep.name, matcher
|
||||
|
||||
|
||||
def resolve_provider(model_name: str) -> Optional[str]:
|
||||
"""Return the canonical provider id for ``model_name``, or None.
|
||||
|
||||
Precedence:
|
||||
1. Explicit litellm-style prefix wins (``openai/gpt-4o`` -> ``openai``).
|
||||
2. Entry-point registered matchers (third-party vendors / overrides).
|
||||
3. Built-in matchers.
|
||||
"""
|
||||
if not model_name:
|
||||
return None
|
||||
|
||||
# 1. Explicit provider prefix.
|
||||
if "/" in model_name:
|
||||
return model_name.split("/", 1)[0].lower()
|
||||
|
||||
lower = model_name.lower()
|
||||
|
||||
# 2. Third-party / override matchers.
|
||||
for provider, matcher in _entry_point_matchers():
|
||||
try:
|
||||
if matcher(lower):
|
||||
return provider
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 3. Built-ins.
|
||||
for provider, matcher in _BUILTINS.items():
|
||||
if matcher(lower):
|
||||
return provider
|
||||
|
||||
return None
|
||||
@@ -21,7 +21,6 @@ import inspect
|
||||
from pathlib import Path
|
||||
|
||||
from ..errors import ToolExecutionError
|
||||
from ..model_harness.guard import check_model_request
|
||||
|
||||
# Graceful "wrap-up" instruction injected when the step budget is nearly
|
||||
# exhausted, so the model produces a coherent final answer instead of being
|
||||
@@ -247,15 +246,6 @@ def process_stream_chunks(chunks):
|
||||
|
||||
# First pass: Get initial tool call data
|
||||
for chunk in chunks:
|
||||
# Capture token usage from the final usage-only chunk. With
|
||||
# stream_options={"include_usage": True} the provider sends a
|
||||
# trailing chunk carrying `usage` and an empty `choices` list, so
|
||||
# this must run before the `choices` guard below skips it.
|
||||
chunk_usage = getattr(chunk, "usage", None)
|
||||
if chunk_usage:
|
||||
completion_tokens = getattr(chunk_usage, "completion_tokens", 0) or completion_tokens
|
||||
prompt_tokens = getattr(chunk_usage, "prompt_tokens", 0) or prompt_tokens
|
||||
|
||||
if not hasattr(chunk, "choices") or not chunk.choices:
|
||||
continue
|
||||
|
||||
@@ -423,15 +413,7 @@ class OpenAIClient:
|
||||
|
||||
@property
|
||||
def sync_client(self):
|
||||
"""Get the synchronous OpenAI client (lazy initialization).
|
||||
|
||||
Raises:
|
||||
ModelRequestBlocked: If a test suite turned real model requests off
|
||||
via ``praisonaiagents.model_harness.allow_model_requests(False)``.
|
||||
Every request this class makes goes through this property, so
|
||||
guarding it here covers the whole OpenAI-native path.
|
||||
"""
|
||||
check_model_request(getattr(self, "model", None), "openai.chat.completions")
|
||||
"""Get the synchronous OpenAI client (lazy initialization)."""
|
||||
if self._sync_client is None:
|
||||
OpenAI, _ = _get_openai_classes()
|
||||
client_kwargs = {"api_key": self.api_key, "base_url": self.base_url}
|
||||
@@ -442,13 +424,7 @@ class OpenAIClient:
|
||||
|
||||
@property
|
||||
def async_client(self):
|
||||
"""Get the asynchronous OpenAI client (lazy initialization).
|
||||
|
||||
Raises:
|
||||
ModelRequestBlocked: If a test suite turned real model requests off
|
||||
via ``praisonaiagents.model_harness.allow_model_requests(False)``.
|
||||
"""
|
||||
check_model_request(getattr(self, "model", None), "openai.chat.completions")
|
||||
"""Get the asynchronous OpenAI client (lazy initialization)."""
|
||||
if self._async_client is None:
|
||||
_, AsyncOpenAI = _get_openai_classes()
|
||||
client_kwargs = {"api_key": self.api_key, "base_url": self.base_url}
|
||||
@@ -868,24 +844,6 @@ class OpenAIClient:
|
||||
part.get("image_url")
|
||||
)
|
||||
responses_content.append(item)
|
||||
elif part_type == "file":
|
||||
# Chat Completions ``{"type": "file", "file": {...}}`` (used for
|
||||
# PDF attachments) becomes a Responses API ``input_file`` part.
|
||||
# Passing it through untranslated would be rejected by the API.
|
||||
file_spec = part.get("file")
|
||||
if not isinstance(file_spec, dict):
|
||||
responses_content.append(part)
|
||||
continue
|
||||
file_item: Dict[str, Any] = {"type": "input_file"}
|
||||
for src, dst in (
|
||||
("filename", "filename"),
|
||||
("file_data", "file_data"),
|
||||
("file_id", "file_id"),
|
||||
("file_url", "file_url"),
|
||||
):
|
||||
if file_spec.get(src):
|
||||
file_item[dst] = file_spec[src]
|
||||
responses_content.append(file_item)
|
||||
else:
|
||||
responses_content.append(part)
|
||||
return responses_content
|
||||
@@ -1078,11 +1036,6 @@ class OpenAIClient:
|
||||
# Fall through to Chat Completions streaming
|
||||
|
||||
# ── Chat Completions streaming path ─────────────────────────
|
||||
# Request the trailing usage-only chunk for every streamed call so
|
||||
# process_stream_chunks records real token usage even when stream
|
||||
# events are not being emitted. Respect a caller-supplied
|
||||
# stream_options so we never clobber an explicit choice.
|
||||
stream_options = kwargs.pop("stream_options", {"include_usage": True})
|
||||
# Create the response stream
|
||||
response_stream = self.sync_client.chat.completions.create(
|
||||
model=model,
|
||||
@@ -1090,7 +1043,7 @@ class OpenAIClient:
|
||||
temperature=temperature,
|
||||
tools=tools if tools else None,
|
||||
stream=True,
|
||||
stream_options=stream_options,
|
||||
stream_options={"include_usage": True} if _emit else None,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@@ -1352,11 +1305,6 @@ class OpenAIClient:
|
||||
# Fall through to Chat Completions streaming
|
||||
|
||||
# ── Chat Completions streaming path ─────────────────────────
|
||||
# Request the trailing usage-only chunk for every streamed call so
|
||||
# process_stream_chunks records real token usage even when stream
|
||||
# events are not being emitted. Respect a caller-supplied
|
||||
# stream_options so we never clobber an explicit choice.
|
||||
stream_options = kwargs.pop("stream_options", {"include_usage": True})
|
||||
# Create the response stream
|
||||
response_stream = await self.async_client.chat.completions.create(
|
||||
model=model,
|
||||
@@ -1364,7 +1312,7 @@ class OpenAIClient:
|
||||
temperature=temperature,
|
||||
tools=tools if tools else None,
|
||||
stream=True,
|
||||
stream_options=stream_options,
|
||||
stream_options={"include_usage": True} if _emit else None,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@@ -2157,15 +2105,6 @@ class OpenAIClient:
|
||||
break
|
||||
# G2: Mid-run steering - inject pending steering notes before next call.
|
||||
_inject_steering(messages)
|
||||
# Trigger LLM callback for status/trace output (mirrors the sync
|
||||
# tool-loop at chat_completion_with_tools so async runs emit the same
|
||||
# llm_start/llm_end lifecycle events consumed by trace/status output).
|
||||
from ..main import execute_sync_callback
|
||||
execute_sync_callback('llm_start', model=model, agent_name=agent_name)
|
||||
# Per-iteration start so latency_ms measures THIS model request, not
|
||||
# the cumulative time since the method began (each tool-loop step is
|
||||
# a separate billed call).
|
||||
iteration_start_time = time.time()
|
||||
# Graceful wrap-up on the final permitted step.
|
||||
if not _wrapup_injected and max_iterations > 1 and iteration_count == max_iterations - 1:
|
||||
messages.append({
|
||||
@@ -2243,17 +2182,6 @@ class OpenAIClient:
|
||||
)
|
||||
|
||||
if not final_response:
|
||||
# Pair the llm_start emitted above with an llm_end even on the
|
||||
# failure path so traces/status telemetry never carry an
|
||||
# unmatched lifecycle event when a provider returns nothing.
|
||||
execute_sync_callback(
|
||||
'llm_end',
|
||||
model=model,
|
||||
tokens_in=0,
|
||||
tokens_out=0,
|
||||
cost=None,
|
||||
latency_ms=(time.time() - iteration_start_time) * 1000
|
||||
)
|
||||
return None
|
||||
|
||||
# Record usage for THIS billed completion. Every tool-loop iteration
|
||||
@@ -2262,29 +2190,6 @@ class OpenAIClient:
|
||||
# from session totals and by_model/by_agent rollups (Issue #3933).
|
||||
self._track_token_usage(final_response, model, agent_name)
|
||||
|
||||
# Trigger llm_end callback with cost/latency metrics — mirrors the
|
||||
# sync tool-loop so async agents also emit LLM spans and cost figures
|
||||
# for --trace/status output (otherwise async cost tracking goes dark).
|
||||
llm_latency_ms = (time.time() - iteration_start_time) * 1000
|
||||
usage = getattr(final_response, 'usage', None)
|
||||
tokens_in = getattr(usage, 'prompt_tokens', 0) if usage else 0
|
||||
tokens_out = getattr(usage, 'completion_tokens', 0) if usage else 0
|
||||
cost = None
|
||||
try:
|
||||
from ._cost import calculate_cost
|
||||
cost = calculate_cost(final_response, model=model)
|
||||
except Exception as e:
|
||||
# Cost calculation is optional - log for debugging
|
||||
get_logger(__name__).debug(f"Cost calculation failed: {e}")
|
||||
execute_sync_callback(
|
||||
'llm_end',
|
||||
model=model,
|
||||
tokens_in=tokens_in,
|
||||
tokens_out=tokens_out,
|
||||
cost=cost,
|
||||
latency_ms=llm_latency_ms
|
||||
)
|
||||
|
||||
# Check for tool calls
|
||||
if not final_response.choices or final_response.choices[0].message is None:
|
||||
raise ValueError("LLM returned empty or filtered response")
|
||||
|
||||
@@ -271,16 +271,15 @@ class LLMProviderAdapterProtocol(Protocol):
|
||||
return False
|
||||
|
||||
def should_summarize_tools(self, iter_count: int) -> bool:
|
||||
return iter_count >= 1 # Ollama-specific threshold
|
||||
return iter_count >= 3 # Ollama-specific threshold
|
||||
|
||||
def handle_empty_response_with_tools(self, state) -> bool:
|
||||
# Ollama tends to return empty content after tool execution
|
||||
return bool(state.get('accumulated_tool_results')
|
||||
and not state.get('response_text', '').strip())
|
||||
def format_tools(self, tools) -> list:
|
||||
return tools # No special formatting needed
|
||||
|
||||
def recover_tool_calls_from_text(self, response_text, tools):
|
||||
# Small local models often emit the call as JSON content
|
||||
return _recover_json_tool_calls(response_text, tools)
|
||||
def post_tool_iteration(self, state) -> None:
|
||||
if state.get('empty_response') and state.get('tools'):
|
||||
# Add Ollama-specific tool summary
|
||||
state['summary'] = "Tool results processed"
|
||||
```
|
||||
"""
|
||||
|
||||
@@ -292,8 +291,17 @@ class LLMProviderAdapterProtocol(Protocol):
|
||||
"""Check if tools should be summarized at this iteration count."""
|
||||
...
|
||||
|
||||
def format_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Format tools for this provider's requirements."""
|
||||
...
|
||||
|
||||
def post_tool_iteration(self, state: Dict[str, Any]) -> None:
|
||||
"""Handle provider-specific post-tool processing."""
|
||||
...
|
||||
|
||||
def supports_structured_output(self) -> bool:
|
||||
"""Check if provider supports structured JSON output."""
|
||||
...
|
||||
|
||||
def supports_streaming(self) -> bool:
|
||||
"""Check if provider supports streaming responses."""
|
||||
@@ -303,6 +311,9 @@ class LLMProviderAdapterProtocol(Protocol):
|
||||
"""Check if provider supports streaming with tools enabled."""
|
||||
...
|
||||
|
||||
def get_max_iteration_threshold(self) -> int:
|
||||
"""Get provider-specific maximum iteration count."""
|
||||
...
|
||||
|
||||
def format_tool_result_message(self, function_name: str, tool_result: Any, tool_call_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Format tool result message for this provider's requirements."""
|
||||
@@ -316,13 +327,25 @@ class LLMProviderAdapterProtocol(Protocol):
|
||||
"""Get provider-specific default settings."""
|
||||
...
|
||||
|
||||
def parse_tool_calls(self, raw_response: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Parse tool calls from provider-specific response format."""
|
||||
...
|
||||
|
||||
def should_skip_streaming_with_tools(self) -> bool:
|
||||
"""Check if provider should skip streaming when tools are present."""
|
||||
...
|
||||
|
||||
def recover_tool_calls_from_text(self, response_text: str, tools: List[Dict[str, Any]]) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Attempt to recover tool calls from response text for providers that don't format them properly."""
|
||||
...
|
||||
|
||||
def inject_cache_control(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Inject provider-specific cache control headers."""
|
||||
...
|
||||
|
||||
def extract_reasoning_tokens(self, response: Dict[str, Any]) -> int:
|
||||
"""Extract reasoning token count from provider-specific response."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
Streaming Protocol Extension - implements Gap 3 from Issue #1392.
|
||||
|
||||
This module completes the streaming adapter pattern by extending LLM adapters
|
||||
with comprehensive streaming capabilities, removing provider-specific conditionals
|
||||
from the core LLM loop.
|
||||
|
||||
Architecture:
|
||||
- Extends existing LLMProviderAdapterProtocol with streaming interface
|
||||
- Each adapter owns its streaming decisions and error recovery
|
||||
- Core llm.py contains no provider-name conditionals for streaming
|
||||
- StreamEvent protocol is used consistently for all streaming deltas
|
||||
- Observable fallback events instead of silent streaming->non-streaming fallback
|
||||
|
||||
Design principles:
|
||||
- Protocol-driven: complete the existing adapter pattern
|
||||
- DRY: eliminate scattered streaming conditionals
|
||||
- Extensibility: new providers = new adapter only (no core changes)
|
||||
- Observable: emit events for streaming capability decisions
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List, AsyncIterator, Union, Protocol
|
||||
from abc import abstractmethod
|
||||
from ..streaming.events import StreamEvent, StreamEventType, StreamEventEmitter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StreamingCapableAdapter(Protocol):
|
||||
"""
|
||||
Extended protocol for LLM adapters with comprehensive streaming support.
|
||||
|
||||
This extends the base LLMProviderAdapterProtocol with streaming-specific
|
||||
methods that encapsulate all provider quirks and decisions.
|
||||
"""
|
||||
|
||||
def can_stream(self, *, tools: Optional[List[Dict[str, Any]]] = None, **kwargs) -> bool:
|
||||
"""
|
||||
Determine if this provider can stream in the requested configuration.
|
||||
|
||||
Args:
|
||||
tools: Optional list of tools that will be used
|
||||
**kwargs: Additional LLM parameters that might affect streaming
|
||||
|
||||
Returns:
|
||||
True if streaming is possible with these parameters, False otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
async def stream_completion(
|
||||
self,
|
||||
*,
|
||||
messages: List[Dict[str, Any]],
|
||||
model: str,
|
||||
temperature: float = 1.0,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
stream_emitter: Optional[StreamEventEmitter] = None,
|
||||
**kwargs
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
"""
|
||||
Stream LLM completion using this provider's native streaming API.
|
||||
|
||||
Args:
|
||||
messages: Chat messages
|
||||
model: Model identifier
|
||||
temperature: Sampling temperature
|
||||
tools: Optional tools for function calling
|
||||
stream_emitter: Optional emitter for additional event handling
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Yields:
|
||||
StreamEvent instances with standardized streaming data
|
||||
|
||||
Raises:
|
||||
StreamingNotSupportedError: If streaming not available in this configuration
|
||||
StreamingError: If streaming fails and cannot recover
|
||||
"""
|
||||
...
|
||||
|
||||
def is_stream_error_recoverable(self, exc: Exception) -> bool:
|
||||
"""
|
||||
Determine if a streaming error can be recovered by falling back to non-streaming.
|
||||
|
||||
Args:
|
||||
exc: Exception that occurred during streaming
|
||||
|
||||
Returns:
|
||||
True if fallback to non-streaming should be attempted, False otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
def create_stream_unavailable_event(self, reason: str, **metadata) -> StreamEvent:
|
||||
"""
|
||||
Create a StreamEvent indicating why streaming is not available.
|
||||
|
||||
This enables observable fallback instead of silent streaming -> non-streaming.
|
||||
|
||||
Args:
|
||||
reason: Human-readable reason why streaming is unavailable
|
||||
**metadata: Additional context for the unavailability
|
||||
|
||||
Returns:
|
||||
StreamEvent with type STREAM_UNAVAILABLE and reason
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class StreamingNotSupportedError(Exception):
|
||||
"""Raised when streaming is not supported in the current configuration."""
|
||||
pass
|
||||
|
||||
|
||||
class StreamingError(Exception):
|
||||
"""Raised when streaming fails unrecoverably."""
|
||||
pass
|
||||
|
||||
|
||||
class DefaultStreamingAdapter:
|
||||
"""
|
||||
Default streaming adapter with sensible fallbacks.
|
||||
|
||||
Provides baseline streaming support for most providers that follow
|
||||
OpenAI-style streaming APIs.
|
||||
"""
|
||||
|
||||
def can_stream(self, *, tools: Optional[List[Dict[str, Any]]] = None, **kwargs) -> bool:
|
||||
"""Most providers support streaming, even with tools."""
|
||||
return True
|
||||
|
||||
async def stream_completion(
|
||||
self,
|
||||
*,
|
||||
messages: List[Dict[str, Any]],
|
||||
model: str,
|
||||
temperature: float = 1.0,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
stream_emitter: Optional[StreamEventEmitter] = None,
|
||||
**kwargs
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
"""
|
||||
Default streaming implementation using litellm.
|
||||
|
||||
This is the baseline implementation that most providers can use.
|
||||
Provider-specific adapters override this for custom behavior.
|
||||
"""
|
||||
import litellm
|
||||
import time
|
||||
|
||||
# Emit request start event
|
||||
start_time = time.perf_counter()
|
||||
request_start_event = StreamEvent(
|
||||
type=StreamEventType.REQUEST_START,
|
||||
timestamp=start_time,
|
||||
metadata={"model": model, "provider": "default"}
|
||||
)
|
||||
|
||||
# Yield the event to consumers and emit to optional stream_emitter
|
||||
yield request_start_event
|
||||
if stream_emitter:
|
||||
await stream_emitter.emit_async(request_start_event)
|
||||
|
||||
try:
|
||||
# Build completion parameters
|
||||
completion_params = {
|
||||
"messages": messages,
|
||||
"model": model,
|
||||
"temperature": temperature,
|
||||
"stream": True,
|
||||
**kwargs
|
||||
}
|
||||
|
||||
if tools:
|
||||
completion_params["tools"] = tools
|
||||
|
||||
response_text = ""
|
||||
tool_calls = []
|
||||
first_token_emitted = False
|
||||
|
||||
# Stream the completion
|
||||
async for chunk in litellm.acompletion(**completion_params):
|
||||
if chunk and chunk.choices and chunk.choices[0].delta:
|
||||
delta = chunk.choices[0].delta
|
||||
|
||||
# Handle text content
|
||||
if delta.content:
|
||||
if not first_token_emitted:
|
||||
yield StreamEvent(
|
||||
type=StreamEventType.FIRST_TOKEN,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta.content
|
||||
)
|
||||
first_token_emitted = True
|
||||
else:
|
||||
yield StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta.content
|
||||
)
|
||||
response_text += delta.content
|
||||
|
||||
# Handle tool calls
|
||||
if delta.tool_calls:
|
||||
for tool_call in delta.tool_calls:
|
||||
yield StreamEvent(
|
||||
type=StreamEventType.DELTA_TOOL_CALL,
|
||||
timestamp=time.perf_counter(),
|
||||
tool_call={
|
||||
"id": tool_call.id,
|
||||
"name": tool_call.function.name if tool_call.function else None,
|
||||
"arguments": tool_call.function.arguments if tool_call.function else None
|
||||
}
|
||||
)
|
||||
tool_calls.append(tool_call)
|
||||
|
||||
# Emit stream completion
|
||||
yield StreamEvent(
|
||||
type=StreamEventType.STREAM_END,
|
||||
timestamp=time.perf_counter(),
|
||||
metadata={
|
||||
"response_text": response_text,
|
||||
"tool_calls": [tc.to_dict() if hasattr(tc, 'to_dict') else tc for tc in tool_calls]
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Emit error event
|
||||
yield StreamEvent(
|
||||
type=StreamEventType.ERROR,
|
||||
timestamp=time.perf_counter(),
|
||||
error=str(e),
|
||||
metadata={"exception_type": type(e).__name__}
|
||||
)
|
||||
|
||||
if not self.is_stream_error_recoverable(e):
|
||||
raise StreamingError(f"Unrecoverable streaming error: {e}") from e
|
||||
|
||||
def is_stream_error_recoverable(self, exc: Exception) -> bool:
|
||||
"""
|
||||
Default error recovery logic.
|
||||
|
||||
Most JSON parsing errors and timeout errors are recoverable.
|
||||
"""
|
||||
error_str = str(exc).lower()
|
||||
recoverable_patterns = [
|
||||
"json",
|
||||
"parsing",
|
||||
"timeout",
|
||||
"connection"
|
||||
]
|
||||
return any(pattern in error_str for pattern in recoverable_patterns)
|
||||
|
||||
def create_stream_unavailable_event(self, reason: str, **metadata) -> StreamEvent:
|
||||
"""Create standard stream unavailable event."""
|
||||
import time
|
||||
return StreamEvent(
|
||||
type=StreamEventType.STREAM_UNAVAILABLE,
|
||||
timestamp=time.perf_counter(),
|
||||
error=f"Streaming unavailable: {reason}",
|
||||
metadata={
|
||||
"reason": reason,
|
||||
**metadata
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class OllamaStreamingAdapter(DefaultStreamingAdapter):
|
||||
"""
|
||||
Ollama-specific streaming adapter.
|
||||
|
||||
Handles Ollama's streaming limitations:
|
||||
- Doesn't support streaming with tools reliably
|
||||
- Has specific error patterns that need different recovery
|
||||
"""
|
||||
|
||||
def can_stream(self, *, tools: Optional[List[Dict[str, Any]]] = None, **kwargs) -> bool:
|
||||
"""Ollama can stream, but not reliably with tools."""
|
||||
if tools:
|
||||
return False # Disable streaming when tools are present
|
||||
return True
|
||||
|
||||
def is_stream_error_recoverable(self, exc: Exception) -> bool:
|
||||
"""Ollama-specific error recovery patterns."""
|
||||
error_str = str(exc).lower()
|
||||
ollama_recoverable = [
|
||||
"json",
|
||||
"parsing",
|
||||
"connection reset",
|
||||
"incomplete",
|
||||
"tool" # Tool-related errors are often recoverable by disabling tools
|
||||
]
|
||||
return any(pattern in error_str for pattern in ollama_recoverable)
|
||||
|
||||
|
||||
class AnthropicStreamingAdapter(DefaultStreamingAdapter):
|
||||
"""
|
||||
Anthropic/Claude streaming adapter.
|
||||
|
||||
Handles the known litellm/async-generator bug with Anthropic streaming.
|
||||
"""
|
||||
|
||||
def can_stream(self, *, tools: Optional[List[Dict[str, Any]]] = None, **kwargs) -> bool:
|
||||
"""
|
||||
Anthropic streaming is disabled due to litellm bug.
|
||||
|
||||
Issue: litellm.acompletion with stream=True returns a ModelResponse (not async generator)
|
||||
for Anthropic in the async path, causing 'async for requires __aiter__' error.
|
||||
"""
|
||||
return False # Disable until litellm bug is fixed
|
||||
|
||||
def create_stream_unavailable_event(self, reason: Optional[str] = None, **metadata) -> StreamEvent:
|
||||
"""Create Anthropic-specific unavailable event with bug details."""
|
||||
return super().create_stream_unavailable_event(
|
||||
reason or "litellm async generator bug",
|
||||
provider="anthropic",
|
||||
# TODO: Add actual bug reference when litellm issue is filed
|
||||
**metadata
|
||||
)
|
||||
|
||||
|
||||
class GeminiStreamingAdapter(DefaultStreamingAdapter):
|
||||
"""
|
||||
Google Gemini streaming adapter.
|
||||
|
||||
Handles Gemini's streaming issues with tools and JSON parsing.
|
||||
"""
|
||||
|
||||
def can_stream(self, *, tools: Optional[List[Dict[str, Any]]] = None, **kwargs) -> bool:
|
||||
"""Gemini has issues with streaming + tools."""
|
||||
if tools:
|
||||
return False # Disable streaming when tools are present due to JSON parsing issues
|
||||
return True
|
||||
|
||||
def create_stream_unavailable_event(self, reason: Optional[str] = None, **metadata) -> StreamEvent:
|
||||
"""Create Gemini-specific unavailable event."""
|
||||
return super().create_stream_unavailable_event(
|
||||
reason or "JSON parsing issues with tools",
|
||||
provider="gemini",
|
||||
**metadata
|
||||
)
|
||||
|
||||
|
||||
# Streaming adapter registry
|
||||
_streaming_adapters: Dict[str, StreamingCapableAdapter] = {}
|
||||
|
||||
# Register core streaming adapters
|
||||
_streaming_adapters['default'] = DefaultStreamingAdapter()
|
||||
_streaming_adapters['ollama'] = OllamaStreamingAdapter()
|
||||
_streaming_adapters['anthropic'] = AnthropicStreamingAdapter()
|
||||
_streaming_adapters['claude'] = AnthropicStreamingAdapter() # Alias
|
||||
_streaming_adapters['gemini'] = GeminiStreamingAdapter()
|
||||
|
||||
|
||||
def get_streaming_adapter(provider_name: str) -> StreamingCapableAdapter:
|
||||
"""
|
||||
Get streaming adapter for provider with fallback to default.
|
||||
|
||||
Args:
|
||||
provider_name: Provider name (e.g., "anthropic", "ollama", "gemini")
|
||||
|
||||
Returns:
|
||||
StreamingCapableAdapter instance
|
||||
"""
|
||||
name_lower = provider_name.lower()
|
||||
|
||||
# Exact match first
|
||||
if name_lower in _streaming_adapters:
|
||||
return _streaming_adapters[name_lower]
|
||||
|
||||
# Provider prefixes or substrings
|
||||
if "ollama" in name_lower:
|
||||
return _streaming_adapters["ollama"]
|
||||
if "claude" in name_lower or "anthropic" in name_lower:
|
||||
return _streaming_adapters["anthropic"]
|
||||
if "gemini" in name_lower:
|
||||
return _streaming_adapters["gemini"]
|
||||
|
||||
return _streaming_adapters["default"]
|
||||
|
||||
|
||||
def add_streaming_adapter(name: str, adapter: StreamingCapableAdapter) -> None:
|
||||
"""Register a custom streaming adapter."""
|
||||
_streaming_adapters[name] = adapter
|
||||
|
||||
|
||||
def list_streaming_adapters() -> List[str]:
|
||||
"""List all registered streaming adapter names."""
|
||||
return sorted(_streaming_adapters.keys())
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Local model runtime resolution: what is running, what it can do, what it gets wrong.
|
||||
|
||||
Every local runtime worth supporting already speaks OpenAI over HTTP, and litellm
|
||||
already owns that transport. This package is therefore NOT a transport and NOT a
|
||||
client. It answers three questions and returns the answers as frozen data:
|
||||
|
||||
1. What is running on this machine? discover()
|
||||
2. What can that model actually do? capabilities via resolve()
|
||||
3. What will it silently get wrong? quirktable
|
||||
|
||||
Two properties are load-bearing and are enforced by tests:
|
||||
|
||||
* It is a dependency sink. Every module here imports only the standard library
|
||||
and its siblings -- nothing from praisonaiagents. That is what lets modules
|
||||
which currently bypass the LLM layer adopt it without deepening the 26
|
||||
mutually-importing subpackage pairs the package already has.
|
||||
|
||||
* It produces data, never behaviour. No chat call, no retry, no request
|
||||
mutation, no process management, no disk writes. Compensating behaviour
|
||||
belongs in llm/adapters/.
|
||||
"""
|
||||
|
||||
from .capabilities import (ApiStyle, Cap, DEFAULT_CAPS, Evidence, LocalEngine,
|
||||
default_caps, parse_llama_cpp_props,
|
||||
parse_lm_studio_models, parse_ollama_capabilities,
|
||||
parse_openai_models)
|
||||
from .discover import (DEFAULT_PROBE_TIMEOUT, DEFAULT_TOTAL_BUDGET, Discovery,
|
||||
HttpReply, LOOPBACK_HOST, PROBES, ProbeSpec, Rule, RuleKind,
|
||||
discover, models, probe_endpoint, probe_table)
|
||||
from .quirktable import (NOTES, Quirk, QuirkNote, Severity, all_notes, note,
|
||||
notes_for, quirks_for, silent_quirks)
|
||||
from .errors import (EngineUnreachableError, HostHeaderRejectedError,
|
||||
InvalidLocalSpecError, LocalError, ModelNotAvailableError,
|
||||
NoLocalEngineError)
|
||||
from .embed import (
|
||||
ENV_EMBED_MODEL,
|
||||
PREFERRED_EMBED_MODELS,
|
||||
local_embedder_config,
|
||||
select_embedding_model,
|
||||
)
|
||||
from .target import (DEFAULT_API_KEY, ENV_BASE_URL, ENV_ENGINE, ENV_MODEL,
|
||||
LocalTarget, build_target, litellm_model_for,
|
||||
parse_ollama_host, parse_spec, select_model)
|
||||
from .resolve import cache_info, clear_cache, resolve, resolve_or_none
|
||||
|
||||
__all__ = [
|
||||
"ENV_EMBED_MODEL",
|
||||
"PREFERRED_EMBED_MODELS",
|
||||
"local_embedder_config",
|
||||
"select_embedding_model",
|
||||
"LocalEngine", "ApiStyle", "Cap", "Evidence", "Severity", "Quirk",
|
||||
"DEFAULT_CAPS", "default_caps", "parse_ollama_capabilities",
|
||||
"parse_llama_cpp_props", "parse_lm_studio_models", "parse_openai_models",
|
||||
"Discovery", "HttpReply", "ProbeSpec", "Rule", "RuleKind", "PROBES",
|
||||
"LOOPBACK_HOST", "DEFAULT_PROBE_TIMEOUT", "DEFAULT_TOTAL_BUDGET",
|
||||
"discover", "probe_endpoint", "probe_table", "models",
|
||||
"QuirkNote", "NOTES", "note", "all_notes", "notes_for", "quirks_for",
|
||||
"silent_quirks",
|
||||
"LocalTarget", "resolve", "resolve_or_none", "build_target",
|
||||
"litellm_model_for", "parse_spec", "parse_ollama_host", "select_model",
|
||||
"clear_cache", "cache_info", "DEFAULT_API_KEY", "ENV_BASE_URL", "ENV_ENGINE",
|
||||
"ENV_MODEL",
|
||||
"LocalError", "NoLocalEngineError", "EngineUnreachableError",
|
||||
"HostHeaderRejectedError", "ModelNotAvailableError", "InvalidLocalSpecError",
|
||||
]
|
||||
@@ -1,168 +0,0 @@
|
||||
"""Local-engine vocabulary and capability resolution.
|
||||
|
||||
Layer 0 of ``praisonaiagents.local``: standard library only, no sibling imports
|
||||
beyond none at all. This module owns the shared vocabulary (``LocalEngine``,
|
||||
``ApiStyle``, ``Cap``, ``Evidence``) as well as the capability parsers, because
|
||||
that is the only placement that keeps the package's import graph acyclic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping
|
||||
|
||||
__all__ = [
|
||||
"LocalEngine", "ApiStyle", "Cap", "Evidence",
|
||||
"DEFAULT_CAPS", "default_caps",
|
||||
"parse_ollama_capabilities", "parse_llama_cpp_props",
|
||||
"parse_lm_studio_models", "parse_openai_models",
|
||||
]
|
||||
|
||||
|
||||
class LocalEngine(str, Enum):
|
||||
"""A local model server. Never called "runtime": that name is taken."""
|
||||
|
||||
OLLAMA = "ollama"
|
||||
LLAMA_CPP = "llama_cpp"
|
||||
MLX_LM = "mlx_lm"
|
||||
LM_STUDIO = "lm_studio"
|
||||
VLLM = "vllm"
|
||||
TRANSFORMERS_SERVE = "transformers_serve"
|
||||
LLAMAFILE = "llamafile"
|
||||
LOCALAI = "localai"
|
||||
RAMALAMA = "ramalama"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class ApiStyle(str, Enum):
|
||||
OPENAI_CHAT = "openai_chat"
|
||||
OPENAI_COMPLETIONS = "openai_completions"
|
||||
OLLAMA_NATIVE = "ollama_native"
|
||||
|
||||
|
||||
class Cap(str, Enum):
|
||||
CHAT = "chat"
|
||||
COMPLETION = "completion"
|
||||
TOOLS = "tools"
|
||||
VISION = "vision"
|
||||
AUDIO_IN = "audio_in"
|
||||
THINKING = "thinking"
|
||||
JSON_OBJECT = "json_object"
|
||||
JSON_SCHEMA = "json_schema"
|
||||
STREAMING = "streaming"
|
||||
STREAMING_WITH_TOOLS = "streaming_with_tools"
|
||||
PARALLEL_TOOL_CALLS = "parallel_tool_calls"
|
||||
EMBEDDINGS_ENDPOINT = "embeddings_endpoint"
|
||||
|
||||
|
||||
class Evidence(str, Enum):
|
||||
"""Where a field's value came from. Keeps guesses distinguishable from facts."""
|
||||
|
||||
SERVER = "server"
|
||||
TABLE = "table"
|
||||
ENV = "env"
|
||||
SPEC = "spec"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
# Static per-engine floor. Cap.TOOLS and Cap.VISION are deliberately absent from
|
||||
# every row: claiming them without server evidence is exactly the silent failure
|
||||
# this package exists to prevent.
|
||||
DEFAULT_CAPS: Mapping[LocalEngine, frozenset] = {
|
||||
LocalEngine.OLLAMA: frozenset({
|
||||
Cap.CHAT, Cap.COMPLETION, Cap.STREAMING, Cap.JSON_OBJECT,
|
||||
Cap.JSON_SCHEMA, Cap.EMBEDDINGS_ENDPOINT}),
|
||||
LocalEngine.LLAMA_CPP: frozenset({
|
||||
Cap.CHAT, Cap.COMPLETION, Cap.STREAMING, Cap.JSON_SCHEMA,
|
||||
Cap.JSON_OBJECT, Cap.EMBEDDINGS_ENDPOINT}),
|
||||
LocalEngine.MLX_LM: frozenset({Cap.CHAT, Cap.COMPLETION, Cap.STREAMING}),
|
||||
LocalEngine.LM_STUDIO: frozenset({
|
||||
Cap.CHAT, Cap.COMPLETION, Cap.STREAMING, Cap.JSON_SCHEMA,
|
||||
Cap.JSON_OBJECT, Cap.EMBEDDINGS_ENDPOINT}),
|
||||
LocalEngine.VLLM: frozenset({
|
||||
Cap.CHAT, Cap.COMPLETION, Cap.STREAMING, Cap.JSON_SCHEMA, Cap.JSON_OBJECT,
|
||||
Cap.EMBEDDINGS_ENDPOINT, Cap.PARALLEL_TOOL_CALLS}),
|
||||
LocalEngine.TRANSFORMERS_SERVE: frozenset({Cap.CHAT, Cap.STREAMING}),
|
||||
LocalEngine.LLAMAFILE: frozenset({Cap.CHAT, Cap.COMPLETION, Cap.STREAMING}),
|
||||
LocalEngine.LOCALAI: frozenset({Cap.CHAT, Cap.COMPLETION, Cap.STREAMING}),
|
||||
LocalEngine.RAMALAMA: frozenset({Cap.CHAT, Cap.COMPLETION, Cap.STREAMING}),
|
||||
LocalEngine.UNKNOWN: frozenset({Cap.CHAT, Cap.STREAMING}),
|
||||
}
|
||||
|
||||
|
||||
def default_caps(engine) -> frozenset:
|
||||
"""Return the static per-engine capability floor (no network, Evidence.TABLE)."""
|
||||
try:
|
||||
return DEFAULT_CAPS[LocalEngine(engine)]
|
||||
except (KeyError, ValueError):
|
||||
return DEFAULT_CAPS[LocalEngine.UNKNOWN]
|
||||
|
||||
|
||||
_OLLAMA_CAP_MAP = {
|
||||
"completion": (Cap.COMPLETION, Cap.CHAT),
|
||||
"tools": (Cap.TOOLS,),
|
||||
"vision": (Cap.VISION,),
|
||||
"thinking": (Cap.THINKING,),
|
||||
"embedding": (Cap.EMBEDDINGS_ENDPOINT,),
|
||||
"audio": (Cap.AUDIO_IN,),
|
||||
}
|
||||
|
||||
|
||||
def parse_ollama_capabilities(payload: Mapping[str, Any]) -> frozenset:
|
||||
"""Map an Ollama /api/show or /api/tags entry's ``capabilities`` list onto Cap.
|
||||
|
||||
Unknown strings ("insert", anything new) are ignored rather than raised on,
|
||||
so a newer Ollama cannot break resolution.
|
||||
"""
|
||||
out = set()
|
||||
try:
|
||||
for name in payload.get("capabilities") or ():
|
||||
out.update(_OLLAMA_CAP_MAP.get(str(name).lower(), ()))
|
||||
except AttributeError:
|
||||
return frozenset()
|
||||
return frozenset(out)
|
||||
|
||||
|
||||
def parse_llama_cpp_props(payload: Mapping[str, Any]) -> frozenset:
|
||||
"""Map a llama.cpp GET /props reply onto Cap."""
|
||||
out = {Cap.CHAT, Cap.COMPLETION, Cap.STREAMING}
|
||||
try:
|
||||
modalities = payload.get("modalities") or {}
|
||||
if modalities.get("vision"):
|
||||
out.add(Cap.VISION)
|
||||
if modalities.get("audio"):
|
||||
out.add(Cap.AUDIO_IN)
|
||||
template = str(payload.get("chat_template") or "")
|
||||
if "tool" in template.lower():
|
||||
out.add(Cap.TOOLS)
|
||||
except AttributeError:
|
||||
return frozenset()
|
||||
return frozenset(out)
|
||||
|
||||
|
||||
def parse_lm_studio_models(payload: Mapping[str, Any], model_id) -> frozenset:
|
||||
"""Map an LM Studio GET /api/v0/models entry onto Cap."""
|
||||
out = {Cap.CHAT, Cap.COMPLETION, Cap.STREAMING}
|
||||
try:
|
||||
for entry in payload.get("data") or ():
|
||||
if model_id and entry.get("id") != model_id:
|
||||
continue
|
||||
if entry.get("type") == "embeddings":
|
||||
out.add(Cap.EMBEDDINGS_ENDPOINT)
|
||||
if entry.get("vision"):
|
||||
out.add(Cap.VISION)
|
||||
if entry.get("tool_use") or entry.get("function_calling"):
|
||||
out.add(Cap.TOOLS)
|
||||
except AttributeError:
|
||||
return frozenset()
|
||||
return frozenset(out)
|
||||
|
||||
|
||||
def parse_openai_models(payload: Mapping[str, Any]) -> tuple:
|
||||
"""Extract model ids from an OpenAI-shaped {"data":[{"id":...}]} reply."""
|
||||
try:
|
||||
return tuple(
|
||||
str(e["id"]) for e in (payload.get("data") or ()) if isinstance(e, dict) and "id" in e
|
||||
)
|
||||
except AttributeError:
|
||||
return ()
|
||||
@@ -1,409 +0,0 @@
|
||||
"""Identify which local model server is listening, without inferring from the port.
|
||||
|
||||
Port collisions are real: 8080 is claimed by llama-server, mlx_lm, llamafile,
|
||||
LocalAI and RamaLama; 8000 by vLLM and `transformers serve`. Identity is therefore
|
||||
never inferred from a port -- a ProbeSpec matches only when every Rule holds, and
|
||||
RuleKind.ABSENT rules exist specifically to separate co-tenants of one port.
|
||||
|
||||
Standard library only. urllib.request rather than httpx: httpx is present only as
|
||||
an undeclared transitive dependency of `openai`, and a package whose whole point
|
||||
is that anything can import it must not rest on that.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import http.client
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from .capabilities import ApiStyle, Evidence, LocalEngine, parse_openai_models
|
||||
from .errors import InvalidLocalSpecError
|
||||
|
||||
__all__ = [
|
||||
"HttpReply", "RuleKind", "Rule", "ProbeSpec", "Discovery",
|
||||
"PROBES", "LOOPBACK_HOST", "DEFAULT_PROBE_TIMEOUT", "DEFAULT_TOTAL_BUDGET",
|
||||
"probe_table", "probe_endpoint", "discover", "models", "default_transport",
|
||||
]
|
||||
|
||||
LOOPBACK_HOST = "127.0.0.1"
|
||||
DEFAULT_PROBE_TIMEOUT = 0.4
|
||||
DEFAULT_TOTAL_BUDGET = 1.5
|
||||
_MAX_BODY = 1 << 20 # 1 MiB
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HttpReply:
|
||||
status: int # 0 == transport failure
|
||||
body: bytes = b""
|
||||
error: Optional[str] = None # "refused" | "timeout" | "reset" | "http" | None
|
||||
|
||||
|
||||
class RuleKind(str, Enum):
|
||||
BODY_PREFIX = "body_prefix"
|
||||
JSON_KEY = "json_key"
|
||||
STATUS_OK = "status_ok"
|
||||
ABSENT = "absent"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Rule:
|
||||
method: str
|
||||
path: str
|
||||
kind: RuleKind
|
||||
value: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProbeSpec:
|
||||
engine: LocalEngine
|
||||
default_ports: tuple
|
||||
identity: tuple
|
||||
api_style: ApiStyle
|
||||
verified: bool
|
||||
note: str
|
||||
version_path: Optional[str] = None
|
||||
version_key: Optional[str] = None
|
||||
models_path: Optional[str] = None
|
||||
models_key: Optional[str] = None
|
||||
model_caps: Optional[Rule] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Discovery:
|
||||
engine: LocalEngine
|
||||
base_url: str
|
||||
api_style: ApiStyle
|
||||
evidence: Evidence
|
||||
engine_version: Optional[str] = None
|
||||
models: tuple = ()
|
||||
# Per-model metadata when the server offers it, as a tuple of
|
||||
# (model_id, capabilities_tuple, modified_at) -- enough to choose a model
|
||||
# without a second request. Empty when the server reports names only.
|
||||
model_meta: tuple = ()
|
||||
raw_identity: str = ""
|
||||
latency_ms: int = 0
|
||||
blocked: Optional[str] = None
|
||||
|
||||
|
||||
PROBES: tuple = (
|
||||
ProbeSpec(
|
||||
engine=LocalEngine.OLLAMA,
|
||||
default_ports=(11434,),
|
||||
identity=(Rule("GET", "/", RuleKind.BODY_PREFIX, "Ollama is running"),),
|
||||
api_style=ApiStyle.OPENAI_CHAT,
|
||||
verified=True,
|
||||
note=("verified 0.33.2 @ 2026-09-03: GET / -> 'Ollama is running'; "
|
||||
"/api/version -> {'version':'0.33.2'}; /api/tags entries carry "
|
||||
"capabilities and modified_at; POST /api/show carries capabilities."),
|
||||
version_path="/api/version", version_key="version",
|
||||
models_path="/api/tags", models_key="models[].model",
|
||||
model_caps=Rule("POST", "/api/show", RuleKind.JSON_KEY, "capabilities"),
|
||||
),
|
||||
ProbeSpec(
|
||||
engine=LocalEngine.LLAMA_CPP,
|
||||
default_ports=(8080,),
|
||||
identity=(Rule("GET", "/health", RuleKind.STATUS_OK),
|
||||
Rule("GET", "/props", RuleKind.JSON_KEY, "build_info")),
|
||||
api_style=ApiStyle.OPENAI_CHAT,
|
||||
verified=False,
|
||||
note=("binary present (build 7620), --port default 8080 confirmed from "
|
||||
"--help; /props reply shape NOT re-verified live."),
|
||||
version_path="/props", version_key="build_info",
|
||||
models_path="/v1/models", models_key="data[].id",
|
||||
model_caps=Rule("GET", "/props", RuleKind.JSON_KEY, "modalities"),
|
||||
),
|
||||
ProbeSpec(
|
||||
engine=LocalEngine.MLX_LM,
|
||||
default_ports=(8080,),
|
||||
identity=(Rule("GET", "/health", RuleKind.STATUS_OK),
|
||||
Rule("GET", "/props", RuleKind.ABSENT),
|
||||
Rule("GET", "/v1/models", RuleKind.JSON_KEY, "data")),
|
||||
api_style=ApiStyle.OPENAI_CHAT,
|
||||
verified=False,
|
||||
note=("mlx_lm.server present on PATH; default port 8080. Identity is "
|
||||
"'/health answers AND /props does not', to separate it from "
|
||||
"llama.cpp on the same port."),
|
||||
models_path="/v1/models", models_key="data[].id",
|
||||
),
|
||||
ProbeSpec(
|
||||
engine=LocalEngine.LM_STUDIO,
|
||||
default_ports=(1234,),
|
||||
identity=(Rule("GET", "/api/v0/models", RuleKind.JSON_KEY, "data"),),
|
||||
api_style=ApiStyle.OPENAI_CHAT,
|
||||
verified=False,
|
||||
note=("LM Studio not installed on the machine this was written on. "
|
||||
"/api/v0/models is richer than /v1/models and is the discriminator. "
|
||||
"Use HTTP, not the Python SDK (stale since Aug 2025, WebSocket-based)."),
|
||||
models_path="/api/v0/models", models_key="data[].id",
|
||||
model_caps=Rule("GET", "/api/v0/models", RuleKind.JSON_KEY, "data"),
|
||||
),
|
||||
ProbeSpec(
|
||||
engine=LocalEngine.VLLM,
|
||||
default_ports=(8000,),
|
||||
identity=(Rule("GET", "/version", RuleKind.JSON_KEY, "version"),),
|
||||
api_style=ApiStyle.OPENAI_CHAT,
|
||||
verified=False,
|
||||
note=("Linux only. Tools require BOTH --enable-auto-tool-choice AND "
|
||||
"--tool-call-parser, else tool calls come back as plain text."),
|
||||
version_path="/version", version_key="version",
|
||||
models_path="/v1/models", models_key="data[].id",
|
||||
),
|
||||
ProbeSpec(
|
||||
engine=LocalEngine.TRANSFORMERS_SERVE,
|
||||
default_ports=(8000,),
|
||||
identity=(Rule("GET", "/version", RuleKind.ABSENT),
|
||||
Rule("GET", "/health", RuleKind.STATUS_OK),
|
||||
Rule("GET", "/v1/models", RuleKind.JSON_KEY, "data")),
|
||||
api_style=ApiStyle.OPENAI_CHAT,
|
||||
verified=False,
|
||||
note=("`transformers serve`, default 8000; /health is undocumented. "
|
||||
"Identity is '/version absent AND /health ok', to separate it "
|
||||
"from vLLM on 8000."),
|
||||
models_path="/v1/models", models_key="data[].id",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def probe_table() -> tuple:
|
||||
"""The ordered probe table (identity endpoints and discriminators)."""
|
||||
return PROBES
|
||||
|
||||
|
||||
def default_transport(method: str, url: str, body, timeout: float) -> HttpReply:
|
||||
"""The only thing in this package that touches a socket."""
|
||||
req = urllib.request.Request(url, data=body, method=method)
|
||||
req.add_header("Accept", "application/json")
|
||||
req.add_header("User-Agent", "praisonaiagents-local/1")
|
||||
if body is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return HttpReply(resp.status, resp.read(_MAX_BODY))
|
||||
except urllib.error.HTTPError as exc:
|
||||
# HTTPError is both an exception and a response: read it, don't leak it.
|
||||
try:
|
||||
payload = exc.read(_MAX_BODY)
|
||||
except Exception:
|
||||
payload = b""
|
||||
return HttpReply(exc.code, payload, "http")
|
||||
except urllib.error.URLError as exc:
|
||||
reason = getattr(exc, "reason", None)
|
||||
if isinstance(reason, socket.timeout) or isinstance(reason, TimeoutError):
|
||||
return HttpReply(0, b"", "timeout")
|
||||
return HttpReply(0, b"", "refused")
|
||||
except (socket.timeout, TimeoutError):
|
||||
return HttpReply(0, b"", "timeout")
|
||||
except ConnectionResetError:
|
||||
return HttpReply(0, b"", "reset")
|
||||
except (OSError, ValueError, http.client.HTTPException):
|
||||
return HttpReply(0, b"", "refused")
|
||||
|
||||
|
||||
def _json(reply: HttpReply):
|
||||
try:
|
||||
return json.loads(reply.body.decode("utf-8", errors="replace"))
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def _rule_holds(rule: Rule, reply: HttpReply) -> bool:
|
||||
if rule.kind is RuleKind.ABSENT:
|
||||
if reply.status in (404, 405):
|
||||
return True
|
||||
if reply.status == 0:
|
||||
return False
|
||||
return _json(reply) is None
|
||||
if rule.kind is RuleKind.STATUS_OK:
|
||||
return 200 <= reply.status < 300
|
||||
if reply.status != 200:
|
||||
return False
|
||||
if rule.kind is RuleKind.BODY_PREFIX:
|
||||
return reply.body.decode("utf-8", errors="replace").lstrip().startswith(rule.value)
|
||||
if rule.kind is RuleKind.JSON_KEY:
|
||||
obj = _json(reply)
|
||||
return isinstance(obj, dict) and rule.value in obj
|
||||
return False
|
||||
|
||||
|
||||
def _extract_model_meta(payload, key) -> tuple:
|
||||
"""Per-model (id, capabilities, modified_at) when the listing carries it."""
|
||||
if key != "models[].model" or not isinstance(payload, dict):
|
||||
return ()
|
||||
out = []
|
||||
for m in payload.get("models") or ():
|
||||
if isinstance(m, dict) and "model" in m:
|
||||
caps = tuple(str(c).lower() for c in (m.get("capabilities") or ()))
|
||||
out.append((str(m["model"]), caps, str(m.get("modified_at") or "")))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _extract_models(payload, key) -> tuple:
|
||||
if not isinstance(payload, dict) or not key:
|
||||
return ()
|
||||
if key == "data[].id":
|
||||
return parse_openai_models(payload)
|
||||
if key == "models[].model":
|
||||
return tuple(
|
||||
str(m["model"]) for m in (payload.get("models") or ())
|
||||
if isinstance(m, dict) and "model" in m
|
||||
)
|
||||
return ()
|
||||
|
||||
|
||||
def probe_endpoint(base_url: str, *, expect=None, timeout: float = DEFAULT_PROBE_TIMEOUT,
|
||||
transport=None) -> Optional[Discovery]:
|
||||
"""Identify the server at ``base_url``, or None if nothing identifiable answered.
|
||||
|
||||
A 200 that matches no discriminator yields engine=UNKNOWN. A bodyless 403
|
||||
yields blocked="host_header_rejected" rather than being reported as absent.
|
||||
"""
|
||||
base = (base_url or "").rstrip("/")
|
||||
if "://" not in base or not base.split("://", 1)[1]:
|
||||
raise InvalidLocalSpecError(f"Not a usable base URL: {base_url!r}")
|
||||
send = transport or default_transport
|
||||
started = time.monotonic()
|
||||
|
||||
candidates = [p for p in PROBES if expect is None or p.engine == LocalEngine(expect)]
|
||||
cache = {}
|
||||
|
||||
def fetch(method, path, body=None):
|
||||
key = (method, path)
|
||||
if key not in cache:
|
||||
cache[key] = send(method, base + path, body, timeout)
|
||||
return cache[key]
|
||||
|
||||
first_reply = None
|
||||
for spec in candidates:
|
||||
ok = True
|
||||
for rule in spec.identity:
|
||||
reply = fetch(rule.method, rule.path,
|
||||
b'{"model":""}' if rule.method == "POST" else None)
|
||||
if first_reply is None:
|
||||
first_reply = reply
|
||||
if reply.status in (401, 403) and not reply.body:
|
||||
return Discovery(
|
||||
engine=LocalEngine(expect) if expect else LocalEngine.UNKNOWN,
|
||||
base_url=base, api_style=spec.api_style, evidence=Evidence.TABLE,
|
||||
blocked="host_header_rejected",
|
||||
latency_ms=int((time.monotonic() - started) * 1000))
|
||||
if not _rule_holds(rule, reply):
|
||||
ok = False
|
||||
break
|
||||
if not ok:
|
||||
continue
|
||||
version = None
|
||||
if spec.version_path:
|
||||
obj = _json(fetch("GET", spec.version_path))
|
||||
if isinstance(obj, dict) and spec.version_key:
|
||||
v = obj.get(spec.version_key)
|
||||
version = str(v) if v is not None else None
|
||||
found, meta = (), ()
|
||||
if spec.models_path:
|
||||
listing = _json(fetch("GET", spec.models_path))
|
||||
found = _extract_models(listing, spec.models_key)
|
||||
meta = _extract_model_meta(listing, spec.models_key)
|
||||
raw = b""
|
||||
if spec.identity:
|
||||
raw = fetch(spec.identity[0].method, spec.identity[0].path).body
|
||||
return Discovery(
|
||||
engine=spec.engine, base_url=base, api_style=spec.api_style,
|
||||
evidence=Evidence.SERVER, engine_version=version, models=found,
|
||||
model_meta=meta,
|
||||
raw_identity=raw.decode("utf-8", errors="replace")[:200],
|
||||
latency_ms=int((time.monotonic() - started) * 1000))
|
||||
|
||||
# No discriminator matched. Before declaring the host absent, ask the one
|
||||
# question every OpenAI-compatible server answers. Gating this on the first
|
||||
# probe's status was wrong: that probe is Ollama's `GET /`, which a generic
|
||||
# OpenAI-compatible server 404s -- so a healthy llama-server, LM Studio or
|
||||
# vLLM was reported as "did not answer (refused)" while it was serving.
|
||||
# It also makes the pinned-engine path reachable: an engine named by
|
||||
# PRAISONAI_LOCAL_ENGINE that has no ProbeSpec (llamafile, localai,
|
||||
# ramalama) produced an empty candidate list and could never resolve.
|
||||
# A base the user pasted from LM Studio or vLLM already ends in /v1; adding
|
||||
# another produced /v1/v1/models and a 404, so a healthy server looked absent.
|
||||
models_reply = fetch("GET", "/models" if base.endswith("/v1") else "/v1/models")
|
||||
if 200 <= models_reply.status < 300:
|
||||
listing = _json(models_reply)
|
||||
pinned = LocalEngine(expect) if expect else None
|
||||
return Discovery(
|
||||
engine=pinned or LocalEngine.UNKNOWN, base_url=base,
|
||||
api_style=ApiStyle.OPENAI_CHAT if isinstance(listing, dict) else ApiStyle.OPENAI_COMPLETIONS,
|
||||
# A pinned engine is asserted by the environment, not observed.
|
||||
evidence=Evidence.ENV if pinned else Evidence.TABLE,
|
||||
models=parse_openai_models(listing) if isinstance(listing, dict) else (),
|
||||
raw_identity=models_reply.body.decode("utf-8", errors="replace")[:200],
|
||||
latency_ms=int((time.monotonic() - started) * 1000))
|
||||
|
||||
# Some servers expose no model listing at all. If anything answered with a
|
||||
# 2xx we still know a server is there; only a transport failure means absent.
|
||||
if first_reply is not None and 200 <= first_reply.status < 300:
|
||||
return Discovery(
|
||||
engine=LocalEngine(expect) if expect else LocalEngine.UNKNOWN,
|
||||
base_url=base, api_style=ApiStyle.OPENAI_CHAT,
|
||||
evidence=Evidence.ENV if expect else Evidence.TABLE,
|
||||
raw_identity=first_reply.body.decode("utf-8", errors="replace")[:200],
|
||||
latency_ms=int((time.monotonic() - started) * 1000))
|
||||
return None
|
||||
|
||||
|
||||
def discover(*, ports: Optional[Sequence] = None, include: Optional[Sequence] = None,
|
||||
host: str = LOOPBACK_HOST, timeout: float = DEFAULT_PROBE_TIMEOUT,
|
||||
budget: float = DEFAULT_TOTAL_BUDGET, transport=None) -> tuple:
|
||||
"""Scan the probe table's default ports; return every server found.
|
||||
|
||||
Ports are probed concurrently. Results are ordered by PROBES order then port,
|
||||
so Ollama on 11434 outranks an unidentified server on 8000. Never raises.
|
||||
"""
|
||||
wanted = [p for p in PROBES if include is None or p.engine in {LocalEngine(e) for e in include}]
|
||||
targets = []
|
||||
for spec in wanted:
|
||||
for port in (ports if ports is not None else spec.default_ports):
|
||||
targets.append((spec.engine, port))
|
||||
seen_ports = sorted({p for _, p in targets})
|
||||
|
||||
results = {}
|
||||
deadline = time.monotonic() + budget
|
||||
|
||||
def work(port):
|
||||
if time.monotonic() > deadline:
|
||||
return port, None
|
||||
try:
|
||||
return port, probe_endpoint(f"http://{host}:{port}", timeout=timeout,
|
||||
transport=transport)
|
||||
except Exception:
|
||||
return port, None
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
||||
for port, found in pool.map(work, seen_ports):
|
||||
if found is not None:
|
||||
results[port] = found
|
||||
|
||||
ordered = []
|
||||
for spec in wanted:
|
||||
for port in (ports if ports is not None else spec.default_ports):
|
||||
found = results.get(port)
|
||||
if found is not None and found.engine == spec.engine and found not in ordered:
|
||||
ordered.append(found)
|
||||
for found in results.values():
|
||||
if found not in ordered:
|
||||
ordered.append(found)
|
||||
return tuple(ordered)
|
||||
|
||||
|
||||
def models(discovery: Discovery, *, timeout: float = DEFAULT_PROBE_TIMEOUT,
|
||||
transport=None) -> tuple:
|
||||
"""List model ids served at ``discovery.base_url`` (GET only). Never raises."""
|
||||
spec = next((p for p in PROBES if p.engine == discovery.engine), None)
|
||||
if spec is None or not spec.models_path:
|
||||
return discovery.models
|
||||
send = transport or default_transport
|
||||
reply = send("GET", discovery.base_url + spec.models_path, None, timeout)
|
||||
return _extract_models(_json(reply), spec.models_key) or discovery.models
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Resolve a local embedding model, so a local agent stays local end to end.
|
||||
|
||||
The gap this closes: `Agent(llm="local", knowledge=[...])` routed chat to the
|
||||
local server and embeddings to OpenAI, sending both the user's documents and
|
||||
their queries off the machine with no warning. Ollama already serves
|
||||
`nomic-embed-text` and friends; nothing asked it.
|
||||
|
||||
Data only, per the package contract: this returns a model name and an endpoint.
|
||||
It never embeds anything.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional, Sequence, Tuple
|
||||
|
||||
from .capabilities import LocalEngine
|
||||
|
||||
__all__ = ["ENV_EMBED_MODEL", "PREFERRED_EMBED_MODELS",
|
||||
"select_embedding_model", "local_embedder_config"]
|
||||
|
||||
ENV_EMBED_MODEL = "PRAISONAI_LOCAL_EMBED_MODEL"
|
||||
|
||||
# Preference order when the caller names nothing. These are the embedders people
|
||||
# actually pull for Ollama; the first one present wins. Order is quality-biased,
|
||||
# not alphabetical: nomic-embed-text is the common default and outranks the
|
||||
# smaller all-minilm.
|
||||
PREFERRED_EMBED_MODELS = (
|
||||
"nomic-embed-text",
|
||||
"mxbai-embed-large",
|
||||
"bge-m3",
|
||||
"snowflake-arctic-embed",
|
||||
"all-minilm",
|
||||
)
|
||||
|
||||
|
||||
def _is_embedding_model(name: str, capabilities: Sequence[str]) -> bool:
|
||||
"""True when a listed model can serve embeddings.
|
||||
|
||||
Prefers the server's own capability list; falls back to the name for
|
||||
servers that report no capabilities at all.
|
||||
"""
|
||||
if capabilities:
|
||||
return "embedding" in capabilities
|
||||
lowered = name.lower()
|
||||
return "embed" in lowered or lowered.startswith("bge-")
|
||||
|
||||
|
||||
def select_embedding_model(
|
||||
models: Sequence[str],
|
||||
model_meta: Sequence[Tuple[str, Tuple[str, ...], Optional[str]]] = (),
|
||||
) -> Optional[str]:
|
||||
"""Pick an embedding model from what the server actually serves.
|
||||
|
||||
Precedence: PRAISONAI_LOCAL_EMBED_MODEL (validated against the list) >
|
||||
PREFERRED_EMBED_MODELS order > any remaining embedding-capable model.
|
||||
Returns None when the server serves no embedding model -- callers must
|
||||
treat that as "cannot embed locally", never as "fall back to a cloud
|
||||
default".
|
||||
"""
|
||||
caps_by_name = {name: caps for name, caps, _ in (model_meta or ())}
|
||||
embedders = [m for m in models
|
||||
if _is_embedding_model(m, caps_by_name.get(m, ()))]
|
||||
if not embedders:
|
||||
return None
|
||||
|
||||
named = (os.environ.get(ENV_EMBED_MODEL) or "").strip()
|
||||
if named:
|
||||
# Accept an exact id or a bare name the server tags (":latest").
|
||||
for candidate in embedders:
|
||||
if candidate == named or candidate.split(":", 1)[0] == named:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
for preferred in PREFERRED_EMBED_MODELS:
|
||||
for candidate in embedders:
|
||||
if candidate.split(":", 1)[0] == preferred:
|
||||
return candidate
|
||||
return embedders[0]
|
||||
|
||||
|
||||
def local_embedder_config(engine: LocalEngine, base_url: str,
|
||||
model: str) -> dict:
|
||||
"""Build the embedder config block for a local engine.
|
||||
|
||||
Shaped for the knowledge/memory layer (mem0-style provider + config).
|
||||
"""
|
||||
if engine is LocalEngine.OLLAMA:
|
||||
return {
|
||||
"provider": "ollama",
|
||||
"config": {"model": model, "ollama_base_url": base_url},
|
||||
}
|
||||
# Everything else speaks OpenAI over HTTP.
|
||||
trimmed = base_url.rstrip("/")
|
||||
return {
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model": model,
|
||||
"openai_base_url": trimmed if trimmed.endswith("/v1") else trimmed + "/v1",
|
||||
},
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Exceptions raised by praisonaiagents.local.
|
||||
|
||||
Deliberately NOT subclasses of praisonaiagents.errors: this package imports
|
||||
nothing from praisonaiagents, and wrapping is the integration layer's job.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["LocalError", "NoLocalEngineError", "EngineUnreachableError",
|
||||
"HostHeaderRejectedError", "ModelNotAvailableError",
|
||||
"InvalidLocalSpecError"]
|
||||
|
||||
|
||||
class LocalError(Exception):
|
||||
"""Base class for every error raised by praisonaiagents.local.
|
||||
|
||||
Deliberately not a subclass of praisonaiagents.errors: this package imports
|
||||
nothing from praisonaiagents, and wrapping is the integration layer's job.
|
||||
"""
|
||||
|
||||
|
||||
class NoLocalEngineError(LocalError):
|
||||
"""No local model server could be found or was named."""
|
||||
|
||||
|
||||
class EngineUnreachableError(LocalError):
|
||||
"""An explicitly named local server did not answer."""
|
||||
|
||||
def __init__(self, message, base_url="", reason="", source=""):
|
||||
super().__init__(message)
|
||||
self.base_url = base_url
|
||||
self.reason = reason
|
||||
self.source = source
|
||||
|
||||
|
||||
class HostHeaderRejectedError(EngineUnreachableError):
|
||||
"""The server returned a bodyless 403 because the Host header was not local."""
|
||||
|
||||
|
||||
class ModelNotAvailableError(LocalError):
|
||||
def __init__(self, message, model_id="", available=()):
|
||||
super().__init__(message)
|
||||
self.model_id = model_id
|
||||
self.available = tuple(available)
|
||||
|
||||
|
||||
class InvalidLocalSpecError(LocalError, ValueError):
|
||||
"""The spec string could not be parsed."""
|
||||
@@ -1,180 +0,0 @@
|
||||
"""Static catalogue of local-engine behaviours that fail without an error.
|
||||
|
||||
Pure data. No network, no I/O, no environment reads. Every SILENT entry is a way
|
||||
a local server returns HTTP 200 and a wrong result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Iterable, Mapping
|
||||
|
||||
from .capabilities import ApiStyle, Cap, LocalEngine
|
||||
|
||||
__all__ = ["Severity", "Quirk", "QuirkNote", "NOTES", "note", "all_notes",
|
||||
"notes_for", "quirks_for", "silent_quirks"]
|
||||
|
||||
|
||||
class Severity(str, Enum):
|
||||
SILENT = "silent" # wrong result, no error surface -- the dangerous class
|
||||
HARD = "hard" # the request fails loudly (4xx/5xx)
|
||||
COST = "cost" # correct result, unexpected latency or resource cost
|
||||
|
||||
|
||||
class Quirk(str, Enum):
|
||||
FORMAT_AND_TOOLS_MUTUALLY_DESTRUCTIVE = "format_and_tools_mutually_destructive"
|
||||
TOOL_SCHEMA_KEYWORDS_STRIPPED = "tool_schema_keywords_stripped"
|
||||
TOOL_PARAM_TYPE_MUST_BE_BARE_STRING = "tool_param_type_must_be_bare_string"
|
||||
UNKNOWN_OPTIONS_ACCEPTED_AND_IGNORED = "unknown_options_accepted_and_ignored"
|
||||
THINK_FIELD_IGNORED_ON_OPENAI_ROUTE = "think_field_ignored_on_openai_route"
|
||||
THINKING_WITH_TOOLS_YIELDS_EMPTY_TURN = "thinking_with_tools_yields_empty_turn"
|
||||
NATIVE_ROUTE_NEVER_SETS_TOOL_FINISH_REASON = "native_route_never_sets_tool_finish_reason"
|
||||
TOOL_CALLS_ARRIVE_WHOLE_NOT_INCREMENTAL = "tool_calls_arrive_whole_not_incremental"
|
||||
STREAM_ERROR_ENDS_WITHOUT_DONE = "stream_error_ends_without_done"
|
||||
SAMPLING_DEFAULTS_FORCED_TO_ONE = "sampling_defaults_forced_to_one"
|
||||
CONTEXT_OPTION_CHANGE_RELOADS_MODEL = "context_option_change_reloads_model"
|
||||
KEEP_ALIVE_IS_GLOBAL_LAST_WRITER_WINS = "keep_alive_is_global_last_writer_wins"
|
||||
BODYLESS_403_ON_NON_LOCAL_HOST_HEADER = "bodyless_403_on_non_local_host_header"
|
||||
TOOLS_REQUIRE_SERVER_FLAGS = "tools_require_server_flags"
|
||||
NO_JSON_SCHEMA_SUPPORT = "no_json_schema_support"
|
||||
NO_EMBEDDINGS_ENDPOINT = "no_embeddings_endpoint"
|
||||
SINGLE_MODEL_PER_PROCESS = "single_model_per_process"
|
||||
CAPS_ASSUMED_ENGINE_UNIDENTIFIED = "caps_assumed_engine_unidentified"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuirkNote:
|
||||
quirk: Quirk
|
||||
engines: tuple
|
||||
severity: Severity
|
||||
symptom: str
|
||||
workaround: str
|
||||
verified_on: str
|
||||
api_styles: tuple = ()
|
||||
requires_caps: frozenset = field(default_factory=frozenset)
|
||||
|
||||
|
||||
_OLLAMA = (LocalEngine.OLLAMA,)
|
||||
_OPENAI_ROUTES = (ApiStyle.OPENAI_CHAT, ApiStyle.OPENAI_COMPLETIONS)
|
||||
_V = "ollama 0.33.2 @ 2026-09-03"
|
||||
|
||||
NOTES: Mapping[Quirk, QuirkNote] = {n.quirk: n for n in (
|
||||
QuirkNote(
|
||||
Quirk.FORMAT_AND_TOOLS_MUTUALLY_DESTRUCTIVE, _OLLAMA, Severity.SILENT,
|
||||
"JSON format plus tools suppresses the tool call; the model fabricates an answer.",
|
||||
"Refuse the combination at request build. Raise -- a confident fabrication is worse than an error.",
|
||||
_V),
|
||||
QuirkNote(
|
||||
Quirk.TOOL_SCHEMA_KEYWORDS_STRIPPED, _OLLAMA, Severity.SILENT,
|
||||
"minLength, format, default, additionalProperties, $ref and oneOf never reach the model.",
|
||||
"Warn once at tool-declaration time, naming the dropped keys.", _V),
|
||||
QuirkNote(
|
||||
Quirk.TOOL_PARAM_TYPE_MUST_BE_BARE_STRING, _OLLAMA, Severity.HARD,
|
||||
'A tool parameter "type" given as a list 400s the whole request.',
|
||||
"Collapse union types to a single string before sending.", _V),
|
||||
QuirkNote(
|
||||
Quirk.UNKNOWN_OPTIONS_ACCEPTED_AND_IGNORED, _OLLAMA, Severity.SILENT,
|
||||
"Unrecognised options keys return 200 and change nothing.",
|
||||
"Validate keys against the known set; warn on unknowns.", _V),
|
||||
QuirkNote(
|
||||
Quirk.THINK_FIELD_IGNORED_ON_OPENAI_ROUTE, _OLLAMA, Severity.SILENT,
|
||||
"think is a native-API field; on /v1 it is dropped and reasoning still emits.",
|
||||
'Translate to reasoning_effort="none" on the OpenAI route.', _V,
|
||||
api_styles=_OPENAI_ROUTES),
|
||||
QuirkNote(
|
||||
Quirk.THINKING_WITH_TOOLS_YIELDS_EMPTY_TURN, _OLLAMA, Severity.SILENT,
|
||||
'Empty content, zero tool calls, finish_reason "stop".',
|
||||
"Treat as retryable; retry once with reasoning disabled.", _V,
|
||||
requires_caps=frozenset({Cap.THINKING, Cap.TOOLS})),
|
||||
QuirkNote(
|
||||
Quirk.NATIVE_ROUTE_NEVER_SETS_TOOL_FINISH_REASON, _OLLAMA, Severity.SILENT,
|
||||
'/api/chat reports done_reason "stop" even when tool_calls is populated.',
|
||||
"Inspect the message body, never the finish reason.", _V,
|
||||
api_styles=(ApiStyle.OLLAMA_NATIVE,)),
|
||||
QuirkNote(
|
||||
Quirk.TOOL_CALLS_ARRIVE_WHOLE_NOT_INCREMENTAL, _OLLAMA, Severity.SILENT,
|
||||
"Streamed tool calls arrive in one chunk, not as argument deltas.",
|
||||
"The accumulator must tolerate both; never assume fragments.", _V),
|
||||
QuirkNote(
|
||||
Quirk.STREAM_ERROR_ENDS_WITHOUT_DONE, _OLLAMA, Severity.SILENT,
|
||||
"A mid-stream failure yields an empty delta and no [DONE] sentinel.",
|
||||
"Set a read timeout; check every NDJSON line for an error key.", _V,
|
||||
api_styles=_OPENAI_ROUTES),
|
||||
QuirkNote(
|
||||
Quirk.SAMPLING_DEFAULTS_FORCED_TO_ONE, _OLLAMA, Severity.SILENT,
|
||||
"Omitted temperature/top_p become 1.0, not the model's own defaults.",
|
||||
"Send explicit values, or use the native route.", _V,
|
||||
api_styles=_OPENAI_ROUTES),
|
||||
QuirkNote(
|
||||
Quirk.CONTEXT_OPTION_CHANGE_RELOADS_MODEL, _OLLAMA, Severity.COST,
|
||||
"Changing num_ctx/num_batch/num_gpu forces a full model reload.",
|
||||
"Freeze runner options per target; never vary them per call.", _V),
|
||||
QuirkNote(
|
||||
Quirk.KEEP_ALIVE_IS_GLOBAL_LAST_WRITER_WINS, _OLLAMA, Severity.SILENT,
|
||||
"keep_alive is per-model and process-wide, not per-request.",
|
||||
"Never send keep_alive=0 from a library path; it evicts another caller's model.", _V),
|
||||
QuirkNote(
|
||||
Quirk.BODYLESS_403_ON_NON_LOCAL_HOST_HEADER, _OLLAMA, Severity.HARD,
|
||||
"A Host header that is not localhost or an IP gets HTTP 403 with a zero-byte body.",
|
||||
"Send Host as localhost or an IP; set OLLAMA_HOST on the server to allow the origin.", _V),
|
||||
QuirkNote(
|
||||
Quirk.TOOLS_REQUIRE_SERVER_FLAGS,
|
||||
(LocalEngine.VLLM, LocalEngine.LLAMA_CPP, LocalEngine.LLAMAFILE), Severity.SILENT,
|
||||
"Tool calls come back as assistant text unless the server was launched with the right flags.",
|
||||
"vLLM needs BOTH --enable-auto-tool-choice AND --tool-call-parser; llama-server needs --jinja.",
|
||||
"unverified: vendor docs"),
|
||||
QuirkNote(
|
||||
Quirk.NO_JSON_SCHEMA_SUPPORT, (LocalEngine.MLX_LM,), Severity.SILENT,
|
||||
"response_format json_schema is unimplemented.",
|
||||
"Drop the capability; fall back to prompt-and-parse.", "unverified: source read"),
|
||||
QuirkNote(
|
||||
Quirk.NO_EMBEDDINGS_ENDPOINT, (LocalEngine.MLX_LM,), Severity.HARD,
|
||||
"/v1/embeddings is absent.",
|
||||
"Resolve embeddings to a different target, and say so.", "unverified: source read"),
|
||||
QuirkNote(
|
||||
Quirk.SINGLE_MODEL_PER_PROCESS,
|
||||
(LocalEngine.LLAMA_CPP, LocalEngine.MLX_LM), Severity.SILENT,
|
||||
"One model per process; the request's model field is ignored.",
|
||||
"Asking for model B returns model A's answer. Pin the model at launch.",
|
||||
"unverified: vendor docs"),
|
||||
QuirkNote(
|
||||
Quirk.CAPS_ASSUMED_ENGINE_UNIDENTIFIED, (LocalEngine.UNKNOWN,), Severity.SILENT,
|
||||
"The engine could not be identified, so every capability is a static guess.",
|
||||
"Treat capabilities as unproven; keep repair paths enabled.", "n/a"),
|
||||
)}
|
||||
|
||||
|
||||
def note(quirk) -> QuirkNote:
|
||||
"""Return the catalogue entry for one quirk."""
|
||||
return NOTES[Quirk(quirk)]
|
||||
|
||||
|
||||
def all_notes() -> tuple:
|
||||
"""Every catalogue entry, ordered by (first engine, quirk value)."""
|
||||
return tuple(sorted(NOTES.values(), key=lambda n: (n.engines[0].value, n.quirk.value)))
|
||||
|
||||
|
||||
def quirks_for(engine, api_style, *, caps=frozenset()) -> frozenset:
|
||||
"""Quirks that apply to this engine, route and capability set."""
|
||||
engine = LocalEngine(engine)
|
||||
style = ApiStyle(api_style)
|
||||
caps = frozenset(caps)
|
||||
return frozenset(
|
||||
n.quirk for n in NOTES.values()
|
||||
if engine in n.engines
|
||||
and (not n.api_styles or style in n.api_styles)
|
||||
and n.requires_caps <= caps
|
||||
)
|
||||
|
||||
|
||||
def notes_for(quirks: Iterable) -> tuple:
|
||||
"""Catalogue entries for ``quirks``, ordered by severity then value."""
|
||||
order = {Severity.SILENT: 0, Severity.HARD: 1, Severity.COST: 2}
|
||||
got = [NOTES[Quirk(q)] for q in quirks]
|
||||
return tuple(sorted(got, key=lambda n: (order[n.severity], n.quirk.value)))
|
||||
|
||||
|
||||
def silent_quirks(quirks: Iterable) -> frozenset:
|
||||
"""Filter to Severity.SILENT -- the set a caller must actively defend against."""
|
||||
return frozenset(q for q in (Quirk(x) for x in quirks) if NOTES[q].severity is Severity.SILENT)
|
||||
@@ -1,322 +0,0 @@
|
||||
"""Resolution precedence and the process-local cache.
|
||||
|
||||
Precedence: spec > PRAISONAI_LOCAL_BASE_URL > OLLAMA_HOST >
|
||||
OPENAI_BASE_URL/OPENAI_API_BASE (only when local) > probe-table scan.
|
||||
|
||||
The first three are authoritative: if one names a server that does not answer
|
||||
this raises, rather than silently talking to a different one. Silently using a
|
||||
server the caller did not name is the failure mode this package exists to
|
||||
prevent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from .capabilities import Cap, Evidence, LocalEngine, default_caps
|
||||
from .capabilities import (parse_llama_cpp_props, parse_lm_studio_models,
|
||||
parse_ollama_capabilities)
|
||||
from .discover import (DEFAULT_PROBE_TIMEOUT, DEFAULT_TOTAL_BUDGET, PROBES,
|
||||
default_transport, discover, probe_endpoint)
|
||||
from .errors import (EngineUnreachableError, HostHeaderRejectedError,
|
||||
InvalidLocalSpecError, LocalError, ModelNotAvailableError,
|
||||
NoLocalEngineError)
|
||||
from .target import (ENV_BASE_URL, ENV_ENGINE, ENV_MODEL, build_target,
|
||||
parse_ollama_host, parse_spec, select_model)
|
||||
|
||||
ENV_TTL = "PRAISONAI_LOCAL_TTL"
|
||||
ENV_NEG_TTL = "PRAISONAI_LOCAL_NEG_TTL"
|
||||
ENV_TIMEOUT = "PRAISONAI_LOCAL_TIMEOUT"
|
||||
|
||||
__all__ = ["resolve", "resolve_or_none", "clear_cache", "cache_info",
|
||||
"ENV_TTL", "ENV_NEG_TTL", "ENV_TIMEOUT"]
|
||||
|
||||
def _is_local_host(url: str) -> bool:
|
||||
try:
|
||||
host = urllib.parse.urlparse(url).hostname or ""
|
||||
except ValueError:
|
||||
return False
|
||||
if host in ("localhost", "") or host.endswith(".localhost") or host.endswith(".local"):
|
||||
return True
|
||||
try:
|
||||
addr = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return False
|
||||
return addr.is_loopback or addr.is_private or addr.is_link_local
|
||||
|
||||
|
||||
def _model_capabilities(discovery: Discovery, model_id, *, timeout, transport):
|
||||
"""One request, at most, for per-model capabilities. Degrades to TABLE."""
|
||||
spec = next((p for p in PROBES if p.engine == discovery.engine), None)
|
||||
if spec is None or spec.model_caps is None or not model_id:
|
||||
return default_caps(discovery.engine), Evidence.TABLE, ()
|
||||
send = transport or default_transport
|
||||
rule = spec.model_caps
|
||||
import json as _json
|
||||
body = _json.dumps({"model": model_id}).encode() if rule.method == "POST" else None
|
||||
reply = send(rule.method, discovery.base_url + rule.path, body, timeout)
|
||||
if reply.status != 200:
|
||||
return default_caps(discovery.engine), Evidence.TABLE, ()
|
||||
try:
|
||||
payload = _json.loads(reply.body.decode("utf-8", errors="replace"))
|
||||
except ValueError:
|
||||
return default_caps(discovery.engine), Evidence.TABLE, ()
|
||||
if not isinstance(payload, dict):
|
||||
return default_caps(discovery.engine), Evidence.TABLE, ()
|
||||
|
||||
if discovery.engine == LocalEngine.OLLAMA:
|
||||
caps = parse_ollama_capabilities(payload)
|
||||
elif discovery.engine == LocalEngine.LLAMA_CPP:
|
||||
caps = parse_llama_cpp_props(payload)
|
||||
elif discovery.engine == LocalEngine.LM_STUDIO:
|
||||
caps = parse_lm_studio_models(payload, model_id)
|
||||
else:
|
||||
caps = frozenset()
|
||||
if not caps:
|
||||
return default_caps(discovery.engine), Evidence.TABLE, ()
|
||||
|
||||
extra = []
|
||||
info = payload.get("model_info") or {}
|
||||
if isinstance(info, dict):
|
||||
for key, value in info.items():
|
||||
if key.endswith(".context_length"):
|
||||
extra.append(("context_length", str(value)))
|
||||
elif key.endswith(".embedding_length"):
|
||||
extra.append(("embedding_length", str(value)))
|
||||
details = payload.get("details") or {}
|
||||
if isinstance(details, dict):
|
||||
for k in ("family", "parameter_size", "quantization_level"):
|
||||
if details.get(k):
|
||||
extra.append((k, str(details[k])))
|
||||
# Union with the engine floor so streaming/embeddings survive.
|
||||
return frozenset(caps) | default_caps(discovery.engine), Evidence.SERVER, tuple(extra)
|
||||
|
||||
|
||||
_CACHE_LOCK = threading.Lock()
|
||||
_TARGETS = {}
|
||||
_NEGATIVES = {}
|
||||
_STATS = {"hits": 0, "misses": 0}
|
||||
|
||||
|
||||
def _float_env(name, default):
|
||||
try:
|
||||
return float(os.environ.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
|
||||
|
||||
def _cache_key(spec, timeout):
|
||||
return "\x00".join((
|
||||
str(spec) if spec is not None else "*",
|
||||
os.environ.get(ENV_BASE_URL, ""), os.environ.get(ENV_ENGINE, ""),
|
||||
os.environ.get(ENV_MODEL, ""), os.environ.get("OLLAMA_HOST", ""),
|
||||
os.environ.get("OPENAI_BASE_URL", ""), os.environ.get("OPENAI_API_BASE", ""),
|
||||
f"{timeout:.3f}",
|
||||
))
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Drop every cached target and negative result (test hook)."""
|
||||
with _CACHE_LOCK:
|
||||
_TARGETS.clear()
|
||||
_NEGATIVES.clear()
|
||||
_STATS.update(hits=0, misses=0)
|
||||
|
||||
|
||||
def cache_info() -> dict:
|
||||
with _CACHE_LOCK:
|
||||
return {"targets": len(_TARGETS), "negatives": len(_NEGATIVES), **_STATS}
|
||||
|
||||
|
||||
_ERRORS = {c.__name__: c for c in (
|
||||
NoLocalEngineError, EngineUnreachableError, HostHeaderRejectedError,
|
||||
ModelNotAvailableError, InvalidLocalSpecError, LocalError)}
|
||||
|
||||
|
||||
def resolve(spec=None, *, timeout=None, refresh=False, transport=None) -> LocalTarget:
|
||||
"""Resolve the local model server and model this machine should use.
|
||||
|
||||
Precedence: spec > PRAISONAI_LOCAL_BASE_URL > OLLAMA_HOST >
|
||||
OPENAI_BASE_URL/OPENAI_API_BASE (only when local) > probe-table scan.
|
||||
|
||||
The first three are authoritative: if one names a server that does not
|
||||
answer, this raises rather than silently talking to a different one.
|
||||
"""
|
||||
total = _float_env(ENV_TIMEOUT, 1.5) if timeout is None else float(timeout)
|
||||
total = min(max(total, 0.05), 30.0)
|
||||
per_request = min(DEFAULT_PROBE_TIMEOUT, total / 3)
|
||||
key = _cache_key(spec, total)
|
||||
pid = os.getpid()
|
||||
|
||||
if not refresh:
|
||||
with _CACHE_LOCK:
|
||||
hit = _TARGETS.get(key)
|
||||
if hit and hit[0] > time.monotonic() and hit[1] == pid:
|
||||
_STATS["hits"] += 1
|
||||
return hit[2]
|
||||
neg = _NEGATIVES.get(key)
|
||||
if neg and neg[0] > time.monotonic() and neg[1] == pid:
|
||||
_STATS["hits"] += 1
|
||||
# A fresh instance: a stored exception carries a dead traceback.
|
||||
raise _ERRORS.get(neg[2], LocalError)(neg[3])
|
||||
with _CACHE_LOCK:
|
||||
_STATS["misses"] += 1
|
||||
|
||||
try:
|
||||
target = _resolve_uncached(spec, per_request, total, transport)
|
||||
except LocalError as exc:
|
||||
ttl = _float_env(ENV_NEG_TTL, 5.0)
|
||||
if ttl > 0:
|
||||
with _CACHE_LOCK:
|
||||
_NEGATIVES[key] = (time.monotonic() + ttl, pid, type(exc).__name__, str(exc))
|
||||
raise
|
||||
ttl = _float_env(ENV_TTL, 30.0)
|
||||
if ttl > 0:
|
||||
with _CACHE_LOCK:
|
||||
_TARGETS[key] = (time.monotonic() + ttl, pid, target)
|
||||
return target
|
||||
|
||||
|
||||
def _with_url_evidence(discovery, evidence):
|
||||
"""Record where the base URL came from; the probe only proves it answered."""
|
||||
import dataclasses
|
||||
return dataclasses.replace(discovery, evidence=evidence) if evidence else discovery
|
||||
|
||||
|
||||
def _require_model(discovery, model_id):
|
||||
"""Reject a model-less resolution or a named model the server does not serve."""
|
||||
available = tuple(discovery.models)
|
||||
where = f"{discovery.engine.value} at {discovery.base_url}"
|
||||
if model_id is None:
|
||||
raise NoLocalEngineError(
|
||||
f"{where} answered but lists no usable model to chat with. Pull one "
|
||||
f"(e.g. `ollama pull qwen3:0.6b`) or name it explicitly.")
|
||||
if available and model_id not in available:
|
||||
raise ModelNotAvailableError(
|
||||
f"Model {model_id!r} is not served by {where}. "
|
||||
f"Available: {', '.join(available)}.",
|
||||
model_id=model_id, available=available)
|
||||
|
||||
|
||||
def _finish(discovery, model_id, model_evidence, per_request, transport, url_evidence=None):
|
||||
if model_id is None:
|
||||
model_id = select_model(discovery, transport=transport, timeout=per_request)
|
||||
model_evidence = Evidence.SERVER
|
||||
# An explicitly named model must be served, and a reachable server that
|
||||
# lists none must not yield a model-less target -- Agent would otherwise
|
||||
# substitute its own default (e.g. gpt-4o-mini) and send it to the local
|
||||
# endpoint, failing only on the first completion instead of at resolution.
|
||||
_require_model(discovery, model_id)
|
||||
caps, caps_evidence, extra = _model_capabilities(
|
||||
discovery, model_id, timeout=per_request, transport=transport)
|
||||
target = build_target(discovery, model_id, caps=caps, caps_evidence=caps_evidence,
|
||||
model_evidence=model_evidence, extra=extra)
|
||||
if url_evidence is not None:
|
||||
import dataclasses
|
||||
ev = dict(target.evidence)
|
||||
ev["base_url"] = url_evidence
|
||||
target = dataclasses.replace(target, evidence=tuple(sorted(ev.items())))
|
||||
return target
|
||||
|
||||
|
||||
def _authoritative(url, expect, source, per_request, transport, model_id, model_evidence):
|
||||
found = probe_endpoint(url, expect=expect, timeout=per_request, transport=transport)
|
||||
if found is None:
|
||||
hint = ""
|
||||
if source == "OLLAMA_HOST" and url.rsplit(":", 1)[-1] in ("80", "443"):
|
||||
hint = (" Note that OLLAMA_HOST with an explicit http:// scheme and no "
|
||||
"port means port 80, not 11434.")
|
||||
raise EngineUnreachableError(
|
||||
f"Local runtime at {url} did not answer (refused). It was named "
|
||||
f"explicitly by {source}, so no other port was probed.{hint}",
|
||||
base_url=url, reason="refused", source=source)
|
||||
if found.blocked:
|
||||
raise HostHeaderRejectedError(
|
||||
f"Local runtime at {url} returned HTTP 403 with an empty body. Ollama "
|
||||
f"rejects any request whose Host header is not localhost or an IP "
|
||||
f"address; set OLLAMA_HOST on the server to allow this origin.",
|
||||
base_url=url, reason="http_403", source=source)
|
||||
return found, model_id, model_evidence
|
||||
|
||||
|
||||
def _resolve_uncached(spec, per_request, total, transport):
|
||||
engine, spec_url, spec_model = parse_spec(spec)
|
||||
model_evidence = Evidence.SPEC if spec_model else Evidence.SERVER
|
||||
|
||||
if spec_url:
|
||||
found, m, ev = _authoritative(spec_url, engine, "spec", per_request,
|
||||
transport, spec_model, model_evidence)
|
||||
return _finish(found, m, ev, per_request, transport, Evidence.SPEC)
|
||||
|
||||
env_url = os.environ.get(ENV_BASE_URL)
|
||||
if env_url:
|
||||
env_engine = os.environ.get(ENV_ENGINE)
|
||||
expect = engine
|
||||
if env_engine:
|
||||
try:
|
||||
expect = LocalEngine(env_engine.lower())
|
||||
except ValueError:
|
||||
raise InvalidLocalSpecError(
|
||||
f"{ENV_ENGINE}={env_engine!r} is not a known local engine. "
|
||||
f"Expected one of: {', '.join(e.value for e in LocalEngine)}.")
|
||||
found, m, ev = _authoritative(env_url.rstrip("/"), expect, ENV_BASE_URL,
|
||||
per_request, transport, spec_model, model_evidence)
|
||||
return _finish(found, m, ev, per_request, transport, Evidence.ENV)
|
||||
|
||||
ollama_host = os.environ.get("OLLAMA_HOST")
|
||||
if ollama_host:
|
||||
url = parse_ollama_host(ollama_host)
|
||||
if url:
|
||||
found, m, ev = _authoritative(url, engine or LocalEngine.OLLAMA, "OLLAMA_HOST",
|
||||
per_request, transport, spec_model, model_evidence)
|
||||
return _finish(found, m, ev, per_request, transport, Evidence.ENV)
|
||||
|
||||
skipped = ""
|
||||
for var in ("OPENAI_BASE_URL", "OPENAI_API_BASE"):
|
||||
url = os.environ.get(var)
|
||||
if not url:
|
||||
continue
|
||||
if not _is_local_host(url):
|
||||
skipped = f" Ignored {var}={url!r}: not a local address."
|
||||
continue
|
||||
found = probe_endpoint(url.rstrip("/"), expect=engine, timeout=per_request,
|
||||
transport=transport)
|
||||
if found is not None and not found.blocked:
|
||||
return _finish(found, spec_model, model_evidence, per_request, transport)
|
||||
|
||||
include = [engine] if engine else None
|
||||
results = discover(include=include, timeout=per_request, budget=total,
|
||||
transport=transport)
|
||||
usable = [d for d in results if not d.blocked]
|
||||
if usable:
|
||||
return _finish(usable[0], spec_model, model_evidence, per_request, transport)
|
||||
if results:
|
||||
blocked = results[0]
|
||||
raise HostHeaderRejectedError(
|
||||
f"Local runtime at {blocked.base_url} returned HTTP 403 with an empty "
|
||||
f"body. Ollama rejects any request whose Host header is not localhost "
|
||||
f"or an IP address; set OLLAMA_HOST on the server to allow this origin.",
|
||||
base_url=blocked.base_url, reason="http_403", source="scan")
|
||||
|
||||
ports = {}
|
||||
for p in PROBES:
|
||||
for port in p.default_ports:
|
||||
ports.setdefault(port, []).append(p.engine.value)
|
||||
clause = ", ".join(f"{port} ({', '.join(names)})" for port, names in sorted(ports.items()))
|
||||
raise NoLocalEngineError(
|
||||
f"No local model runtime found. Probed {DEFAULT_TOTAL_BUDGET and '127.0.0.1'} "
|
||||
f"ports {clause} in {total:.1f}s; nothing answered. Start one "
|
||||
f"(e.g. `ollama serve`, then `ollama pull qwen3:0.6b`) or set "
|
||||
f"{ENV_BASE_URL} to its address.{skipped}")
|
||||
|
||||
|
||||
def resolve_or_none(spec=None, **kwargs):
|
||||
"""resolve() but returns None instead of raising LocalError."""
|
||||
try:
|
||||
return resolve(spec, **kwargs)
|
||||
except LocalError:
|
||||
return None
|
||||
@@ -1,233 +0,0 @@
|
||||
"""The LocalTarget value type and the pure helpers that build one.
|
||||
|
||||
Everything here is a pure function of its arguments: no network, no cache, no
|
||||
environment reads except the single documented model override. Resolution
|
||||
precedence lives in resolve.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from .capabilities import (ApiStyle, Cap, Evidence, LocalEngine, default_caps,
|
||||
parse_llama_cpp_props, parse_lm_studio_models,
|
||||
parse_ollama_capabilities)
|
||||
from .discover import (DEFAULT_PROBE_TIMEOUT, DEFAULT_TOTAL_BUDGET, PROBES,
|
||||
Discovery, default_transport, discover, probe_endpoint)
|
||||
from .errors import InvalidLocalSpecError, ModelNotAvailableError
|
||||
from .quirktable import Quirk, quirks_for
|
||||
|
||||
__all__ = ["LocalTarget", "build_target", "litellm_model_for", "parse_spec",
|
||||
"parse_ollama_host", "select_model", "DEFAULT_API_KEY",
|
||||
"ENV_BASE_URL", "ENV_ENGINE", "ENV_MODEL"]
|
||||
|
||||
DEFAULT_API_KEY = "local"
|
||||
ENV_BASE_URL = "PRAISONAI_LOCAL_BASE_URL"
|
||||
ENV_ENGINE = "PRAISONAI_LOCAL_ENGINE"
|
||||
ENV_MODEL = "PRAISONAI_LOCAL_MODEL"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalTarget:
|
||||
engine: LocalEngine
|
||||
base_url: str
|
||||
openai_base_url: str
|
||||
api_style: ApiStyle
|
||||
model_id: Optional[str]
|
||||
caps: frozenset
|
||||
quirks: frozenset
|
||||
litellm_model: Optional[str]
|
||||
api_key: str
|
||||
engine_version: Optional[str]
|
||||
# Tuples of pairs, not dicts: a dict field would make the generated __hash__
|
||||
# raise TypeError at runtime, and this type is meant to be hashable.
|
||||
evidence: tuple
|
||||
extra: tuple
|
||||
probed_at: float
|
||||
|
||||
def supports(self, cap) -> bool:
|
||||
return Cap(cap) in self.caps
|
||||
|
||||
def has_quirk(self, quirk) -> bool:
|
||||
return Quirk(quirk) in self.quirks
|
||||
|
||||
def evidence_for(self, field: str) -> Evidence:
|
||||
return dict(self.evidence).get(field, Evidence.UNKNOWN)
|
||||
|
||||
def get_extra(self, key: str, default=None):
|
||||
return dict(self.extra).get(key, default)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"engine": self.engine.value,
|
||||
"base_url": self.base_url,
|
||||
"openai_base_url": self.openai_base_url,
|
||||
"api_style": self.api_style.value,
|
||||
"model_id": self.model_id,
|
||||
"caps": sorted(c.value for c in self.caps),
|
||||
"quirks": sorted(q.value for q in self.quirks),
|
||||
"litellm_model": self.litellm_model,
|
||||
"engine_version": self.engine_version,
|
||||
"evidence": {k: v.value for k, v in self.evidence},
|
||||
"extra": dict(self.extra),
|
||||
}
|
||||
|
||||
|
||||
_LITELLM_PREFIX = {
|
||||
LocalEngine.OLLAMA: "ollama",
|
||||
LocalEngine.LM_STUDIO: "lm_studio",
|
||||
LocalEngine.VLLM: "hosted_vllm",
|
||||
}
|
||||
|
||||
|
||||
def litellm_model_for(engine, model_id) -> Optional[str]:
|
||||
"""Format a litellm model string for this engine. Pure.
|
||||
|
||||
OLLAMA maps to "ollama/", never "ollama_chat/": llm.py's _detect_provider
|
||||
resolves ollama_chat to "openai" and would silently lose OllamaAdapter.
|
||||
"""
|
||||
if not model_id:
|
||||
return None
|
||||
return f"{_LITELLM_PREFIX.get(LocalEngine(engine), 'openai')}/{model_id}"
|
||||
|
||||
|
||||
def parse_ollama_host(value: str) -> Optional[str]:
|
||||
"""Apply Ollama's own OLLAMA_HOST rules and return a base_url.
|
||||
|
||||
The asymmetry that catches people: a bare host defaults to port 11434, but an
|
||||
explicit http:// with no port means port 80 -- not 11434.
|
||||
"""
|
||||
if not value or not value.strip():
|
||||
return None
|
||||
raw = value.strip()
|
||||
if "://" in raw:
|
||||
parsed = urllib.parse.urlparse(raw)
|
||||
if not parsed.hostname:
|
||||
return None
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
# Keep any path prefix. A reverse proxy commonly mounts the server under
|
||||
# one (http://gateway:8000/ollama); rebuilding from host+port alone
|
||||
# silently rewrote the URL and sent every request to the proxy root.
|
||||
path = parsed.path.rstrip("/")
|
||||
return f"{parsed.scheme}://{parsed.hostname}:{port}{path}"
|
||||
if raw.isdigit():
|
||||
return f"http://127.0.0.1:{raw}"
|
||||
if raw.startswith(":") and raw[1:].isdigit():
|
||||
return f"http://127.0.0.1:{raw[1:]}"
|
||||
if raw.count(":") > 1 and not raw.startswith("["):
|
||||
return None # unbracketed IPv6
|
||||
host, _, port = raw.partition(":")
|
||||
return f"http://{host}:{port or 11434}"
|
||||
|
||||
|
||||
|
||||
|
||||
def parse_spec(spec):
|
||||
"""Split a spec into (engine, base_url, model_id); any element may be None."""
|
||||
if spec is None:
|
||||
return None, None, None
|
||||
text = str(spec).strip()
|
||||
if text.lower() in ("", "local", "auto"):
|
||||
return None, None, None
|
||||
if "://" in text:
|
||||
url, _, fragment = text.partition("#")
|
||||
return None, url.rstrip("/"), (fragment or None)
|
||||
if "/" in text:
|
||||
head, _, tail = text.partition("/")
|
||||
try:
|
||||
return LocalEngine(head.lower()), None, (tail or None)
|
||||
except ValueError:
|
||||
pass
|
||||
raise InvalidLocalSpecError(
|
||||
f"Cannot parse local spec {spec!r}. Expected None, 'local', "
|
||||
f"'<engine>/<model>', a base URL, or '<url>#<model>'."
|
||||
)
|
||||
|
||||
|
||||
def select_model(discovery: Discovery, *, transport=None,
|
||||
timeout: float = DEFAULT_PROBE_TIMEOUT) -> Optional[str]:
|
||||
"""Pick a model deterministically when the caller named none.
|
||||
|
||||
Never guesses a model that is not in the server's list.
|
||||
"""
|
||||
named = os.environ.get(ENV_MODEL)
|
||||
available = tuple(discovery.models)
|
||||
if named:
|
||||
if available and named not in available:
|
||||
raise ModelNotAvailableError(
|
||||
f"Model {named!r} is not served by {discovery.engine.value} at "
|
||||
f"{discovery.base_url}. Available: {', '.join(available) or '(none)'}.",
|
||||
model_id=named, available=available)
|
||||
return named
|
||||
if not available:
|
||||
return None
|
||||
meta = {m[0]: (m[1], m[2]) for m in (discovery.model_meta or ())}
|
||||
if len(available) == 1 and not meta:
|
||||
return available[0]
|
||||
|
||||
def rank(model):
|
||||
caps, modified = meta.get(model, ((), ""))
|
||||
# An embedding-only model cannot hold a conversation. Picking one because
|
||||
# it sorted first is the kind of silent nonsense this package exists to
|
||||
# prevent, so it is ranked last rather than merely unpreferred.
|
||||
embed_only = bool(caps) and "embedding" in caps and "completion" not in caps
|
||||
return (embed_only, "tools" not in caps, _negate_iso(modified), model)
|
||||
|
||||
chat_capable = [m for m in available if not rank(m)[0]]
|
||||
pool = chat_capable or list(available)
|
||||
return sorted(pool, key=rank)[0]
|
||||
|
||||
|
||||
def _negate_iso(value: str):
|
||||
"""Sort ISO-8601 timestamps newest-first inside an ascending sort key."""
|
||||
return tuple(-ord(c) for c in value) if value else ()
|
||||
|
||||
|
||||
def _openai_base(base_url: str) -> str:
|
||||
"""Return the OpenAI-compatible root for a base URL, without doubling /v1.
|
||||
|
||||
Users routinely paste the URL their server prints, which for LM Studio and
|
||||
vLLM already ends in /v1. Appending unconditionally produced /v1/v1.
|
||||
"""
|
||||
trimmed = base_url.rstrip("/")
|
||||
return trimmed if trimmed.endswith("/v1") else trimmed + "/v1"
|
||||
|
||||
|
||||
def build_target(discovery: Discovery, model_id, *, caps=None,
|
||||
caps_evidence=Evidence.TABLE, model_evidence=Evidence.SERVER,
|
||||
extra=()) -> LocalTarget:
|
||||
"""Assemble a LocalTarget from a Discovery. Pure: no network, no cache."""
|
||||
engine = discovery.engine
|
||||
resolved_caps = frozenset(caps) if caps is not None else default_caps(engine)
|
||||
quirks = quirks_for(engine, discovery.api_style, caps=resolved_caps)
|
||||
if Cap.TOOLS in resolved_caps and Quirk.TOOL_CALLS_ARRIVE_WHOLE_NOT_INCREMENTAL not in quirks:
|
||||
resolved_caps = resolved_caps | {Cap.STREAMING_WITH_TOOLS}
|
||||
evidence = {
|
||||
"engine": discovery.evidence,
|
||||
"base_url": discovery.evidence,
|
||||
"api_style": Evidence.TABLE,
|
||||
"caps": caps_evidence,
|
||||
"quirks": Evidence.TABLE,
|
||||
"model_id": model_evidence if model_id else Evidence.UNKNOWN,
|
||||
"engine_version": Evidence.SERVER if discovery.engine_version else Evidence.UNKNOWN,
|
||||
}
|
||||
return LocalTarget(
|
||||
engine=engine,
|
||||
base_url=discovery.base_url,
|
||||
openai_base_url=_openai_base(discovery.base_url),
|
||||
api_style=discovery.api_style,
|
||||
model_id=model_id,
|
||||
caps=resolved_caps,
|
||||
quirks=quirks,
|
||||
litellm_model=litellm_model_for(engine, model_id),
|
||||
api_key=DEFAULT_API_KEY,
|
||||
engine_version=discovery.engine_version,
|
||||
evidence=tuple(sorted(evidence.items())),
|
||||
extra=tuple(sorted(extra)),
|
||||
probed_at=time.time(),
|
||||
)
|
||||
@@ -46,7 +46,6 @@ __all__ = [
|
||||
"detect_root_uri",
|
||||
"probe",
|
||||
"DEFAULT_SERVERS",
|
||||
"resolve_servers",
|
||||
]
|
||||
|
||||
|
||||
@@ -65,7 +64,7 @@ def __getattr__(name: str):
|
||||
return locals()[name]
|
||||
|
||||
if name in ("LSPConfig", "detect_language", "detect_root_uri", "probe",
|
||||
"DEFAULT_SERVERS", "resolve_servers"):
|
||||
"DEFAULT_SERVERS"):
|
||||
from . import config
|
||||
return getattr(config, name)
|
||||
|
||||
|
||||
@@ -50,8 +50,7 @@ class LSPClient:
|
||||
command: Optional[str] = None,
|
||||
args: Optional[List[str]] = None,
|
||||
root_uri: Optional[str] = None,
|
||||
workspace_file: Optional[str] = None,
|
||||
servers: Optional[Dict[str, Dict]] = None
|
||||
workspace_file: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Initialize the LSP client.
|
||||
@@ -65,18 +64,12 @@ class LSPClient:
|
||||
from the nearest root marker (e.g. ``pyproject.toml``,
|
||||
``go.mod``) when ``root_uri`` is not given, so multi-root and
|
||||
monorepo setups initialise against the correct project root.
|
||||
servers: Optional user-supplied server registry (from project/global
|
||||
config) deep-merged over the built-in defaults, so a language
|
||||
that ships no default — or one whose default should be
|
||||
overridden — resolves from config instead of raising.
|
||||
"""
|
||||
self._servers = servers
|
||||
self.config = LSPConfig(
|
||||
language=language,
|
||||
command=command,
|
||||
args=args or [],
|
||||
root_uri=root_uri,
|
||||
servers=servers
|
||||
root_uri=root_uri
|
||||
)
|
||||
self._workspace_file = workspace_file
|
||||
|
||||
@@ -114,9 +107,7 @@ class LSPClient:
|
||||
# user-supplied custom server), and only surface the registry's install
|
||||
# hint when it matches the default server for the language.
|
||||
if not shutil.which(self.config.command):
|
||||
_, default_command, install_hint = probe(
|
||||
self.config.language, self._servers
|
||||
)
|
||||
_, default_command, install_hint = probe(self.config.language)
|
||||
hint = (
|
||||
f"; install with `{install_hint}`"
|
||||
if install_hint and self.config.command == default_command
|
||||
@@ -147,9 +138,7 @@ class LSPClient:
|
||||
# root), falling back to the CWD only when neither is available.
|
||||
root_uri = self.config.root_uri
|
||||
if root_uri is None and self._workspace_file is not None:
|
||||
root_uri = detect_root_uri(
|
||||
self._workspace_file, self.config.language, self._servers
|
||||
)
|
||||
root_uri = detect_root_uri(self._workspace_file, self.config.language)
|
||||
if root_uri is None:
|
||||
root_uri = path_to_uri(os.getcwd())
|
||||
|
||||
|
||||
@@ -56,54 +56,18 @@ DEFAULT_SERVERS = {
|
||||
}
|
||||
|
||||
|
||||
def resolve_servers(user_servers: Optional[Dict[str, Dict]] = None) -> Dict[str, Dict]:
|
||||
"""Return the effective server registry: ``DEFAULT_SERVERS`` merged with
|
||||
user-supplied entries.
|
||||
|
||||
User entries override built-ins per language key and add new languages, so
|
||||
shipped defaults keep working and a project can register a sixth language or
|
||||
pin a different server without editing package source. With no user config
|
||||
the result is byte-for-byte ``DEFAULT_SERVERS``. The merge is shallow at the
|
||||
per-language level (a user language entry replaces the built-in entry for
|
||||
that key) and keeps the helper lazy and dependency-free.
|
||||
|
||||
Command/args are treated as a unit: when a user swaps ``command`` for a
|
||||
different executable without supplying ``args``, the built-in ``args`` are
|
||||
dropped rather than inherited, so arguments belonging to the replaced server
|
||||
(e.g. ``--stdio`` for ``typescript-language-server``) are never passed to an
|
||||
unrelated replacement binary.
|
||||
"""
|
||||
if not user_servers:
|
||||
return DEFAULT_SERVERS
|
||||
merged = dict(DEFAULT_SERVERS)
|
||||
for language, server in user_servers.items():
|
||||
if not isinstance(server, dict):
|
||||
continue
|
||||
base = dict(merged.get(language, {}))
|
||||
overrides_command = (
|
||||
"command" in server and server["command"] != base.get("command")
|
||||
)
|
||||
base.update(server)
|
||||
if overrides_command and "args" not in server:
|
||||
base["args"] = []
|
||||
merged[language] = base
|
||||
return merged
|
||||
|
||||
|
||||
def _extension_map(servers: Optional[Dict[str, Dict]] = None) -> Dict[str, str]:
|
||||
def _extension_map() -> Dict[str, str]:
|
||||
"""Return a ``{extension: language}`` map derived from the registry."""
|
||||
mapping: Dict[str, str] = {}
|
||||
for language, server in resolve_servers(servers).items():
|
||||
for language, server in DEFAULT_SERVERS.items():
|
||||
for ext in server.get("extensions", []):
|
||||
mapping.setdefault(ext.lower(), language)
|
||||
return mapping
|
||||
|
||||
|
||||
def detect_language(
|
||||
file_path: str, servers: Optional[Dict[str, Dict]] = None
|
||||
) -> Optional[str]:
|
||||
def detect_language(file_path: str) -> Optional[str]:
|
||||
"""Resolve the LSP language id for ``file_path`` by extension, else ``None``."""
|
||||
return _extension_map(servers).get(os.path.splitext(file_path)[1].lower())
|
||||
return _extension_map().get(os.path.splitext(file_path)[1].lower())
|
||||
|
||||
|
||||
def path_to_uri(path: str) -> str:
|
||||
@@ -116,11 +80,7 @@ def path_to_uri(path: str) -> str:
|
||||
return Path(os.path.abspath(path)).as_uri()
|
||||
|
||||
|
||||
def detect_root_uri(
|
||||
file_path: str,
|
||||
language: Optional[str] = None,
|
||||
servers: Optional[Dict[str, Dict]] = None,
|
||||
) -> Optional[str]:
|
||||
def detect_root_uri(file_path: str, language: Optional[str] = None) -> Optional[str]:
|
||||
"""Discover the workspace root for ``file_path`` from the nearest root marker.
|
||||
|
||||
Walks up from the file's directory looking for a language-appropriate root
|
||||
@@ -128,9 +88,8 @@ def detect_root_uri(
|
||||
``.git`` …). Returns a ``file://`` URI for the nearest matching directory,
|
||||
or ``None`` when no marker is found so the caller can fall back to the CWD.
|
||||
"""
|
||||
registry = resolve_servers(servers)
|
||||
language = language or detect_language(file_path, servers)
|
||||
server = registry.get(language) if language else None
|
||||
language = language or detect_language(file_path)
|
||||
server = DEFAULT_SERVERS.get(language) if language else None
|
||||
markers = server.get("root_markers", []) if server else [".git"]
|
||||
start = os.path.dirname(os.path.abspath(file_path))
|
||||
current = start
|
||||
@@ -144,9 +103,7 @@ def detect_root_uri(
|
||||
current = parent
|
||||
|
||||
|
||||
def probe(
|
||||
language: str, servers: Optional[Dict[str, Dict]] = None
|
||||
) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||
def probe(language: str) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||
"""Check whether the language server for ``language`` is on ``PATH``.
|
||||
|
||||
Returns ``(available, command, install_hint)``. ``available`` is ``True``
|
||||
@@ -154,7 +111,7 @@ def probe(
|
||||
``command`` and ``install_hint`` describe what is missing and how to install
|
||||
it so the degradation can be surfaced instead of silently swallowed.
|
||||
"""
|
||||
server = resolve_servers(servers).get(language)
|
||||
server = DEFAULT_SERVERS.get(language)
|
||||
if not server:
|
||||
return False, None, None
|
||||
command = server["command"]
|
||||
@@ -172,23 +129,13 @@ class LSPConfig:
|
||||
root_uri: Optional[str] = None
|
||||
initialization_options: Dict = field(default_factory=dict)
|
||||
timeout: float = 30.0
|
||||
servers: Optional[Dict[str, Dict]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
# Use default server if not specified, consulting the effective registry
|
||||
# (built-ins deep-merged with any user-supplied ``servers``) so a
|
||||
# configured sixth language resolves instead of raising.
|
||||
# Use default server if not specified
|
||||
if self.command is None:
|
||||
registry = resolve_servers(self.servers)
|
||||
if self.language in registry:
|
||||
server = registry[self.language]
|
||||
if self.language in DEFAULT_SERVERS:
|
||||
server = DEFAULT_SERVERS[self.language]
|
||||
self.command = server["command"]
|
||||
self.args = list(server.get("args", []))
|
||||
if not self.initialization_options and server.get(
|
||||
"initialization_options"
|
||||
):
|
||||
self.initialization_options = dict(
|
||||
server["initialization_options"]
|
||||
)
|
||||
self.args = list(server["args"])
|
||||
else:
|
||||
raise ValueError(f"No default server for language: {self.language}")
|
||||
@@ -29,7 +29,7 @@ Memory Providers:
|
||||
- RulesManager: Rules/instructions file management
|
||||
- AutoMemory: Automatic memory extraction from conversations
|
||||
- WorkflowManager: Multi-step workflow execution
|
||||
- HooksManager: Standalone, opt-in pre/post operation hooks (call .execute() yourself; not auto-wired into the agent pipeline)
|
||||
- HooksManager: Pre/post operation hooks
|
||||
- DocsManager: Documentation context management
|
||||
- MCPConfigManager: MCP server configuration management
|
||||
"""
|
||||
|
||||
@@ -424,35 +424,6 @@ class MongoDBMemoryAdapter:
|
||||
self.pymongo = pymongo
|
||||
|
||||
config = kwargs.get("config", {})
|
||||
|
||||
# Resolve the embedding model once, at construction. Previously
|
||||
# _get_embedding hardcoded "text-embedding-3-small" and __init__ stored
|
||||
# nothing about embeddings, so an agent configured entirely against a
|
||||
# local runtime still embedded against OpenAI with no way to say
|
||||
# otherwise. Accepts both the flat key and the `embedder` block used
|
||||
# elsewhere in the memory layer.
|
||||
#
|
||||
# The `embedder` block carries the provider separately from the model
|
||||
# (e.g. {"provider": "ollama", "config": {"model": "mxbai-embed-large"}}),
|
||||
# so recombine them into the litellm-style "<provider>/<model>" litellm
|
||||
# needs to route the call. Dropping the provider would send a bare local
|
||||
# model name to the OpenAI default endpoint, the embedding would fail,
|
||||
# and _get_embedding swallows that into "no vector" — documents stored
|
||||
# without embeddings and vector search silently degraded to text search.
|
||||
# Mirrors knowledge/adapters/mongodb_adapter._init_embedding_model.
|
||||
_embedder = config.get("embedder") or {}
|
||||
_embedder_model = (_embedder.get("config") or {}).get("model")
|
||||
if _embedder_model:
|
||||
_provider = _embedder.get("provider")
|
||||
if _provider and _provider != "openai":
|
||||
_embedder_model = f"{_provider}/{_embedder_model}"
|
||||
self.embedding_model = (
|
||||
kwargs.get("embedding_model")
|
||||
or config.get("embedding_model")
|
||||
or _embedder_model
|
||||
or "text-embedding-3-small"
|
||||
)
|
||||
|
||||
connection_string = config.get("connection_string", "mongodb://localhost:27017/")
|
||||
database_name = config.get("database", "praisonai")
|
||||
self.use_vector_search = config.get("use_vector_search", False)
|
||||
@@ -613,7 +584,7 @@ class MongoDBMemoryAdapter:
|
||||
"""Get embedding for text."""
|
||||
try:
|
||||
from praisonaiagents.embedding import embedding
|
||||
result = embedding(text, model=self.embedding_model)
|
||||
result = embedding(text, model="text-embedding-3-small")
|
||||
return result.embeddings[0] if result.embeddings else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -4,17 +4,6 @@ Cascade Hooks System for PraisonAI Agents.
|
||||
Provides event hooks similar to Windsurf's Cascade Hooks,
|
||||
allowing custom actions before/after agent operations.
|
||||
|
||||
.. note::
|
||||
``HooksManager`` is a **standalone, opt-in utility**: it is NOT auto-wired
|
||||
into the agent/tool execution pipeline. You must construct it and call
|
||||
:meth:`HooksManager.execute` yourself at the point where you want a hook to
|
||||
run (e.g. in a custom tool or wrapper). For hooks that fire automatically
|
||||
around agent tool/LLM calls, use the live ``praisonaiagents.hooks`` package
|
||||
instead: register handlers on a ``HookRegistry`` (e.g. via
|
||||
``@registry.on(HookEvent.BEFORE_TOOL)``) and pass that registry to the
|
||||
``Agent`` through its ``hooks`` argument (the ``Agent`` builds the
|
||||
``HookRunner`` internally).
|
||||
|
||||
Features:
|
||||
- Pre/post hooks for read, write, command, prompt operations
|
||||
- JSON configuration (.praisonai/hooks.json)
|
||||
@@ -98,12 +87,6 @@ class HooksManager:
|
||||
Hooks are configured in .praisonai/hooks.json and can execute
|
||||
scripts before/after agent operations.
|
||||
|
||||
This is a standalone, opt-in utility. It does not automatically
|
||||
intercept agent file/command/prompt operations; the caller must
|
||||
invoke :meth:`execute` at the desired point. For hooks that fire
|
||||
automatically around agent tool/LLM calls, use the
|
||||
``praisonaiagents.hooks`` package instead.
|
||||
|
||||
Example:
|
||||
```python
|
||||
hooks = HooksManager(workspace_path="/path/to/project")
|
||||
|
||||
@@ -19,7 +19,6 @@ from .stores import (
|
||||
ImprovementStore,
|
||||
LearnEntry,
|
||||
)
|
||||
from ...llm.model_providers import default_auxiliary_model
|
||||
|
||||
|
||||
class LearnManager:
|
||||
@@ -556,7 +555,7 @@ Return empty arrays if nothing is found for a category."""
|
||||
from ...llm import LLM
|
||||
import json
|
||||
|
||||
model = default_auxiliary_model(llm)
|
||||
model = llm or "gpt-4o-mini"
|
||||
llm_instance = LLM(model=model)
|
||||
response = llm_instance.get_response(
|
||||
prompt=extraction_prompt,
|
||||
@@ -592,7 +591,7 @@ Return empty arrays if nothing is found for a category."""
|
||||
|
||||
for improvement in extracted.get("improvements", []):
|
||||
if improvement and "improvements" in self._stores:
|
||||
self.capture_improvement(improvement, area="auto_extraction")
|
||||
self.capture_improvement(improvement, source="auto_extraction")
|
||||
stored["improvements"] += 1
|
||||
|
||||
return {
|
||||
@@ -624,7 +623,7 @@ Return empty arrays if nothing is found for a category."""
|
||||
Dictionary with extracted learnings
|
||||
"""
|
||||
if not messages:
|
||||
return {"persona": [], "insights": [], "patterns": [], "improvements": [], "stored": {}}
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}}
|
||||
|
||||
# Build conversation text
|
||||
conversation_text = "\n".join([
|
||||
@@ -641,14 +640,12 @@ Extract the following (if present):
|
||||
1. USER PREFERENCES: Things the user likes, dislikes, prefers, or their style
|
||||
2. INSIGHTS: Observations about the user's domain, work, or context
|
||||
3. PATTERNS: Recurring behaviors or request patterns
|
||||
4. IMPROVEMENTS: Concrete proposals to improve future responses
|
||||
|
||||
Return JSON:
|
||||
{{
|
||||
"persona": ["preference 1", "preference 2"],
|
||||
"insights": ["insight 1", "insight 2"],
|
||||
"patterns": ["pattern 1", "pattern 2"],
|
||||
"improvements": ["improvement 1", "improvement 2"]
|
||||
"patterns": ["pattern 1", "pattern 2"]
|
||||
}}
|
||||
|
||||
Only include items that are clearly evident from the conversation.
|
||||
@@ -660,7 +657,7 @@ Return empty arrays if nothing is found for a category."""
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
model = default_auxiliary_model(llm)
|
||||
model = llm or "gpt-4o-mini"
|
||||
llm_instance = LLM(model=model)
|
||||
|
||||
# Run sync LLM call in executor for async compatibility
|
||||
@@ -679,9 +676,9 @@ Return empty arrays if nothing is found for a category."""
|
||||
elif isinstance(response, dict):
|
||||
extracted = response
|
||||
else:
|
||||
extracted = {"persona": [], "insights": [], "patterns": [], "improvements": []}
|
||||
extracted = {"persona": [], "insights": [], "patterns": []}
|
||||
|
||||
stored = {"persona": 0, "insights": 0, "patterns": 0, "improvements": 0}
|
||||
stored = {"persona": 0, "insights": 0, "patterns": 0}
|
||||
|
||||
for preference in extracted.get("persona", []):
|
||||
if preference and "persona" in self._stores:
|
||||
@@ -698,16 +695,10 @@ Return empty arrays if nothing is found for a category."""
|
||||
self.capture_pattern(pattern, pattern_type="auto_extracted")
|
||||
stored["patterns"] += 1
|
||||
|
||||
for improvement in extracted.get("improvements", []):
|
||||
if improvement and "improvements" in self._stores:
|
||||
self.capture_improvement(improvement, area="auto_extraction")
|
||||
stored["improvements"] += 1
|
||||
|
||||
return {
|
||||
"persona": extracted.get("persona", []),
|
||||
"insights": extracted.get("insights", []),
|
||||
"patterns": extracted.get("patterns", []),
|
||||
"improvements": extracted.get("improvements", []),
|
||||
"stored": stored,
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ from .protocols import MemoryProtocol
|
||||
from .adapters import get_memory_adapter, get_first_available_memory_adapter
|
||||
from .adapters.sqlite_adapter import SqliteMemoryAdapter
|
||||
from .adapters.factories import sanitize_chroma_metadata
|
||||
from ..llm.model_providers import default_auxiliary_model
|
||||
|
||||
# Disable litellm telemetry before any imports
|
||||
os.environ["LITELLM_TELEMETRY"] = "False"
|
||||
@@ -2172,7 +2171,7 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
import litellm
|
||||
|
||||
# Convert model name if it's in litellm format
|
||||
model_name = default_auxiliary_model(llm)
|
||||
model_name = llm or "gpt-4o-mini"
|
||||
|
||||
response = litellm.completion(
|
||||
model=model_name,
|
||||
@@ -2186,24 +2185,10 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
elif openai_available:
|
||||
# Fallback to OpenAI client
|
||||
from openai import OpenAI
|
||||
# Pass the endpoint explicitly. A bare OpenAI() happens to work
|
||||
# when OPENAI_BASE_URL is exported, but silently ignores a
|
||||
# base_url supplied through config -- which is the shape the rest
|
||||
# of the memory layer uses.
|
||||
_client_kwargs = {}
|
||||
_cfg = getattr(self, "cfg", None) or {}
|
||||
_base_url = _cfg.get("base_url")
|
||||
if not _base_url and isinstance(_cfg.get("config"), dict):
|
||||
_base_url = _cfg["config"].get("base_url")
|
||||
_base_url = _base_url or os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE")
|
||||
if _base_url:
|
||||
_client_kwargs["base_url"] = _base_url
|
||||
# Local servers reject an empty key; any non-empty value works.
|
||||
_client_kwargs["api_key"] = os.getenv("OPENAI_API_KEY") or "local"
|
||||
client = OpenAI(**_client_kwargs)
|
||||
client = OpenAI()
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=default_auxiliary_model(llm),
|
||||
model=llm or "gpt-4o-mini",
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": custom_prompt or default_prompt
|
||||
|
||||
@@ -65,20 +65,12 @@ class SearchMixin:
|
||||
if min_quality is not None:
|
||||
results = [r for r in results if r.get("metadata", {}).get("quality", 0.0) >= min_quality]
|
||||
|
||||
# Apply metadata filtering (defense-in-depth). Auto-derive a user_id
|
||||
# filter so per-user isolation holds even for adapters that accept
|
||||
# user_id but ignore it in their own query (Chroma/RAG, SQLite, in-memory).
|
||||
# An explicit user_id always wins so a conflicting
|
||||
# metadata_filter["user_id"] can never widen the scope to another
|
||||
# tenant's data (mirrors Memory._build_metadata_filter).
|
||||
effective_filter = dict(metadata_filter or {})
|
||||
if user_id:
|
||||
effective_filter["user_id"] = user_id
|
||||
if effective_filter:
|
||||
# Apply metadata filtering if specified
|
||||
if metadata_filter:
|
||||
filtered_results = []
|
||||
for result in results:
|
||||
metadata = result.get("metadata", {})
|
||||
if all(metadata.get(k) == v for k, v in effective_filter.items()):
|
||||
if all(metadata.get(k) == v for k, v in metadata_filter.items()):
|
||||
filtered_results.append(result)
|
||||
results = filtered_results
|
||||
|
||||
@@ -132,20 +124,12 @@ class SearchMixin:
|
||||
if min_quality is not None:
|
||||
results = [r for r in results if r.get("metadata", {}).get("quality", 0.0) >= min_quality]
|
||||
|
||||
# Apply metadata filtering (defense-in-depth). Auto-derive a user_id
|
||||
# filter so per-user isolation holds even for adapters that accept
|
||||
# user_id but ignore it in their own query (Chroma/RAG, SQLite, in-memory).
|
||||
# An explicit user_id always wins so a conflicting
|
||||
# metadata_filter["user_id"] can never widen the scope to another
|
||||
# tenant's data (mirrors Memory._build_metadata_filter).
|
||||
effective_filter = dict(metadata_filter or {})
|
||||
if user_id:
|
||||
effective_filter["user_id"] = user_id
|
||||
if effective_filter:
|
||||
# Apply metadata filtering if specified
|
||||
if metadata_filter:
|
||||
filtered_results = []
|
||||
for result in results:
|
||||
metadata = result.get("metadata", {})
|
||||
if all(metadata.get(k) == v for k, v in effective_filter.items()):
|
||||
if all(metadata.get(k) == v for k, v in metadata_filter.items()):
|
||||
filtered_results.append(result)
|
||||
results = filtered_results
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Model-family-aware agent harness, plus the test doubles for model calls.
|
||||
"""Model-family-aware agent harness.
|
||||
|
||||
Selects, by model id/family, a base/harness system-prompt fragment and a
|
||||
preferred file-edit format (string-replace ``edit_file`` vs ``apply_patch``).
|
||||
@@ -14,23 +14,6 @@ Usage::
|
||||
profile = resolve_harness("claude-opus-4")
|
||||
profile.base_prompt # optional prompt fragment (str or None)
|
||||
profile.preferred_edit_format # "apply_patch" | "edit_file" | None
|
||||
|
||||
Testing::
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.model_harness import ScriptedModel, allow_model_requests
|
||||
|
||||
allow_model_requests(False) # nothing in this suite may reach a provider
|
||||
|
||||
model = ScriptedModel(["Paris."])
|
||||
agent = Agent(instructions="You are a geography bot.", llm=model)
|
||||
assert agent.start("Capital of France?") == "Paris."
|
||||
assert model.requests[0].system_prompt.startswith("You are a geography bot.")
|
||||
|
||||
:class:`ScriptedModel` returns scripted replies in order, can script a tool call
|
||||
followed by a final answer, records every request the agent sent, and raises
|
||||
:class:`ScriptExhausted` when the script runs out. :func:`allow_model_requests`
|
||||
is the global "no real model calls" switch.
|
||||
"""
|
||||
|
||||
from praisonaiagents._lazy import create_lazy_getattr
|
||||
@@ -50,41 +33,6 @@ _LAZY_IMPORTS = {
|
||||
"praisonaiagents.model_harness.profiles",
|
||||
"DEFAULT_PROFILE",
|
||||
),
|
||||
# Test doubles and the real-request guard.
|
||||
"ScriptedModel": ("praisonaiagents.model_harness.scripted", "ScriptedModel"),
|
||||
"ScriptedReply": ("praisonaiagents.model_harness.scripted", "ScriptedReply"),
|
||||
"ScriptedToolCall": (
|
||||
"praisonaiagents.model_harness.scripted",
|
||||
"ScriptedToolCall",
|
||||
),
|
||||
"RecordedRequest": (
|
||||
"praisonaiagents.model_harness.scripted",
|
||||
"RecordedRequest",
|
||||
),
|
||||
"ScriptExhausted": (
|
||||
"praisonaiagents.model_harness.scripted",
|
||||
"ScriptExhausted",
|
||||
),
|
||||
"ScriptedModelError": (
|
||||
"praisonaiagents.model_harness.scripted",
|
||||
"ScriptedModelError",
|
||||
),
|
||||
"allow_model_requests": (
|
||||
"praisonaiagents.model_harness.guard",
|
||||
"allow_model_requests",
|
||||
),
|
||||
"model_requests_allowed": (
|
||||
"praisonaiagents.model_harness.guard",
|
||||
"model_requests_allowed",
|
||||
),
|
||||
"no_model_requests": (
|
||||
"praisonaiagents.model_harness.guard",
|
||||
"no_model_requests",
|
||||
),
|
||||
"ModelRequestBlocked": (
|
||||
"praisonaiagents.model_harness.guard",
|
||||
"ModelRequestBlocked",
|
||||
),
|
||||
}
|
||||
|
||||
__all__ = list(_LAZY_IMPORTS.keys())
|
||||
|
||||
Loaded 100 of 946 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user