mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-09 03:39:37 -10:00
Compare commits
176
Commits
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__create_comment,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__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"
|
||||
if [[ -n "$INPUT_ALLOWED_TOOLS" ]]; then
|
||||
TOOLS="$TOOLS,$INPUT_ALLOWED_TOOLS"
|
||||
fi
|
||||
@@ -328,6 +328,7 @@ 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"
|
||||
timeout $TIMEOUT_SECONDS claude "${CMD_ARGS[@]}" < "$PROMPT_FILE" \
|
||||
| tee "$GITHUB_WORKSPACE/.claude-assess-output.jsonl"
|
||||
|
||||
echo "✅ Claude Code execution completed"
|
||||
@@ -213,6 +213,33 @@ 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(
|
||||
@@ -257,8 +284,13 @@ 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];
|
||||
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase, claudeFinishedAfterRebase];
|
||||
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(
|
||||
@@ -325,5 +357,26 @@ 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);
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
(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);
|
||||
});
|
||||
@@ -315,7 +315,16 @@ function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin =
|
||||
function finalClaudeCompletedOnSha(comments, headPushedAt) {
|
||||
if (!hasFinalClaudeReviewTrigger(comments)) return false;
|
||||
if (isStaleFinalAfterPush(comments, headPushedAt)) return false;
|
||||
return true;
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
const FINAL_CLAUDE_REVIEW_BODY =
|
||||
@@ -478,6 +487,7 @@ 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)) {
|
||||
@@ -514,8 +524,25 @@ 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({
|
||||
@@ -1002,18 +1029,33 @@ 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 (!(c.body || '').includes('MERGE_GATE_VERDICT:')) return false;
|
||||
if (!isMergeGateVerdictComment(c.body)) return false;
|
||||
if (excludeAutomatedFallback && isAutomatedFallbackVerdict(c.body)) return false;
|
||||
const created = new Date(c.created_at).getTime();
|
||||
if (minTime && created < minTime) return false;
|
||||
@@ -1023,8 +1065,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 (body.includes('MERGE_GATE_VERDICT: APPROVE')) return 'APPROVE';
|
||||
if (body.includes('MERGE_GATE_VERDICT: BLOCK')) return 'BLOCK';
|
||||
if (/MERGE_GATE_VERDICT:\s*APPROVE\b/.test(body)) return 'APPROVE';
|
||||
if (/MERGE_GATE_VERDICT:\s*BLOCK\b/.test(body)) return 'BLOCK';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1095,10 +1137,14 @@ 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,17 +201,26 @@ 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');
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: skipped[0].pr, body,
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
} else if (inputPr && candidates.length === 1) {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: candidates[0].pr_number,
|
||||
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
|
||||
});
|
||||
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 ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 10 }, core);
|
||||
await ps.syncOpenPullRequests(
|
||||
github, owner, repo, { maxPrs: 10, dispatchMergeGate: false }, core
|
||||
);
|
||||
|
||||
claude-assess:
|
||||
needs: scan-candidates
|
||||
@@ -267,6 +276,7 @@ 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 }}
|
||||
@@ -308,15 +318,97 @@ jobs:
|
||||
Write
|
||||
Replace
|
||||
allowed_tools: |
|
||||
View
|
||||
GlobTool
|
||||
GrepTool
|
||||
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
|
||||
Read
|
||||
timeout_minutes: 15
|
||||
|
||||
- name: Post fallback BLOCK if Opus did not comment
|
||||
- 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
|
||||
id: fallback_verdict
|
||||
if: steps.assess.outcome != 'success'
|
||||
if: always() && steps.assess.conclusion != 'skipped'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -332,20 +424,21 @@ jobs:
|
||||
ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (existing) {
|
||||
core.info(`Opus verdict already posted: ${existing}`);
|
||||
core.info(`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: BLOCK',
|
||||
`MERGE_GATE_VERDICT: ${verdict}`,
|
||||
'',
|
||||
'Automated fallback — Opus merge gate assessment did not complete.',
|
||||
'Automated fallback — Opus merge gate did not post a PR comment.',
|
||||
];
|
||||
if (check.ready) {
|
||||
lines.push('Deterministic gates passed, but Opus MERGE_GATE_VERDICT is required before merge.');
|
||||
lines.push('All deterministic merge gates passed.');
|
||||
} else {
|
||||
lines.push('Blockers:', ...check.reasons.map((r) => `- ${r}`));
|
||||
}
|
||||
@@ -353,9 +446,10 @@ jobs:
|
||||
owner, repo, issue_number: prNumber,
|
||||
body: lines.join('\n'),
|
||||
});
|
||||
core.info('Posted fallback BLOCK — Opus verdict required');
|
||||
core.info(`Posted fallback verdict: ${verdict}`);
|
||||
|
||||
- name: Require Opus MERGE_GATE_VERDICT
|
||||
- name: Capture Opus MERGE_GATE_VERDICT
|
||||
id: capture_verdict
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -371,14 +465,14 @@ jobs:
|
||||
const verdict = mergeGate.findMergeGateVerdict(
|
||||
comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
core.setOutput('verdict', verdict || 'NONE');
|
||||
if (!verdict) {
|
||||
core.setFailed(
|
||||
'Opus merge gate assessment must post MERGE_GATE_VERDICT on this PR (automated fallback APPROVE is not accepted).'
|
||||
core.warning(
|
||||
'Opus MERGE_GATE_VERDICT not posted on PR (GitHub MCP tools may be blocked). Merge waits for APPROVE.'
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
core.info(`Opus merge gate verdict: ${verdict}`);
|
||||
}
|
||||
core.info(`Opus merge gate verdict: ${verdict}`);
|
||||
core.setOutput('verdict', verdict);
|
||||
|
||||
- name: Remove merge gate active label
|
||||
if: always()
|
||||
@@ -399,10 +493,6 @@ 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: |
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
# 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
|
||||
@@ -0,0 +1,164 @@
|
||||
# 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
|
||||
@@ -36,6 +36,10 @@ on:
|
||||
# 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'
|
||||
pull_request:
|
||||
paths: *watched
|
||||
|
||||
@@ -66,7 +70,27 @@ 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
|
||||
@@ -85,11 +109,11 @@ jobs:
|
||||
# rather than buried in another job's log.
|
||||
#
|
||||
# It needs only THIS package's install. praisonai-ts's own dependencies are
|
||||
# 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.
|
||||
# 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.
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -98,6 +122,11 @@ 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
|
||||
@@ -144,6 +173,17 @@ jobs:
|
||||
# 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: |
|
||||
@@ -203,6 +243,17 @@ jobs:
|
||||
# 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: |
|
||||
|
||||
@@ -5,6 +5,8 @@ name: Parity Gate
|
||||
# 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
|
||||
@@ -77,3 +79,21 @@ jobs:
|
||||
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
|
||||
@@ -0,0 +1,70 @@
|
||||
# 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,6 +759,126 @@ 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)
|
||||
@@ -836,7 +956,7 @@ jobs:
|
||||
# TEST SUMMARY
|
||||
# ============================================
|
||||
test-summary:
|
||||
needs: [smoke, main]
|
||||
needs: [smoke, main, extended-ollama]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
@@ -46,15 +46,45 @@ 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 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 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
|
||||
echo "changes=false" >> $GITHUB_OUTPUT
|
||||
echo "No changes to parity trackers"
|
||||
else
|
||||
@@ -68,7 +98,7 @@ 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'
|
||||
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'
|
||||
commit_user_name: "MervinPraison"
|
||||
commit_user_email: "[email protected]"
|
||||
commit_author: "MervinPraison <[email protected]>"
|
||||
+4
-1
@@ -115,4 +115,7 @@ Cargo.lock
|
||||
src/praisonai/build*.egg-info/
|
||||
src/praisonai/PraisonAI.egg-info/
|
||||
jobs/*
|
||||
*.egg-info
|
||||
*.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/
|
||||
+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`:
|
||||
|
||||
- `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)
|
||||
- `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
|
||||
|
||||
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 (`ts-sdk/`)
|
||||
- **Rust SDK** — High-performance Rust runtime (`rust-sdk/`)
|
||||
- **UI** — Web UI (`ui/`)
|
||||
- **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/`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -35,6 +35,19 @@ 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">
|
||||
@@ -1099,13 +1112,39 @@ 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 server first
|
||||
# Start Ollama and pull a model
|
||||
ollama serve
|
||||
|
||||
# Set environment variable
|
||||
export OPENAI_BASE_URL=http://localhost:11434/v1
|
||||
ollama pull llama3.2
|
||||
```
|
||||
|
||||
```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)</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">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,6 +387,7 @@ 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
|
||||
|
||||
@@ -925,17 +926,23 @@ Methods:
|
||||
|
||||
Types/Exports:
|
||||
```ts
|
||||
export { Agent, AgentTeam, Agents, PraisonAIAgents, Router } from "./agent";
|
||||
export type { AgentEvent, AgentMessage, AgentStreamOptions, AgentTeamConfig, PraisonAIAgentsConfig, SimpleAgentConfig, SimpleRouteConfig, SimpleRouterConfig, StopReason } from "./agent";
|
||||
export { Agent, AgentTeam, Agents, CodeAgent, EmbeddingAgent, OCRAgent, PraisonAIAgents, RealtimeAgent, Router, TASK_STATUS, VideoAgent, VisionAgent, createCodeAgent, createEmbeddingAgent, createOCRAgent, createRealtimeAgent, createVideoAgent, createVisionAgent } from "./agent";
|
||||
export type { AgentChatOptions, AgentEvent, AgentGuardrailEntry, AgentGuardrailFunction, AgentGuardrailInput, AgentHooksInput, AgentMemoryStore, AgentMessage, AgentRetryConfig, AgentStreamOptions, AgentTeamConfig, AgentTeamProcess, AgentTeamStartDictOptions, AgentTeamStartOptions, AgentTeamStartOptionsInput, AgentWebConfig, PraisonAIAgentsConfig, SimpleAgentConfig, SimpleRouteConfig, SimpleRouterConfig, StopReason, TaskCallback, TaskGuardrail, TaskOnError } from "./agent";
|
||||
export { AudioAgent, createAudioAgent } from "./agent/audio";
|
||||
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 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 { ImageAgent, createImageAgent } from "./agent/image";
|
||||
export { PromptExpanderAgent, createPromptExpanderAgent } from "./agent/prompt-expander";
|
||||
export { QueryRewriterAgent, createQueryRewriterAgent } from "./agent/query-rewriter";
|
||||
export { DeepResearchAgent, createDeepResearchAgent } from "./agent/research";
|
||||
export { DeepResearchAgent, Provider, createDeepResearchAgent } from "./agent/research";
|
||||
export { RetryBackoffConfig, interruptibleSleep, jitteredBackoff } from "./agent/retry-utils";
|
||||
export type { JitteredBackoffOptions, RetryBackoffConfigOptions } from "./agent/retry-utils";
|
||||
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
|
||||
@@ -952,6 +959,7 @@ 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
|
||||
@@ -973,23 +981,27 @@ export { // Autonomy Mode
|
||||
export { // Classes
|
||||
DictCondition, // Functions
|
||||
evaluateCondition, // Types
|
||||
type ConditionProtocol, ExpressionCondition, FunctionCondition, andConditions, createCondition, notCondition, orConditions } from "./conditions";
|
||||
type ConditionProtocol, ExpressionCondition, FunctionCondition, andConditions, createCondition, evaluate_condition, 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, 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";
|
||||
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";
|
||||
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 { // Types
|
||||
type DisplayCallback, DisplayFlow, DisplayFlowConfig, asyncDisplayCallbacks, clearDisplayCallbacks, clearErrorLogs, displayError, displayGenerating, displayInstruction, displayInteraction, displaySelfReflection, displayToolCall, errorLogs, logError, registerDisplay, syncDisplayCallbacks } from "./display";
|
||||
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 { // Functions
|
||||
embed, // Types
|
||||
type EmbeddingResult, aembed, aembedding, aembeddings, cosineSimilarity, embedding, embeddings, euclideanDistance, getDimensions, normalizeEmbedding, setEmbeddingConfig } from "./embeddings";
|
||||
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";
|
||||
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";
|
||||
@@ -998,7 +1010,7 @@ export { // Bot types
|
||||
FailoverManager, // Enums
|
||||
SandboxStatus, // Gateway types
|
||||
type GatewayConfig, // Other types
|
||||
type ProviderStatus, AutonomyLevel, RagRetrievalPolicy } from "./gateway";
|
||||
type ProviderStatus, AutonomyLevel, GatewayEventType, 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
|
||||
@@ -1010,7 +1022,11 @@ 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
|
||||
@@ -1018,14 +1034,22 @@ 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
|
||||
@@ -1033,15 +1057,6 @@ 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";
|
||||
@@ -1049,36 +1064,51 @@ export { // Classes
|
||||
Plugin, // Enums
|
||||
PluginHook, // Functions
|
||||
getPluginManager, // Interfaces
|
||||
type PluginMetadata, FunctionPlugin, PluginManager, PluginParseError, PluginType, disablePlugins, discoverAndLoadPlugins, discoverPlugins, enablePlugins, ensurePluginDir, getDefaultPluginDirs, getPluginTemplate, isPluginEnabled, listPlugins, loadPlugin, parsePluginHeader, parsePluginHeaderFromFile } from "./plugins";
|
||||
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";
|
||||
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, workflows } from "./protocols";
|
||||
type ToolDefinition, A2A, A2ARole, AutoRagAgent, AutoRetrievalPolicy, DEFAULT_AUTO_KEYWORDS, GUARDRAIL_POLICY_PRESETS, Tools, memory, obs, resolveGuardrailPolicies, resolve_guardrail_policies, 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, SkillManager, createSkillLoader, createSkillManager, createSkillProperties, parseSkillFile } from "./skills";
|
||||
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";
|
||||
export { AgentTask, AgentTaskConfig, BaseTask, createTaskOutput } from "./task";
|
||||
export { // Python parity additions
|
||||
MinimalTelemetry, AgentTelemetry, PerformanceMonitor, TelemetryCollector, TelemetryIntegration, cleanupTelemetry, cleanupTelemetryResources, createAgentTelemetry, createConsoleSink, createHTTPSink, createPerformanceMonitor, createTelemetryIntegration, disablePerformanceMode, disableTelemetry, enablePerformanceMode, enableTelemetry, getMinimalTelemetry, getTelemetry } from "./telemetry";
|
||||
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";
|
||||
export { // Subagent Tool (agent-as-tool pattern)
|
||||
SubagentTool, BaseTool, FunctionTool, ToolRegistry, ToolResult, ToolValidationError, createDelegator, createSubagentTool, createSubagentTools, createTool, getRegistry, getTool, registerTool, tool, validateTool } from "./tools";
|
||||
export { airweaveSearch, bedrockBrowserClick, bedrockBrowserFill, bedrockBrowserNavigate, bedrockCodeInterpreter, codeExecution, codeMode, createCustomTool, 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, 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, trackWorkflow } from "./trace";
|
||||
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";
|
||||
export { // New: Python-parity Loop and Repeat classes
|
||||
Loop, // Task class
|
||||
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";
|
||||
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";
|
||||
```
|
||||
|
||||
# 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.5" \
|
||||
"praisonai>=4.7.6" \
|
||||
"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.5" \
|
||||
"praisonai>=4.7.6" \
|
||||
"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.5" \
|
||||
"praisonai>=4.7.6" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
> **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,50 +18,50 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 511 |
|
||||
| ✅ Agent | 22 | 56 | 17728 |
|
||||
| ✅ Agent | 22 | 57 | 18040 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
|
||||
| ✅ Approval | 1 | 7 | 3503 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 701 |
|
||||
| ✅ Approval | 1 | 7 | 3531 |
|
||||
| ✅ Audio | 2 | 12 | 1000 |
|
||||
| ✅ Auto Generation | 5 | 12 | 3747 |
|
||||
| ✅ Autonomy | 3 | 7 | 2790 |
|
||||
| ✅ Bots | 7 | 33 | 13860 |
|
||||
| ✅ Bots | 7 | 33 | 13925 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 123 | 37946 |
|
||||
| ✅ Chunking | 2 | 2 | 420 |
|
||||
| ✅ CLI | 5 | 123 | 38138 |
|
||||
| ✅ Chunking | 2 | 2 | 424 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 2 | 14 | 4855 |
|
||||
| ✅ Conditions | 1 | 3 | 1347 |
|
||||
| ✅ Configuration | 3 | 8 | 3292 |
|
||||
| ✅ Context Management | 16 | 36 | 14090 |
|
||||
| ✅ Configuration | 3 | 8 | 3323 |
|
||||
| ✅ Context Management | 16 | 36 | 14104 |
|
||||
| ✅ Deep Research | 8 | 2 | 587 |
|
||||
| ✅ Display | 6 | 3 | 741 |
|
||||
| ✅ Embeddings | 6 | 23 | 2049 |
|
||||
| ✅ Display | 6 | 3 | 840 |
|
||||
| ✅ Embeddings | 6 | 23 | 2051 |
|
||||
| ✅ Evaluation | 1 | 8 | 3220 |
|
||||
| ✅ Events | 1 | 2 | 767 |
|
||||
| ✅ Execution | 3 | 4 | 1631 |
|
||||
| ✅ Failover | 2 | 1 | 452 |
|
||||
| ✅ Failover | 2 | 1 | 480 |
|
||||
| ✅ Files | 2 | 7 | 2376 |
|
||||
| ✅ Flow | 1 | 3 | 788 |
|
||||
| ✅ Gateway | 7 | 85 | 31517 |
|
||||
| ✅ Gateway | 7 | 86 | 32228 |
|
||||
| ✅ Guardrails | 4 | 4 | 2288 |
|
||||
| ✅ Handoffs | 11 | 6 | 2626 |
|
||||
| ✅ Handoffs | 11 | 6 | 2674 |
|
||||
| ✅ Hooks | 2 | 9 | 4690 |
|
||||
| ✅ Image | 1 | 11 | 1255 |
|
||||
| ✅ Knowledge | 4 | 17 | 5226 |
|
||||
| ✅ LLM | 3 | 15 | 5395 |
|
||||
| ✅ Knowledge | 4 | 18 | 5555 |
|
||||
| ✅ LLM | 3 | 15 | 5859 |
|
||||
| ✅ Loops | 4 | 5 | 1475 |
|
||||
| ✅ MCP | 1 | 62 | 14436 |
|
||||
| ✅ Memory | 6 | 19 | 7512 |
|
||||
| ✅ Memory | 6 | 19 | 7548 |
|
||||
| ✅ OCR | 2 | 1 | 237 |
|
||||
| ✅ Observability | 2 | 23 | 2811 |
|
||||
| ✅ Optimizer | 1 | 2 | 792 |
|
||||
| ✅ Output | 3 | 5 | 1273 |
|
||||
| ✅ Parallel Execution | 3 | 2 | 497 |
|
||||
| ✅ Planning | 6 | 7 | 1825 |
|
||||
| ✅ Plugins | 8 | 5 | 3120 |
|
||||
| ✅ Plugins | 8 | 5 | 3124 |
|
||||
| ✅ Prompts | 2 | 9 | 2011 |
|
||||
| ✅ Providers | 1 | 57 | 8197 |
|
||||
| ✅ Providers | 1 | 57 | 8621 |
|
||||
| ✅ Query | 1 | 2 | 736 |
|
||||
| ✅ RAG | 5 | 15 | 3118 |
|
||||
| ✅ Realtime | 2 | 5 | 668 |
|
||||
@@ -70,25 +70,25 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| ✅ Routing | 1 | 2 | 430 |
|
||||
| ✅ Sandbox | 5 | 7 | 3556 |
|
||||
| ✅ Security | 1 | 3 | 3529 |
|
||||
| ✅ Sessions | 4 | 19 | 7648 |
|
||||
| ✅ Skills | 6 | 16 | 5911 |
|
||||
| ✅ Sessions | 4 | 20 | 7898 |
|
||||
| ✅ Skills | 6 | 16 | 5984 |
|
||||
| ✅ Tasks | 2 | 6 | 2897 |
|
||||
| ✅ Telemetry | 1 | 2 | 648 |
|
||||
| ✅ Templates | 1 | 8 | 1667 |
|
||||
| ✅ Tools | 12 | 145 | 44115 |
|
||||
| ✅ Tools | 12 | 145 | 44258 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Vector Store | 1 | 12 | 1219 |
|
||||
| ✅ Video | 2 | 6 | 728 |
|
||||
| ✅ Vision | 2 | 1 | 329 |
|
||||
| ✅ Web | 3 | 9 | 2487 |
|
||||
| ✅ Workflows | 5 | 18 | 7410 |
|
||||
| ✅ Web | 3 | 10 | 2791 |
|
||||
| ✅ Workflows | 5 | 18 | 7425 |
|
||||
|
||||
## Documentation Without Features
|
||||
|
||||
These docs exist but don't match any implemented feature category:
|
||||
|
||||
- ℹ️ Database (43 docs, 7293 lines)
|
||||
- ℹ️ Documents (1 docs, 798 lines)
|
||||
- ℹ️ Database (43 docs, 7573 lines)
|
||||
- ℹ️ Documents (1 docs, 810 lines)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,372 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,298 @@
|
||||
# 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
@@ -0,0 +1,196 @@
|
||||
# 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.
|
||||
@@ -238,6 +238,12 @@ _COMPLETION_PATTERNS = (
|
||||
(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
|
||||
@@ -379,9 +385,26 @@ 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:
|
||||
@@ -398,6 +421,7 @@ 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:
|
||||
@@ -408,6 +432,7 @@ 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
|
||||
@@ -419,6 +444,7 @@ 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."""
|
||||
@@ -1424,7 +1450,16 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
elif memory_backend == "file":
|
||||
memory = True
|
||||
elif _memory_config.config:
|
||||
memory = _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
|
||||
else:
|
||||
memory = memory_backend
|
||||
elif hasattr(_memory_config, 'search') and hasattr(_memory_config, 'add'):
|
||||
@@ -1447,9 +1482,18 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
if learn is False:
|
||||
_learn_config = None
|
||||
elif learn is True:
|
||||
# learn=True shorthand enables AGENTIC mode (auto-learning)
|
||||
# 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.
|
||||
from ..memory.learn.protocols import LearnMode
|
||||
_learn_config = LearnConfig(mode=LearnMode.AGENTIC)
|
||||
_learn_config = LearnConfig(
|
||||
mode=LearnMode.AGENTIC,
|
||||
nudge_interval=10,
|
||||
nudge_min_tool_iters=0,
|
||||
)
|
||||
elif isinstance(learn, LearnConfig):
|
||||
_learn_config = learn
|
||||
elif isinstance(learn, dict):
|
||||
@@ -1496,6 +1540,14 @@ 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
|
||||
@@ -2125,6 +2177,21 @@ 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 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:
|
||||
base_url = _target.base_url
|
||||
if api_key is None:
|
||||
api_key = _target.api_key
|
||||
self._local_target = _target
|
||||
|
||||
# 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.
|
||||
@@ -2226,14 +2293,57 @@ 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()
|
||||
if auth:
|
||||
# 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:
|
||||
# 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, 'auth': auth}
|
||||
llm_params = {'model': model_name}
|
||||
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
|
||||
@@ -2786,6 +2896,10 @@ 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
|
||||
@@ -6560,6 +6674,9 @@ 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
|
||||
@@ -6636,6 +6753,17 @@ 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
|
||||
|
||||
@@ -95,6 +95,51 @@ 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()
|
||||
@@ -3248,6 +3293,10 @@ 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
|
||||
|
||||
@@ -4630,6 +4679,10 @@ 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
|
||||
@@ -4638,6 +4691,9 @@ 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):
|
||||
@@ -4861,7 +4917,15 @@ 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
|
||||
@@ -4877,6 +4941,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
else:
|
||||
knowledge_content = "\n".join(search_results)
|
||||
actual_prompt = f"{prompt}\n\nKnowledge: {knowledge_content}"
|
||||
|
||||
if attachments:
|
||||
actual_prompt = self._build_multimodal_prompt(actual_prompt, attachments)
|
||||
|
||||
# Handle tools properly
|
||||
tools = kwargs.get('tools', self.tools)
|
||||
@@ -4917,18 +4984,47 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
try:
|
||||
# Use the new streaming generator from LLM class
|
||||
response_content = ""
|
||||
stream_history = self.chat_history
|
||||
# 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
|
||||
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=self._build_system_prompt(
|
||||
tool_param,
|
||||
memory_prefetch_context=memory_prefetch_context,
|
||||
),
|
||||
system_prompt=stream_system_prompt,
|
||||
chat_history=stream_history,
|
||||
temperature=kwargs.get('temperature', 1.0),
|
||||
tools=tool_param,
|
||||
@@ -4946,7 +5042,8 @@ 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()
|
||||
max_tool_calls_per_turn=self._resolve_max_tool_calls(),
|
||||
**stream_sampling_kwargs
|
||||
):
|
||||
response_content += chunk
|
||||
yield chunk
|
||||
@@ -4979,6 +5076,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
else:
|
||||
knowledge_content = "\n".join(search_results)
|
||||
actual_prompt = f"{prompt}\n\nKnowledge: {knowledge_content}"
|
||||
|
||||
if attachments:
|
||||
actual_prompt = self._build_multimodal_prompt(actual_prompt, attachments)
|
||||
|
||||
# Handle tools properly
|
||||
tools = kwargs.get('tools', self.tools)
|
||||
@@ -4992,6 +5092,27 @@ 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)
|
||||
|
||||
@@ -5021,6 +5142,37 @@ 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
|
||||
|
||||
@@ -5156,11 +5308,34 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
)
|
||||
else {}
|
||||
)
|
||||
tool_result = executor(
|
||||
_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_call['function']['name'],
|
||||
parsed_args,
|
||||
tool_call_id=tool_call.get('id'),
|
||||
**iteration_kwargs,
|
||||
tool_result,
|
||||
elapsed_time=time_module.perf_counter() - _tool_started,
|
||||
success=True,
|
||||
)
|
||||
# Add tool result to chat history (multimodal-aware)
|
||||
from .tool_execution import build_tool_result_message_pair
|
||||
@@ -5296,7 +5471,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.chat(prompt, **kwargs)
|
||||
response = self._stream_fallback_chat(prompt, kwargs, e)
|
||||
if response:
|
||||
words = str(response).split()
|
||||
chunk_size = max(1, len(words) // 20)
|
||||
@@ -5316,12 +5491,77 @@ 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.chat(prompt, **kwargs)
|
||||
response = self._stream_fallback_chat(prompt, kwargs, e)
|
||||
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.
|
||||
|
||||
@@ -824,6 +824,7 @@ Write the complete compiled report:"""
|
||||
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
|
||||
@@ -1420,10 +1421,27 @@ Write the complete compiled report:"""
|
||||
)
|
||||
if _verdict.get("stuck"):
|
||||
if _verdict.get("level") == "critical":
|
||||
return {
|
||||
"error": _verdict.get("message", "loop detected"),
|
||||
"loop_blocked": True,
|
||||
}
|
||||
# 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,
|
||||
}
|
||||
elif not getattr(self, '_loop_warned_this_turn', False):
|
||||
self._loop_warned_this_turn = True
|
||||
self._pending_self_correction = (
|
||||
@@ -1731,6 +1749,12 @@ Write the complete compiled report:"""
|
||||
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.
|
||||
@@ -2034,6 +2058,14 @@ 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)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -791,11 +791,16 @@ class MemoryMixin:
|
||||
# Reset counter and emit nudge
|
||||
self._turns_since_nudge = 0
|
||||
|
||||
# Optional: only nudge if agent has done meaningful work
|
||||
# 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.
|
||||
min_iters = getattr(self._learn_config, 'nudge_min_tool_iters', 3)
|
||||
tool_calls_count = getattr(self, '_recent_tool_calls_count', 0)
|
||||
if tool_calls_count < min_iters:
|
||||
return None
|
||||
if min_iters > 0:
|
||||
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,11 +113,20 @@ 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(
|
||||
@@ -135,7 +144,6 @@ 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} "
|
||||
|
||||
@@ -983,6 +983,13 @@ 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
|
||||
@@ -1858,6 +1865,61 @@ 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."""
|
||||
@@ -2563,6 +2625,62 @@ class ToolExecutionMixin:
|
||||
arguments = processed
|
||||
return None, arguments
|
||||
|
||||
def _apply_tool_result_guardrails(self, function_name, result):
|
||||
"""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.
|
||||
"""
|
||||
guardrails = getattr(self, "_tool_result_guardrails", None)
|
||||
if not guardrails:
|
||||
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
|
||||
for guardrail in guardrails:
|
||||
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."""
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ __all__ = [
|
||||
"BackgroundConfig",
|
||||
# Shared runner accessor
|
||||
"get_background_runner",
|
||||
# Durable background job store
|
||||
"SqliteBackgroundJobStore",
|
||||
]
|
||||
|
||||
|
||||
@@ -67,4 +69,8 @@ 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,10 +520,47 @@ _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."""
|
||||
"""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.
|
||||
"""
|
||||
global _global_job_manager
|
||||
with _global_lock:
|
||||
if _global_job_manager is None:
|
||||
_global_job_manager = BackgroundJobManager()
|
||||
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,
|
||||
)
|
||||
return _global_job_manager
|
||||
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
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,6 +41,7 @@ from .protocols import (
|
||||
from .base import (
|
||||
BasePlatformAdapter,
|
||||
SendResult,
|
||||
SendStatus,
|
||||
SendErrorKind,
|
||||
classify_send_error,
|
||||
)
|
||||
@@ -169,6 +170,7 @@ __all__ = [
|
||||
"GatewayAdapterContractError",
|
||||
"BasePlatformAdapter",
|
||||
"SendResult",
|
||||
"SendStatus",
|
||||
"SendErrorKind",
|
||||
"classify_send_error",
|
||||
"InteractiveContext",
|
||||
|
||||
@@ -54,17 +54,23 @@ import re
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from typing import Any, Dict, List, Literal, 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.
|
||||
@@ -267,6 +273,10 @@ 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.
|
||||
"""
|
||||
|
||||
@@ -278,12 +288,32 @@ 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),
|
||||
@@ -291,6 +321,8 @@ 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,
|
||||
}
|
||||
|
||||
@@ -627,6 +659,15 @@ 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
|
||||
|
||||
@@ -31,6 +31,7 @@ from typing import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..agent import Agent
|
||||
from .base import SendResult
|
||||
|
||||
|
||||
class ChannelCapabilities(TypedDict, total=False):
|
||||
@@ -929,17 +930,29 @@ class BotProtocol(Protocol):
|
||||
content: Union[str, Dict[str, Any]],
|
||||
reply_to: Optional[str] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
) -> BotMessage:
|
||||
) -> Union[BotMessage, "SendResult"]:
|
||||
"""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:
|
||||
The sent message
|
||||
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"``).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
@@ -374,7 +375,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=model or "gpt-4o-mini",
|
||||
model=default_auxiliary_model(model),
|
||||
messages=[{"role": "user", "content": summary_prompt}],
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.3,
|
||||
@@ -382,7 +383,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=model or "gpt-4o-mini",
|
||||
model=default_auxiliary_model(model),
|
||||
messages=[{"role": "user", "content": summary_prompt}],
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.3,
|
||||
|
||||
@@ -10,6 +10,7 @@ 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__)
|
||||
|
||||
@@ -638,7 +639,7 @@ class LLMContextCompressorOptimizer(BaseOptimizer):
|
||||
use_accurate_tokenizer: Use model-specific tokenizer if available
|
||||
"""
|
||||
self.llm_client = llm_client
|
||||
self.auxiliary_model = auxiliary_model or "gpt-4o-mini"
|
||||
self.auxiliary_model = default_auxiliary_model(auxiliary_model)
|
||||
self.protect_last_n_tokens = protect_last_n_tokens
|
||||
self.summary_target_tokens = summary_target_tokens
|
||||
|
||||
|
||||
@@ -29,6 +29,14 @@ 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,
|
||||
@@ -70,20 +78,30 @@ 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:
|
||||
if model_key in model_lower or model_key in normalised:
|
||||
return dimensions
|
||||
|
||||
# Default to 1536 for unknown models (OpenAI standard)
|
||||
|
||||
@@ -83,6 +83,10 @@ 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,
|
||||
@@ -370,6 +374,10 @@ __all__ = [
|
||||
"GatewayIdlePolicyProtocol",
|
||||
"GatewayIdlePolicy",
|
||||
"ScaleToZeroPolicy",
|
||||
# Gateway freeze-thaw (involuntary host-suspend gap) recovery (Issue #4767)
|
||||
"ThawDecision",
|
||||
"ThawPolicyProtocol",
|
||||
"WallClockGapThawPolicy",
|
||||
"DrainDecision",
|
||||
"GatewayDrainPolicyProtocol",
|
||||
"DrainTimeoutPolicy",
|
||||
|
||||
@@ -6,6 +6,7 @@ Provides configuration dataclasses for gateway and session settings.
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
@@ -18,6 +19,9 @@ 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)
|
||||
@@ -854,6 +858,14 @@ 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."""
|
||||
@@ -977,6 +989,13 @@ 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,7 +1938,16 @@ 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.
|
||||
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).
|
||||
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").
|
||||
@@ -1967,13 +1976,21 @@ 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 peer beats role/channel beats account
|
||||
# beats chat-type. Higher means more specific.
|
||||
# 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 = {
|
||||
"thread_id": 32,
|
||||
"peer": 16,
|
||||
"role": 8,
|
||||
"channel_id": 8,
|
||||
"guild_id": 4,
|
||||
"account": 4,
|
||||
"chat_type": 2,
|
||||
}
|
||||
@@ -2017,7 +2034,20 @@ 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 and str(self.channel_id) != str(facts.channel_id):
|
||||
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):
|
||||
return False
|
||||
if self.account is not None and str(self.account) != str(facts.account):
|
||||
return False
|
||||
@@ -2066,6 +2096,8 @@ 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")),
|
||||
@@ -2084,6 +2116,13 @@ 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"
|
||||
@@ -2091,6 +2130,9 @@ 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
|
||||
@@ -2148,8 +2190,9 @@ 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 peer →
|
||||
role/channel → account → chat-type), then by declaration order.
|
||||
highest ``priority`` wins; ties are broken by specificity (exact thread →
|
||||
peer → role/channel → guild/account → chat-type), then by declaration
|
||||
order.
|
||||
|
||||
Args:
|
||||
bindings: Candidate route bindings (any order).
|
||||
@@ -3381,6 +3424,150 @@ 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -78,7 +78,22 @@ 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:
|
||||
|
||||
@@ -194,7 +194,26 @@ 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,6 +16,7 @@ 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:
|
||||
@@ -76,6 +77,29 @@ 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):
|
||||
@@ -178,7 +202,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")
|
||||
GUARDRAIL_PROTOCOL_METHODS = ("validate_input", "validate_output", "validate_tool_call", "validate_tool_result")
|
||||
|
||||
|
||||
def is_guardrail_object(obj: Any) -> bool:
|
||||
|
||||
@@ -356,11 +356,19 @@ 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):
|
||||
"""Store a memory."""
|
||||
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()``.
|
||||
"""
|
||||
try:
|
||||
if isinstance(content, str):
|
||||
if any(content.lower().endswith(ext) for ext in ['.pdf', '.doc', '.docx', '.txt']):
|
||||
if not is_content and 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)
|
||||
|
||||
@@ -486,10 +494,15 @@ 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', []))
|
||||
return {'results': results, 'relations': []}
|
||||
# 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 self._process_single_input(file_path, user_id, agent_id, run_id, metadata)
|
||||
|
||||
@@ -522,6 +535,12 @@ 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):
|
||||
@@ -536,6 +555,7 @@ 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
|
||||
@@ -556,9 +576,18 @@ class Knowledge:
|
||||
) from e
|
||||
|
||||
if not all_results:
|
||||
logger.warning(f"No supported files found in directory: {input_path}")
|
||||
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}")
|
||||
|
||||
return {'results': all_results, 'relations': []}
|
||||
return {'results': all_results, 'relations': [], 'errors': all_errors}
|
||||
|
||||
# Check if input ends with any supported extension
|
||||
is_supported_file = any(input_path.lower().endswith(ext)
|
||||
@@ -579,7 +608,17 @@ class Knowledge:
|
||||
content = file.read().strip()
|
||||
if not content:
|
||||
raise ValueError("Empty text file")
|
||||
memories = [self.normalize_content(content)]
|
||||
# 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]
|
||||
else:
|
||||
# Use MarkItDown for documents and media
|
||||
result = self.markdown.convert(input_path)
|
||||
@@ -595,8 +634,35 @@ 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:
|
||||
# Treat as raw text content only if no file extension
|
||||
# Genuinely raw text handed in instead of a path.
|
||||
memories = [self.normalize_content(input_path)]
|
||||
|
||||
# Create progress display
|
||||
@@ -611,12 +677,20 @@ 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)
|
||||
run_id=run_id, metadata=metadata, is_content=True)
|
||||
if memory_result:
|
||||
# Handle both dict and list formats for backward compatibility
|
||||
if isinstance(memory_result, dict):
|
||||
@@ -638,6 +712,25 @@ 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="gpt-4o-mini",
|
||||
model=default_auxiliary_model(),
|
||||
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="gpt-4o-mini")
|
||||
llm_fn = create_openai_llm_fn(model=default_auxiliary_model())
|
||||
agent = LiteAgent(llm_fn=llm_fn)
|
||||
"""
|
||||
def llm_fn(messages: List[Dict]) -> str:
|
||||
@@ -312,6 +312,7 @@ 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__ = [
|
||||
|
||||
@@ -9,11 +9,48 @@ and integrates with Gap 2 (parallel tool execution).
|
||||
"""
|
||||
|
||||
from ..protocols import LLMProviderAdapterProtocol
|
||||
from ..model_capabilities import GEMINI_INTERNAL_TOOLS
|
||||
from ..streaming_protocol import StreamingCapableAdapter, get_streaming_adapter
|
||||
import json
|
||||
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."""
|
||||
|
||||
@@ -23,14 +60,8 @@ 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
|
||||
@@ -38,26 +69,35 @@ 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-style tool result message
|
||||
message = {
|
||||
"role": "tool",
|
||||
"content": str(tool_result),
|
||||
}
|
||||
if tool_call_id is not None:
|
||||
message["tool_call_id"] = tool_call_id
|
||||
"""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:
|
||||
# Fallback for backward compatibility
|
||||
message["tool_call_id"] = f"call_{function_name}"
|
||||
return message
|
||||
try:
|
||||
content = json.dumps(tool_result)
|
||||
except (TypeError, ValueError):
|
||||
content = str(tool_result)
|
||||
return {
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id if tool_call_id is not None else f"call_{function_name}",
|
||||
"content": content,
|
||||
}
|
||||
|
||||
def handle_empty_response_with_tools(self, state: Dict[str, Any]) -> bool:
|
||||
return False # No special handling by default
|
||||
@@ -65,24 +105,12 @@ 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):
|
||||
@@ -105,12 +133,7 @@ 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.
|
||||
@@ -159,42 +182,8 @@ 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."""
|
||||
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
|
||||
return _recover_json_tool_calls(response_text, tools)
|
||||
|
||||
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 {
|
||||
@@ -203,14 +192,41 @@ 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)
|
||||
@@ -220,9 +236,6 @@ 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):
|
||||
@@ -235,35 +248,13 @@ 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
|
||||
@@ -273,6 +264,7 @@ _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()
|
||||
@@ -310,6 +302,8 @@ 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:
|
||||
@@ -318,23 +312,12 @@ 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',
|
||||
]
|
||||
@@ -486,6 +486,12 @@ 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)
|
||||
@@ -630,9 +636,18 @@ 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
|
||||
if provider_prefix == "ollama":
|
||||
# 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"}:
|
||||
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:
|
||||
@@ -649,11 +664,12 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
if model_lower.startswith("gemini") or model_lower.startswith("google/gemini"):
|
||||
return "gemini"
|
||||
|
||||
# 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"
|
||||
|
||||
# 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.
|
||||
|
||||
# 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
|
||||
@@ -697,10 +713,25 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
if not self.model:
|
||||
return False
|
||||
|
||||
# Direct ollama/ prefix
|
||||
if self.model.startswith("ollama/"):
|
||||
# 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/")):
|
||||
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
|
||||
@@ -716,6 +747,17 @@ 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:
|
||||
@@ -780,13 +822,20 @@ 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
|
||||
# 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.
|
||||
indicators = [
|
||||
'429',
|
||||
'rate limit',
|
||||
'ratelimit',
|
||||
'too many request',
|
||||
'resource_exhausted',
|
||||
'quota exceeded',
|
||||
'tokens per minute',
|
||||
]
|
||||
|
||||
@@ -2020,14 +2069,22 @@ 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; 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({
|
||||
# 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 = {
|
||||
"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
|
||||
@@ -2750,13 +2807,28 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
)
|
||||
|
||||
if stream:
|
||||
response_text, tool_calls = self._stream_responses_api(
|
||||
response_text, tool_calls, stream_response = 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)
|
||||
@@ -2925,19 +2997,9 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
)
|
||||
|
||||
# Append tool result as standard tool message
|
||||
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,
|
||||
})
|
||||
messages.append(self._create_tool_message(
|
||||
function_name, tool_result,
|
||||
tool_result_obj.tool_call_id, is_ollama=False))
|
||||
|
||||
# Report any unparseable tool-call arguments so the
|
||||
# model can re-emit them (never dispatched with {}).
|
||||
@@ -3096,12 +3158,7 @@ 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
|
||||
|
||||
@@ -3444,39 +3501,16 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
tool_calls = final_response["choices"][0]["message"].get("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}")
|
||||
# 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}")
|
||||
|
||||
# Parse tool calls from XML format in response text
|
||||
# Try for known XML models first, or fallback for any model that might output XML
|
||||
@@ -3748,20 +3782,8 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
pass
|
||||
else:
|
||||
# For other providers, use tool role with tool_call_id
|
||||
# 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
|
||||
})
|
||||
messages.append(self._create_tool_message(
|
||||
function_name, tool_result, tool_call_id, is_ollama=False))
|
||||
|
||||
# Check if we should continue (for tools like sequential thinking)
|
||||
# This mimics the logic from agent.py lines 1004-1007
|
||||
@@ -3820,8 +3842,11 @@ 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._is_ollama_provider() and accumulated_tool_results and iteration_count >= 1 and
|
||||
(not response_text or response_text.strip() == "")):
|
||||
if self._provider_adapter.handle_empty_response_with_tools({
|
||||
'iteration_count': iteration_count,
|
||||
'accumulated_tool_results': accumulated_tool_results,
|
||||
'response_text': response_text or '',
|
||||
}):
|
||||
# Generate coherent response from tool results
|
||||
tool_summary = self._generate_ollama_tool_summary(accumulated_tool_results, response_text)
|
||||
if tool_summary:
|
||||
@@ -4410,6 +4435,15 @@ 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,
|
||||
@@ -4423,15 +4457,46 @@ 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(
|
||||
@@ -4499,26 +4564,130 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
use_streaming = False
|
||||
|
||||
if not use_streaming:
|
||||
# Fall back to non-streaming and yield the complete response
|
||||
# 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.
|
||||
try:
|
||||
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
|
||||
is_ollama = self._is_ollama_provider()
|
||||
fallback_iterations = 0
|
||||
tool_call_count = 0
|
||||
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
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if response and response.choices:
|
||||
content = response.choices[0].message.content
|
||||
|
||||
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.
|
||||
# An answer with no content AND no tool calls is the
|
||||
# provider's business, not ours to invent.
|
||||
if content:
|
||||
yield content
|
||||
break
|
||||
|
||||
# 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 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.")
|
||||
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"Tool call limit reached ({max_fallback_iterations}) on the "
|
||||
"non-streaming fallback.")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Non-streaming fallback failed: {e}")
|
||||
raise
|
||||
@@ -4750,11 +4919,15 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
current_time = time.time()
|
||||
|
||||
if stream:
|
||||
response_text, tool_calls = await self._stream_responses_api_async(
|
||||
response_text, tool_calls, stream_response = 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)
|
||||
@@ -4844,17 +5017,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
_get_display_functions()['display_tool_call'](display_message, console=self.console)
|
||||
|
||||
# Append tool result
|
||||
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,
|
||||
})
|
||||
messages.append(self._create_tool_message(
|
||||
function_name, tool_result, tool_call_id, is_ollama=False))
|
||||
|
||||
if iteration_count + 1 >= max_iterations:
|
||||
self._last_stop_reason = "max_steps"
|
||||
@@ -5127,20 +5291,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
pass
|
||||
else:
|
||||
# For other providers, use tool role with tool_call_id
|
||||
# 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
|
||||
})
|
||||
messages.append(self._create_tool_message(
|
||||
function_name, tool_result, tool_call_id, is_ollama=False))
|
||||
|
||||
# Flush deferred media follow-ups after all tool replies
|
||||
# for this turn have been appended (provider contract).
|
||||
@@ -5317,8 +5469,11 @@ 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._is_ollama_provider() and accumulated_tool_results and iteration_count >= 1 and
|
||||
(not response_text or response_text.strip() == "")):
|
||||
if self._provider_adapter.handle_empty_response_with_tools({
|
||||
'iteration_count': iteration_count,
|
||||
'accumulated_tool_results': accumulated_tool_results,
|
||||
'response_text': response_text or '',
|
||||
}):
|
||||
# Generate coherent response from tool results
|
||||
tool_summary = self._generate_ollama_tool_summary(accumulated_tool_results, response_text)
|
||||
if tool_summary:
|
||||
@@ -6390,6 +6545,7 @@ 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
|
||||
@@ -6401,6 +6557,11 @@ 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)
|
||||
@@ -6492,7 +6653,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
},
|
||||
})
|
||||
|
||||
return response_text, tool_calls if tool_calls else None
|
||||
return response_text, tool_calls if tool_calls else None, _final_response
|
||||
|
||||
async def _stream_responses_api_async(
|
||||
self,
|
||||
@@ -6510,6 +6671,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
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
|
||||
@@ -6520,6 +6682,10 @@ 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", ""))
|
||||
@@ -6594,7 +6760,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
|
||||
return response_text, tool_calls if tool_calls else None, _final_response
|
||||
|
||||
def _process_streaming_chunk(self, chunk) -> Optional[str]:
|
||||
"""Extract content from a streaming chunk"""
|
||||
@@ -6622,6 +6788,33 @@ 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 = []
|
||||
|
||||
@@ -36,6 +36,75 @@ _BUILTINS: Dict[str, Callable[[str], bool]] = {
|
||||
}
|
||||
|
||||
|
||||
# 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.
|
||||
|
||||
|
||||
@@ -246,6 +246,15 @@ 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
|
||||
|
||||
@@ -1036,6 +1045,11 @@ 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,
|
||||
@@ -1043,7 +1057,7 @@ class OpenAIClient:
|
||||
temperature=temperature,
|
||||
tools=tools if tools else None,
|
||||
stream=True,
|
||||
stream_options={"include_usage": True} if _emit else None,
|
||||
stream_options=stream_options,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@@ -1305,6 +1319,11 @@ 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,
|
||||
@@ -1312,7 +1331,7 @@ class OpenAIClient:
|
||||
temperature=temperature,
|
||||
tools=tools if tools else None,
|
||||
stream=True,
|
||||
stream_options={"include_usage": True} if _emit else None,
|
||||
stream_options=stream_options,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
@@ -271,15 +271,16 @@ class LLMProviderAdapterProtocol(Protocol):
|
||||
return False
|
||||
|
||||
def should_summarize_tools(self, iter_count: int) -> bool:
|
||||
return iter_count >= 3 # Ollama-specific threshold
|
||||
return iter_count >= 1 # Ollama-specific threshold
|
||||
|
||||
def format_tools(self, tools) -> list:
|
||||
return tools # No special formatting needed
|
||||
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 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"
|
||||
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)
|
||||
```
|
||||
"""
|
||||
|
||||
@@ -291,17 +292,8 @@ 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."""
|
||||
@@ -311,9 +303,6 @@ 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."""
|
||||
@@ -327,25 +316,13 @@ 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
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
"""
|
||||
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())
|
||||
@@ -0,0 +1,55 @@
|
||||
"""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 .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__ = [
|
||||
"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",
|
||||
]
|
||||
@@ -0,0 +1,168 @@
|
||||
"""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 ()
|
||||
@@ -0,0 +1,387 @@
|
||||
"""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))
|
||||
|
||||
# Something answered but matched no discriminator: report it honestly.
|
||||
if first_reply is not None and first_reply.status == 200:
|
||||
listing = _json(fetch("GET", "/v1/models"))
|
||||
return Discovery(
|
||||
engine=LocalEngine.UNKNOWN, base_url=base,
|
||||
api_style=ApiStyle.OPENAI_CHAT if isinstance(listing, dict) else ApiStyle.OPENAI_COMPLETIONS,
|
||||
evidence=Evidence.TABLE,
|
||||
models=parse_openai_models(listing) if isinstance(listing, dict) else (),
|
||||
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
|
||||
@@ -0,0 +1,48 @@
|
||||
"""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."""
|
||||
@@ -0,0 +1,180 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,322 @@
|
||||
"""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
|
||||
@@ -0,0 +1,219 @@
|
||||
"""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)
|
||||
return f"{parsed.scheme}://{parsed.hostname}:{port}"
|
||||
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 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=discovery.base_url + "/v1",
|
||||
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,6 +46,7 @@ __all__ = [
|
||||
"detect_root_uri",
|
||||
"probe",
|
||||
"DEFAULT_SERVERS",
|
||||
"resolve_servers",
|
||||
]
|
||||
|
||||
|
||||
@@ -64,7 +65,7 @@ def __getattr__(name: str):
|
||||
return locals()[name]
|
||||
|
||||
if name in ("LSPConfig", "detect_language", "detect_root_uri", "probe",
|
||||
"DEFAULT_SERVERS"):
|
||||
"DEFAULT_SERVERS", "resolve_servers"):
|
||||
from . import config
|
||||
return getattr(config, name)
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@ class LSPClient:
|
||||
command: Optional[str] = None,
|
||||
args: Optional[List[str]] = None,
|
||||
root_uri: Optional[str] = None,
|
||||
workspace_file: Optional[str] = None
|
||||
workspace_file: Optional[str] = None,
|
||||
servers: Optional[Dict[str, Dict]] = None
|
||||
):
|
||||
"""
|
||||
Initialize the LSP client.
|
||||
@@ -64,12 +65,18 @@ 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
|
||||
root_uri=root_uri,
|
||||
servers=servers
|
||||
)
|
||||
self._workspace_file = workspace_file
|
||||
|
||||
@@ -107,7 +114,9 @@ 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)
|
||||
_, default_command, install_hint = probe(
|
||||
self.config.language, self._servers
|
||||
)
|
||||
hint = (
|
||||
f"; install with `{install_hint}`"
|
||||
if install_hint and self.config.command == default_command
|
||||
@@ -138,7 +147,9 @@ 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)
|
||||
root_uri = detect_root_uri(
|
||||
self._workspace_file, self.config.language, self._servers
|
||||
)
|
||||
if root_uri is None:
|
||||
root_uri = path_to_uri(os.getcwd())
|
||||
|
||||
|
||||
@@ -56,18 +56,54 @@ DEFAULT_SERVERS = {
|
||||
}
|
||||
|
||||
|
||||
def _extension_map() -> Dict[str, str]:
|
||||
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]:
|
||||
"""Return a ``{extension: language}`` map derived from the registry."""
|
||||
mapping: Dict[str, str] = {}
|
||||
for language, server in DEFAULT_SERVERS.items():
|
||||
for language, server in resolve_servers(servers).items():
|
||||
for ext in server.get("extensions", []):
|
||||
mapping.setdefault(ext.lower(), language)
|
||||
return mapping
|
||||
|
||||
|
||||
def detect_language(file_path: str) -> Optional[str]:
|
||||
def detect_language(
|
||||
file_path: str, servers: Optional[Dict[str, Dict]] = None
|
||||
) -> Optional[str]:
|
||||
"""Resolve the LSP language id for ``file_path`` by extension, else ``None``."""
|
||||
return _extension_map().get(os.path.splitext(file_path)[1].lower())
|
||||
return _extension_map(servers).get(os.path.splitext(file_path)[1].lower())
|
||||
|
||||
|
||||
def path_to_uri(path: str) -> str:
|
||||
@@ -80,7 +116,11 @@ 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) -> Optional[str]:
|
||||
def detect_root_uri(
|
||||
file_path: str,
|
||||
language: Optional[str] = None,
|
||||
servers: Optional[Dict[str, Dict]] = 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
|
||||
@@ -88,8 +128,9 @@ def detect_root_uri(file_path: str, language: Optional[str] = None) -> Optional[
|
||||
``.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.
|
||||
"""
|
||||
language = language or detect_language(file_path)
|
||||
server = DEFAULT_SERVERS.get(language) if language else None
|
||||
registry = resolve_servers(servers)
|
||||
language = language or detect_language(file_path, servers)
|
||||
server = registry.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
|
||||
@@ -103,7 +144,9 @@ def detect_root_uri(file_path: str, language: Optional[str] = None) -> Optional[
|
||||
current = parent
|
||||
|
||||
|
||||
def probe(language: str) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||
def probe(
|
||||
language: str, servers: Optional[Dict[str, Dict]] = None
|
||||
) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||
"""Check whether the language server for ``language`` is on ``PATH``.
|
||||
|
||||
Returns ``(available, command, install_hint)``. ``available`` is ``True``
|
||||
@@ -111,7 +154,7 @@ def probe(language: str) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||
``command`` and ``install_hint`` describe what is missing and how to install
|
||||
it so the degradation can be surfaced instead of silently swallowed.
|
||||
"""
|
||||
server = DEFAULT_SERVERS.get(language)
|
||||
server = resolve_servers(servers).get(language)
|
||||
if not server:
|
||||
return False, None, None
|
||||
command = server["command"]
|
||||
@@ -129,13 +172,23 @@ 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
|
||||
# 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.
|
||||
if self.command is None:
|
||||
if self.language in DEFAULT_SERVERS:
|
||||
server = DEFAULT_SERVERS[self.language]
|
||||
registry = resolve_servers(self.servers)
|
||||
if self.language in registry:
|
||||
server = registry[self.language]
|
||||
self.command = server["command"]
|
||||
self.args = list(server["args"])
|
||||
self.args = list(server.get("args", []))
|
||||
if not self.initialization_options and server.get(
|
||||
"initialization_options"
|
||||
):
|
||||
self.initialization_options = dict(
|
||||
server["initialization_options"]
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"No default server for language: {self.language}")
|
||||
@@ -424,6 +424,35 @@ 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)
|
||||
@@ -584,7 +613,7 @@ class MongoDBMemoryAdapter:
|
||||
"""Get embedding for text."""
|
||||
try:
|
||||
from praisonaiagents.embedding import embedding
|
||||
result = embedding(text, model="text-embedding-3-small")
|
||||
result = embedding(text, model=self.embedding_model)
|
||||
return result.embeddings[0] if result.embeddings else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -19,6 +19,7 @@ from .stores import (
|
||||
ImprovementStore,
|
||||
LearnEntry,
|
||||
)
|
||||
from ...llm.model_providers import default_auxiliary_model
|
||||
|
||||
|
||||
class LearnManager:
|
||||
@@ -555,7 +556,7 @@ Return empty arrays if nothing is found for a category."""
|
||||
from ...llm import LLM
|
||||
import json
|
||||
|
||||
model = llm or "gpt-4o-mini"
|
||||
model = default_auxiliary_model(llm)
|
||||
llm_instance = LLM(model=model)
|
||||
response = llm_instance.get_response(
|
||||
prompt=extraction_prompt,
|
||||
@@ -659,7 +660,7 @@ Return empty arrays if nothing is found for a category."""
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
model = llm or "gpt-4o-mini"
|
||||
model = default_auxiliary_model(llm)
|
||||
llm_instance = LLM(model=model)
|
||||
|
||||
# Run sync LLM call in executor for async compatibility
|
||||
|
||||
@@ -19,6 +19,7 @@ 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"
|
||||
@@ -2171,7 +2172,7 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
import litellm
|
||||
|
||||
# Convert model name if it's in litellm format
|
||||
model_name = llm or "gpt-4o-mini"
|
||||
model_name = default_auxiliary_model(llm)
|
||||
|
||||
response = litellm.completion(
|
||||
model=model_name,
|
||||
@@ -2185,10 +2186,24 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
elif openai_available:
|
||||
# Fallback to OpenAI client
|
||||
from openai import OpenAI
|
||||
client = 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)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=llm or "gpt-4o-mini",
|
||||
model=default_auxiliary_model(llm),
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": custom_prompt or default_prompt
|
||||
|
||||
@@ -65,12 +65,20 @@ 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 if specified
|
||||
if metadata_filter:
|
||||
# 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:
|
||||
filtered_results = []
|
||||
for result in results:
|
||||
metadata = result.get("metadata", {})
|
||||
if all(metadata.get(k) == v for k, v in metadata_filter.items()):
|
||||
if all(metadata.get(k) == v for k, v in effective_filter.items()):
|
||||
filtered_results.append(result)
|
||||
results = filtered_results
|
||||
|
||||
@@ -124,12 +132,20 @@ 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 if specified
|
||||
if metadata_filter:
|
||||
# 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:
|
||||
filtered_results = []
|
||||
for result in results:
|
||||
metadata = result.get("metadata", {})
|
||||
if all(metadata.get(k) == v for k, v in metadata_filter.items()):
|
||||
if all(metadata.get(k) == v for k, v in effective_filter.items()):
|
||||
filtered_results.append(result)
|
||||
results = filtered_results
|
||||
|
||||
|
||||
@@ -50,6 +50,12 @@ class Process:
|
||||
self.workflow_cancelled = False # ADDED: Workflow cancellation flag for timeout
|
||||
self._state_lock_init = threading.Lock() # Thread lock for synchronous shared-state updates
|
||||
self._state_lock = None # Lazy-initialized async lock for shared state protection
|
||||
# Ids of every synthetic ``manager_task`` this Process has injected. Each
|
||||
# hierarchical() / ahierarchical() run adds a fresh manager via add_task
|
||||
# and never removes prior ones, so a re-run of the same team leaves stale
|
||||
# managers in self.tasks. Excluding all of them (not just the current one)
|
||||
# from total_tasks / delegable ids keeps completion reachable across runs.
|
||||
self._synthetic_manager_task_ids: set = set()
|
||||
|
||||
# Resolve verbose from output= param (takes precedence) or legacy verbose= param
|
||||
if output is not None:
|
||||
@@ -1055,9 +1061,30 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
|
||||
)
|
||||
manager_task_id = yield manager_task
|
||||
logging.info(f"Created manager task with ID {manager_task_id}")
|
||||
self._synthetic_manager_task_ids.add(manager_task_id)
|
||||
|
||||
completed_count = 0
|
||||
total_tasks = len(self.tasks) - 1
|
||||
# Exclude every synthetic manager this Process has injected — not just the
|
||||
# current one. Re-running the same team leaves prior managers in self.tasks
|
||||
# (they are never removed and an aborted run may not have marked its manager
|
||||
# completed), so counting them as real work would keep completed_count below
|
||||
# total_tasks forever and the loop could only exit via manager "stop" /
|
||||
# max iterations. Keyed by id (not name) so a user task named "manager_task"
|
||||
# stays delegable.
|
||||
excluded_task_ids = self._synthetic_manager_task_ids
|
||||
|
||||
total_tasks = len([tid for tid in self.tasks if tid not in excluded_task_ids])
|
||||
# Seed completion accounting with tasks already completed before this run
|
||||
# (e.g. re-running the same team). Tracking counted ids by object identity
|
||||
# keeps the counter idempotent: a task is counted exactly once, whether it
|
||||
# was pre-completed or completes during this run. Without this seed,
|
||||
# pre-completed tasks would hit the re-selection branch and never be
|
||||
# counted, making completed_count < total_tasks permanently and the loop
|
||||
# only exitable via manager "stop" / max iterations.
|
||||
counted_task_ids = {
|
||||
tid for tid, tk in self.tasks.items()
|
||||
if tid not in excluded_task_ids and tk.status == "completed"
|
||||
}
|
||||
completed_count = len(counted_task_ids)
|
||||
logging.info(f"Need to complete {total_tasks} tasks (excluding manager task)")
|
||||
|
||||
# Track invalid selection attempts and error context
|
||||
@@ -1065,10 +1092,24 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
|
||||
MAX_INVALID_SELECTIONS = 3
|
||||
error_context = ""
|
||||
|
||||
# Bound the manager-directed delegation loop. Without this a task that
|
||||
# ends in status "failed" (a valid id that is never "completed") is
|
||||
# re-selectable forever, so the loop can run indefinitely. Mirror the
|
||||
# max_iter ceiling workflow() already enforces, plus a per-task cap so a
|
||||
# repeatedly-failing task becomes terminal instead of being retried
|
||||
# without bound.
|
||||
current_iter = 0
|
||||
failed_reselect_counts: Dict[Any, int] = {}
|
||||
MAX_TASK_RESELECTIONS = 3
|
||||
|
||||
while completed_count < total_tasks:
|
||||
current_iter += 1
|
||||
if current_iter > self.max_iter:
|
||||
logging.info(f"Max iteration limit {self.max_iter} reached, ending hierarchical process.")
|
||||
break
|
||||
tasks_summary = []
|
||||
for tid, tk in self.tasks.items():
|
||||
if tid == manager_task_id:
|
||||
if tid in excluded_task_ids:
|
||||
continue
|
||||
task_info = {
|
||||
"task_id": tid,
|
||||
@@ -1141,10 +1182,11 @@ Provide a JSON with the structure:
|
||||
break
|
||||
|
||||
selected_task = self.tasks.get(selected_task_id)
|
||||
if selected_task is None or selected_task_id == manager_task_id:
|
||||
# Reject unknown ids and the synthetic manager_task by its actual id
|
||||
# (it must never delegate to itself). Matching by id avoids colliding
|
||||
# with a legitimate user task that happens to be named "manager_task".
|
||||
if selected_task is None or selected_task_id in excluded_task_ids:
|
||||
# Reject unknown ids and any synthetic manager_task by its actual id
|
||||
# (the manager must never delegate to itself, nor to a stale manager
|
||||
# left by a prior run of the same team). Matching by id avoids
|
||||
# colliding with a legitimate user task named "manager_task".
|
||||
# Re-prompt with the delegable IDs only.
|
||||
invalid_selection_attempts += 1
|
||||
if invalid_selection_attempts > MAX_INVALID_SELECTIONS:
|
||||
@@ -1154,7 +1196,7 @@ Provide a JSON with the structure:
|
||||
break
|
||||
|
||||
valid_task_ids = [
|
||||
tid for tid in self.tasks if tid != manager_task_id
|
||||
tid for tid in self.tasks if tid not in excluded_task_ids
|
||||
]
|
||||
logging.warning(
|
||||
f"Manager selected invalid task_id={selected_task_id} "
|
||||
@@ -1179,18 +1221,57 @@ Provide a JSON with the structure:
|
||||
logging.info(f"Changed agent for task {selected_task_id} from {original_agent} to {selected_agent_name}")
|
||||
break
|
||||
|
||||
# A task whose internal retries are exhausted lands in "failed" — a
|
||||
# valid id that never becomes "completed". Cap how many times the
|
||||
# manager may re-delegate it, then treat it as terminal so the loop
|
||||
# can make progress instead of retrying it forever.
|
||||
if self.tasks[selected_task_id].status == "failed":
|
||||
failed_reselect_counts[selected_task_id] = failed_reselect_counts.get(selected_task_id, 0) + 1
|
||||
if failed_reselect_counts[selected_task_id] > MAX_TASK_RESELECTIONS:
|
||||
logging.error(
|
||||
f"Task {selected_task_id} failed {failed_reselect_counts[selected_task_id]} times; "
|
||||
f"excluding from further delegation."
|
||||
)
|
||||
excluded_task_ids.add(selected_task_id)
|
||||
if selected_task_id not in counted_task_ids:
|
||||
counted_task_ids.add(selected_task_id)
|
||||
completed_count += 1
|
||||
continue
|
||||
|
||||
if self.tasks[selected_task_id].status != "completed":
|
||||
logging.info(f"Starting execution of task {selected_task_id}")
|
||||
yield selected_task_id
|
||||
logging.info(f"Finished execution of task {selected_task_id}, status: {self.tasks[selected_task_id].status}")
|
||||
# Count a task toward completion only the first time it actually
|
||||
# transitions to "completed" (tracked by id). Counting on every
|
||||
# status check would double-count a task the manager re-selects
|
||||
# after it is already completed, letting completed_count reach
|
||||
# total_tasks early and silently skipping genuinely unexecuted
|
||||
# tasks.
|
||||
if (
|
||||
self.tasks[selected_task_id].status == "completed"
|
||||
and selected_task_id not in counted_task_ids
|
||||
):
|
||||
counted_task_ids.add(selected_task_id)
|
||||
completed_count += 1
|
||||
logging.info(f"Task {selected_task_id} completed. Total completed: {completed_count}/{total_tasks}")
|
||||
else:
|
||||
logging.warning(f"Manager re-selected already-completed task {selected_task_id}; ignoring re-selection.")
|
||||
|
||||
if self.tasks[selected_task_id].status == "completed":
|
||||
completed_count += 1
|
||||
logging.info(f"Task {selected_task_id} completed. Total completed: {completed_count}/{total_tasks}")
|
||||
|
||||
self.tasks[manager_task.id].status = "completed"
|
||||
if self.verbose >= 1:
|
||||
logging.info("All tasks completed under manager supervision.")
|
||||
# Distinguish genuine completion from a bounded early exit (max_iter,
|
||||
# repeated invalid selections, manager "stop", or manager failure). Only
|
||||
# report success when every delegable task is accounted for; otherwise
|
||||
# mark the manager terminal without falsely logging full completion.
|
||||
if completed_count >= total_tasks:
|
||||
self.tasks[manager_task.id].status = "completed"
|
||||
if self.verbose >= 1:
|
||||
logging.info("All tasks completed under manager supervision.")
|
||||
else:
|
||||
self.tasks[manager_task.id].status = "failed"
|
||||
logging.warning(
|
||||
f"Hierarchical process ended with {completed_count}/{total_tasks} tasks completed; "
|
||||
f"some tasks were not executed."
|
||||
)
|
||||
logging.info("Hierarchical task execution finished")
|
||||
|
||||
def workflow(self):
|
||||
@@ -1666,9 +1747,30 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
|
||||
)
|
||||
manager_task_id = yield manager_task
|
||||
logging.info(f"Created manager task with ID {manager_task_id}")
|
||||
self._synthetic_manager_task_ids.add(manager_task_id)
|
||||
|
||||
completed_count = 0
|
||||
total_tasks = len(self.tasks) - 1
|
||||
# Exclude every synthetic manager this Process has injected — not just the
|
||||
# current one. Re-running the same team leaves prior managers in self.tasks
|
||||
# (they are never removed and an aborted run may not have marked its manager
|
||||
# completed), so counting them as real work would keep completed_count below
|
||||
# total_tasks forever and the loop could only exit via manager "stop" /
|
||||
# max iterations. Keyed by id (not name) so a user task named "manager_task"
|
||||
# stays delegable.
|
||||
excluded_task_ids = self._synthetic_manager_task_ids
|
||||
|
||||
total_tasks = len([tid for tid in self.tasks if tid not in excluded_task_ids])
|
||||
# Seed completion accounting with tasks already completed before this run
|
||||
# (e.g. re-running the same team). Tracking counted ids by object identity
|
||||
# keeps the counter idempotent: a task is counted exactly once, whether it
|
||||
# was pre-completed or completes during this run. Without this seed,
|
||||
# pre-completed tasks would hit the re-selection branch and never be
|
||||
# counted, making completed_count < total_tasks permanently and the loop
|
||||
# only exitable via manager "stop" / max iterations.
|
||||
counted_task_ids = {
|
||||
tid for tid, tk in self.tasks.items()
|
||||
if tid not in excluded_task_ids and tk.status == "completed"
|
||||
}
|
||||
completed_count = len(counted_task_ids)
|
||||
logging.info(f"Need to complete {total_tasks} tasks (excluding manager task)")
|
||||
|
||||
# Track invalid selection attempts and error context
|
||||
@@ -1676,10 +1778,24 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
|
||||
MAX_INVALID_SELECTIONS = 3
|
||||
error_context = ""
|
||||
|
||||
# Bound the manager-directed delegation loop. Without this a task that
|
||||
# ends in status "failed" (a valid id that is never "completed") is
|
||||
# re-selectable forever, so the loop can run indefinitely. Mirror the
|
||||
# max_iter ceiling workflow() already enforces, plus a per-task cap so a
|
||||
# repeatedly-failing task becomes terminal instead of being retried
|
||||
# without bound.
|
||||
current_iter = 0
|
||||
failed_reselect_counts: Dict[Any, int] = {}
|
||||
MAX_TASK_RESELECTIONS = 3
|
||||
|
||||
while completed_count < total_tasks:
|
||||
current_iter += 1
|
||||
if current_iter > self.max_iter:
|
||||
logging.info(f"Max iteration limit {self.max_iter} reached, ending hierarchical process.")
|
||||
break
|
||||
tasks_summary = []
|
||||
for tid, tk in self.tasks.items():
|
||||
if tid == manager_task_id:
|
||||
if tid in excluded_task_ids:
|
||||
continue
|
||||
task_info = {
|
||||
"task_id": tid,
|
||||
@@ -1725,10 +1841,11 @@ Provide a JSON with the structure:
|
||||
break
|
||||
|
||||
selected_task = self.tasks.get(selected_task_id)
|
||||
if selected_task is None or selected_task_id == manager_task_id:
|
||||
# Reject unknown ids and the synthetic manager_task by its actual id
|
||||
# (it must never delegate to itself). Matching by id avoids colliding
|
||||
# with a legitimate user task that happens to be named "manager_task".
|
||||
if selected_task is None or selected_task_id in excluded_task_ids:
|
||||
# Reject unknown ids and any synthetic manager_task by its actual id
|
||||
# (the manager must never delegate to itself, nor to a stale manager
|
||||
# left by a prior run of the same team). Matching by id avoids
|
||||
# colliding with a legitimate user task named "manager_task".
|
||||
# Re-prompt with the delegable IDs only.
|
||||
invalid_selection_attempts += 1
|
||||
if invalid_selection_attempts > MAX_INVALID_SELECTIONS:
|
||||
@@ -1738,7 +1855,7 @@ Provide a JSON with the structure:
|
||||
break
|
||||
|
||||
valid_task_ids = [
|
||||
tid for tid in self.tasks if tid != manager_task_id
|
||||
tid for tid in self.tasks if tid not in excluded_task_ids
|
||||
]
|
||||
logging.warning(
|
||||
f"Manager selected invalid task_id={selected_task_id} "
|
||||
@@ -1763,16 +1880,55 @@ Provide a JSON with the structure:
|
||||
logging.info(f"Changed agent for task {selected_task_id} from {original_agent} to {selected_agent_name}")
|
||||
break
|
||||
|
||||
# A task whose internal retries are exhausted lands in "failed" — a
|
||||
# valid id that never becomes "completed". Cap how many times the
|
||||
# manager may re-delegate it, then treat it as terminal so the loop
|
||||
# can make progress instead of retrying it forever.
|
||||
if self.tasks[selected_task_id].status == "failed":
|
||||
failed_reselect_counts[selected_task_id] = failed_reselect_counts.get(selected_task_id, 0) + 1
|
||||
if failed_reselect_counts[selected_task_id] > MAX_TASK_RESELECTIONS:
|
||||
logging.error(
|
||||
f"Task {selected_task_id} failed {failed_reselect_counts[selected_task_id]} times; "
|
||||
f"excluding from further delegation."
|
||||
)
|
||||
excluded_task_ids.add(selected_task_id)
|
||||
if selected_task_id not in counted_task_ids:
|
||||
counted_task_ids.add(selected_task_id)
|
||||
completed_count += 1
|
||||
continue
|
||||
|
||||
if self.tasks[selected_task_id].status != "completed":
|
||||
logging.info(f"Starting execution of task {selected_task_id}")
|
||||
yield selected_task_id
|
||||
logging.info(f"Finished execution of task {selected_task_id}, status: {self.tasks[selected_task_id].status}")
|
||||
# Count a task toward completion only the first time it actually
|
||||
# transitions to "completed" (tracked by id). Counting on every
|
||||
# status check would double-count a task the manager re-selects
|
||||
# after it is already completed, letting completed_count reach
|
||||
# total_tasks early and silently skipping genuinely unexecuted
|
||||
# tasks.
|
||||
if (
|
||||
self.tasks[selected_task_id].status == "completed"
|
||||
and selected_task_id not in counted_task_ids
|
||||
):
|
||||
counted_task_ids.add(selected_task_id)
|
||||
completed_count += 1
|
||||
logging.info(f"Task {selected_task_id} completed. Total completed: {completed_count}/{total_tasks}")
|
||||
else:
|
||||
logging.warning(f"Manager re-selected already-completed task {selected_task_id}; ignoring re-selection.")
|
||||
|
||||
if self.tasks[selected_task_id].status == "completed":
|
||||
completed_count += 1
|
||||
logging.info(f"Task {selected_task_id} completed. Total completed: {completed_count}/{total_tasks}")
|
||||
|
||||
self.tasks[manager_task.id].status = "completed"
|
||||
if self.verbose >= 1:
|
||||
logging.info("All tasks completed under manager supervision.")
|
||||
# Distinguish genuine completion from a bounded early exit (max_iter,
|
||||
# repeated invalid selections, manager "stop", or manager failure). Only
|
||||
# report success when every delegable task is accounted for; otherwise
|
||||
# mark the manager terminal without falsely logging full completion.
|
||||
if completed_count >= total_tasks:
|
||||
self.tasks[manager_task.id].status = "completed"
|
||||
if self.verbose >= 1:
|
||||
logging.info("All tasks completed under manager supervision.")
|
||||
else:
|
||||
self.tasks[manager_task.id].status = "failed"
|
||||
logging.warning(
|
||||
f"Hierarchical process ended with {completed_count}/{total_tasks} tasks completed; "
|
||||
f"some tasks were not executed."
|
||||
)
|
||||
logging.info("Hierarchical task execution finished")
|
||||
@@ -31,12 +31,16 @@ from .protocols import (
|
||||
RunLedgerProtocol,
|
||||
RunRecord,
|
||||
RunStatus,
|
||||
TerminalOutcomeDelivererProtocol,
|
||||
notify_recovered,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RunStatus",
|
||||
"RunRecord",
|
||||
"RunLedgerProtocol",
|
||||
"TerminalOutcomeDelivererProtocol",
|
||||
"notify_recovered",
|
||||
"ACTIVE_STATUSES",
|
||||
"TERMINAL_STATUSES",
|
||||
"SQLiteRunLedger",
|
||||
|
||||
@@ -28,6 +28,8 @@ __all__ = [
|
||||
"RunStatus",
|
||||
"RunRecord",
|
||||
"RunLedgerProtocol",
|
||||
"TerminalOutcomeDelivererProtocol",
|
||||
"notify_recovered",
|
||||
"ACTIVE_STATUSES",
|
||||
"TERMINAL_STATUSES",
|
||||
]
|
||||
@@ -97,6 +99,13 @@ class RunRecord:
|
||||
created_at: float = field(default_factory=time.time)
|
||||
updated_at: float = field(default_factory=time.time)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
#: Whether this run's terminal outcome has been delivered back to its
|
||||
#: origin ``channel``/``thread_id``. Enables exactly-once wake-back: a run
|
||||
#: already ``delivered`` is never re-notified, and a terminal/recovered run
|
||||
#: not yet ``delivered`` is a visible, retryable non-outcome rather than a
|
||||
#: silent one. ``False`` preserves prior behaviour when no deliverer wires
|
||||
#: the seam (see :func:`notify_recovered`).
|
||||
delivered: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialise to a plain dict (JSON/SQLite friendly)."""
|
||||
@@ -111,6 +120,7 @@ class RunRecord:
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"metadata": dict(self.metadata or {}),
|
||||
"delivered": bool(self.delivered),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -135,6 +145,7 @@ class RunRecord:
|
||||
created_at=float(data.get("created_at", time.time())),
|
||||
updated_at=float(data.get("updated_at", time.time())),
|
||||
metadata=dict(data.get("metadata") or {}),
|
||||
delivered=bool(data.get("delivered", False)),
|
||||
)
|
||||
|
||||
|
||||
@@ -167,3 +178,70 @@ class RunLedgerProtocol(Protocol):
|
||||
gateway can wake their origin channels.
|
||||
"""
|
||||
...
|
||||
|
||||
def mark_delivered(self, run_id: str) -> None:
|
||||
"""Record that ``run_id``'s terminal outcome reached its origin.
|
||||
|
||||
The exactly-once bookkeeping half of the wake-back guarantee: once a
|
||||
terminal/recovered run is delivered, this marks it so a later
|
||||
:func:`notify_recovered` (e.g. on the next restart) never re-notifies.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TerminalOutcomeDelivererProtocol(Protocol):
|
||||
"""Transport that wakes a run's origin channel with its terminal outcome.
|
||||
|
||||
Core owns the *guarantee* (a terminal/recovered run is delivered exactly
|
||||
once); the wrapper supplies only this concrete transport. Implementations
|
||||
send ``record.terminal_outcome`` to ``record.channel``/``record.thread_id``
|
||||
and return whether the send landed, so a failure is recorded and retried
|
||||
rather than silently dropped.
|
||||
"""
|
||||
|
||||
async def deliver_terminal(self, record: RunRecord) -> bool:
|
||||
"""Deliver ``record``'s terminal outcome to its origin channel.
|
||||
|
||||
Returns ``True`` when the outcome reached the origin, ``False`` when it
|
||||
could not (so the run stays undelivered and is retried next time).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
async def notify_recovered(
|
||||
ledger: "RunLedgerProtocol",
|
||||
deliverer: "TerminalOutcomeDelivererProtocol",
|
||||
) -> int:
|
||||
"""Guarantee every recovered ``LOST`` run wakes its origin, exactly once.
|
||||
|
||||
Closes the loop the ledger only *documents*: :meth:`recover_orphans`
|
||||
reconciles orphaned runs to ``LOST`` and returns them so their origin can
|
||||
be told the run did not finish — but nothing in core enforced that the
|
||||
telling actually happened. This binder does: for each recovered record it
|
||||
invokes ``deliverer.deliver_terminal`` and, only on success, marks the run
|
||||
``delivered`` via :meth:`RunLedgerProtocol.mark_delivered`. A delivery that
|
||||
fails is left undelivered (a visible, retryable non-outcome) rather than
|
||||
silently skipped.
|
||||
|
||||
Mirrors :meth:`BackgroundJobManager.reconcile_on_start`'s ``redeliver``
|
||||
seam. Best-effort per record: a transport that raises is treated as a
|
||||
failed (retryable) delivery, never crashing recovery for the other runs.
|
||||
|
||||
Args:
|
||||
ledger: The durable ledger to reconcile and record delivery against.
|
||||
deliverer: The concrete channel transport (supplied by the wrapper).
|
||||
|
||||
Returns:
|
||||
The number of recovered runs successfully delivered this call.
|
||||
"""
|
||||
delivered = 0
|
||||
for record in ledger.recover_orphans():
|
||||
try:
|
||||
ok = await deliverer.deliver_terminal(record)
|
||||
except Exception: # noqa: BLE001 — a transport error is a retryable miss
|
||||
ok = False
|
||||
if ok:
|
||||
ledger.mark_delivered(record.run_id)
|
||||
delivered += 1
|
||||
return delivered
|
||||
@@ -39,11 +39,19 @@ CREATE TABLE IF NOT EXISTS runs (
|
||||
terminal_outcome TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
metadata TEXT
|
||||
metadata TEXT,
|
||||
delivered INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
|
||||
"""
|
||||
|
||||
#: Added after the initial release; applied on open so a ledger.db created by
|
||||
#: an older version gains the exactly-once delivery bookkeeping column without
|
||||
#: a destructive migration.
|
||||
_MIGRATIONS = (
|
||||
"ALTER TABLE runs ADD COLUMN delivered INTEGER NOT NULL DEFAULT 0",
|
||||
)
|
||||
|
||||
|
||||
def _dump_metadata(metadata: object) -> str:
|
||||
"""Serialise metadata to JSON, never failing on non-JSON-native values.
|
||||
@@ -97,6 +105,18 @@ class SQLiteRunLedger(RunLedgerProtocol):
|
||||
except sqlite3.OperationalError: # pragma: no cover - defensive
|
||||
pass
|
||||
self._conn.executescript(_SCHEMA)
|
||||
# Bring an older ledger.db up to the current schema. Each migration
|
||||
# is additive and idempotent: re-adding an existing column raises
|
||||
# OperationalError("duplicate column name") which we swallow. Any
|
||||
# OTHER OperationalError (locked DB, disk full, ...) means the
|
||||
# column may be genuinely absent — re-raise so startup fails loudly
|
||||
# instead of leaving reads/upserts to fail later on a missing column.
|
||||
for stmt in _MIGRATIONS:
|
||||
try:
|
||||
self._conn.execute(stmt)
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "duplicate column name" not in str(exc).lower():
|
||||
raise
|
||||
|
||||
# ── public API (RunLedgerProtocol) ────────────────────────────────
|
||||
|
||||
@@ -109,8 +129,9 @@ class SQLiteRunLedger(RunLedgerProtocol):
|
||||
"""
|
||||
INSERT INTO runs (
|
||||
run_id, agent_id, channel, thread_id, status, progress,
|
||||
terminal_outcome, created_at, updated_at, metadata
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
terminal_outcome, created_at, updated_at, metadata,
|
||||
delivered
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id) DO UPDATE SET
|
||||
agent_id=excluded.agent_id,
|
||||
channel=excluded.channel,
|
||||
@@ -119,7 +140,8 @@ class SQLiteRunLedger(RunLedgerProtocol):
|
||||
progress=excluded.progress,
|
||||
terminal_outcome=excluded.terminal_outcome,
|
||||
updated_at=excluded.updated_at,
|
||||
metadata=excluded.metadata
|
||||
metadata=excluded.metadata,
|
||||
delivered=excluded.delivered
|
||||
""",
|
||||
(
|
||||
data["run_id"],
|
||||
@@ -132,6 +154,7 @@ class SQLiteRunLedger(RunLedgerProtocol):
|
||||
data["created_at"],
|
||||
data["updated_at"],
|
||||
_dump_metadata(data["metadata"]),
|
||||
1 if data["delivered"] else 0,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -165,11 +188,19 @@ class SQLiteRunLedger(RunLedgerProtocol):
|
||||
return [self._row_to_record(r) for r in rows]
|
||||
|
||||
def recover_orphans(self) -> List[RunRecord]:
|
||||
"""Mark still-active runs as ``LOST`` and return the affected records.
|
||||
"""Mark still-active runs as ``LOST`` and return the undelivered ones.
|
||||
|
||||
Called on gateway boot: any run left in an active status belonged to a
|
||||
process that exited without recording a terminal outcome. They are
|
||||
reconciled to ``LOST`` so the gateway can notify their origin channels.
|
||||
|
||||
Returns every ``LOST`` run whose terminal outcome has **not** yet been
|
||||
delivered — both the runs reconciled in *this* call and any left
|
||||
undelivered by a prior boot (a delivery that failed or the process that
|
||||
died again before :meth:`mark_delivered`). This makes a transient
|
||||
transport failure automatically retryable on the next boot instead of a
|
||||
permanent silence, while :meth:`mark_delivered` keeps it exactly-once:
|
||||
an already-delivered run is filtered out and never woken twice.
|
||||
"""
|
||||
placeholders = ",".join("?" for _ in ACTIVE_STATUSES)
|
||||
active_values = [s.value for s in ACTIVE_STATUSES]
|
||||
@@ -190,9 +221,15 @@ class SQLiteRunLedger(RunLedgerProtocol):
|
||||
""",
|
||||
[RunStatus.LOST.value, outcome, now, *active_values],
|
||||
)
|
||||
# Return ALL undelivered LOST runs, not just the ones reconciled in
|
||||
# this call: a LOST run whose delivery previously failed (or whose
|
||||
# process died before mark_delivered) is still owed a wake-back, so
|
||||
# it must be retried. ``mark_delivered`` guarantees exactly-once by
|
||||
# flipping ``delivered = 1``; delivered rows are excluded here.
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM runs WHERE status = ? AND updated_at = ?",
|
||||
(RunStatus.LOST.value, now),
|
||||
"SELECT * FROM runs WHERE status = ? AND delivered = 0 "
|
||||
"ORDER BY created_at ASC",
|
||||
(RunStatus.LOST.value,),
|
||||
).fetchall()
|
||||
recovered = [self._row_to_record(r) for r in rows]
|
||||
if recovered:
|
||||
@@ -202,6 +239,21 @@ class SQLiteRunLedger(RunLedgerProtocol):
|
||||
)
|
||||
return recovered
|
||||
|
||||
def mark_delivered(self, run_id: str) -> None:
|
||||
"""Record that ``run_id``'s terminal outcome reached its origin.
|
||||
|
||||
The durable half of the exactly-once wake-back guarantee (see
|
||||
:func:`~praisonaiagents.runs.protocols.notify_recovered`): once set,
|
||||
a subsequent :meth:`recover_orphans` never returns this run again, so a
|
||||
restart cannot re-notify an already-delivered outcome.
|
||||
"""
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"UPDATE runs SET delivered = 1, updated_at = ? "
|
||||
"WHERE run_id = ?",
|
||||
(time.time(), run_id),
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the underlying database connection."""
|
||||
with self._lock:
|
||||
@@ -230,5 +282,6 @@ class SQLiteRunLedger(RunLedgerProtocol):
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"metadata": metadata,
|
||||
"delivered": bool(row["delivered"]),
|
||||
}
|
||||
)
|
||||
@@ -349,11 +349,15 @@ def list_runtimes() -> list[str]:
|
||||
return [e.runtime_id for e in entries]
|
||||
|
||||
|
||||
def resolve_runtime(runtime_id: str) -> AgentRuntimeProtocol:
|
||||
def resolve_runtime(
|
||||
runtime_id: str,
|
||||
config_overrides: Optional[Dict[str, Any]] = None,
|
||||
) -> AgentRuntimeProtocol:
|
||||
"""Resolve a runtime by ID using the global registry.
|
||||
|
||||
Args:
|
||||
runtime_id: Runtime identifier (e.g., "praisonai")
|
||||
config_overrides: Optional configuration overrides
|
||||
|
||||
Returns:
|
||||
AgentRuntimeProtocol instance
|
||||
@@ -361,7 +365,7 @@ def resolve_runtime(runtime_id: str) -> AgentRuntimeProtocol:
|
||||
Raises:
|
||||
ValueError: If runtime_id is not registered
|
||||
"""
|
||||
return _global_registry.resolve(runtime_id)
|
||||
return _global_registry.resolve(runtime_id, config_overrides)
|
||||
|
||||
|
||||
def add_runtime_alias(alias: str, canonical_runtime_id: str) -> None:
|
||||
|
||||
@@ -32,6 +32,32 @@ def resolve_schedule_timezone(tz: str | None = None) -> ZoneInfo:
|
||||
raise ValueError(f"Unknown IANA timezone: {name!r}") from exc
|
||||
|
||||
|
||||
def localize_wall_clock(target: datetime, tz: str | None = None) -> datetime:
|
||||
"""Attach the zone a naive wall-clock timestamp was written in.
|
||||
|
||||
A naive ``at`` string is what a person typed from their own clock, so it
|
||||
means *that wall-clock time in the schedule's zone*, resolved in order:
|
||||
the schedule's explicit ``tz``, then ``PRAISONAI_SCHEDULE_TIMEZONE``, then
|
||||
the system's local zone. It is never UTC by default -- stamping UTC is
|
||||
exactly the defect that made one-shot jobs fire an hour off outside UTC.
|
||||
|
||||
An already-aware ``target`` is returned unchanged: an explicit offset in
|
||||
the string always wins over any default.
|
||||
|
||||
The local-zone branch uses :meth:`datetime.astimezone` on the naive value
|
||||
so the offset is the one in force *on that date* (DST-correct), not the
|
||||
offset in force right now.
|
||||
|
||||
Raises ``ValueError`` when the named zone is not a known IANA zone.
|
||||
"""
|
||||
if target.tzinfo is not None:
|
||||
return target
|
||||
name = tz or os.environ.get("PRAISONAI_SCHEDULE_TIMEZONE")
|
||||
if name:
|
||||
return target.replace(tzinfo=resolve_schedule_timezone(name))
|
||||
return target.astimezone()
|
||||
|
||||
|
||||
def next_fire_time(
|
||||
cron_expr: str,
|
||||
base: float,
|
||||
@@ -77,19 +103,17 @@ def is_due(
|
||||
if job.last_run_at is not None:
|
||||
return False # Already ran
|
||||
try:
|
||||
target = datetime.fromisoformat(sched.at)
|
||||
if target.tzinfo is None:
|
||||
# A naive ``at`` reflects the user's wall clock (they typed it
|
||||
# from local time), so interpret it in the schedule's timezone:
|
||||
# the explicit tz / PRAISONAI_SCHEDULE_TIMEZONE when set,
|
||||
# otherwise the local zone rather than UTC — a naive string in a
|
||||
# non-UTC zone would otherwise fire an hour (or more) off.
|
||||
tz = sched.tz or default_timezone
|
||||
if tz or os.environ.get("PRAISONAI_SCHEDULE_TIMEZONE"):
|
||||
tzinfo = resolve_schedule_timezone(tz)
|
||||
else:
|
||||
tzinfo = datetime.now().astimezone().tzinfo
|
||||
target = target.replace(tzinfo=tzinfo)
|
||||
# ``parse_schedule`` stamps every ``at`` it produces with its zone,
|
||||
# so a string reaching here is normally aware and evaluated as-is.
|
||||
# A naive string (a job stored before zones were stamped, or a
|
||||
# hand-written config.yaml entry) is a legacy wall-clock value:
|
||||
# ``localize_wall_clock`` reads it in the schedule tz, else the
|
||||
# instance default, else PRAISONAI_SCHEDULE_TIMEZONE, else the
|
||||
# runner's local zone -- never UTC by default.
|
||||
target = localize_wall_clock(
|
||||
datetime.fromisoformat(sched.at),
|
||||
sched.tz or default_timezone,
|
||||
)
|
||||
# Evaluate against the caller-supplied ``now`` (not wall-clock) so
|
||||
# one-shot jobs are deterministic and consistent with every/cron.
|
||||
return now >= target.timestamp()
|
||||
|
||||
@@ -13,7 +13,9 @@ Supported formats:
|
||||
"*/10s" → every 10s
|
||||
"3600" → every 3600s (raw seconds)
|
||||
"cron:0 7 * * *" → cron expression
|
||||
"at:2026-03-01T09:00:00" → one-shot ISO timestamp
|
||||
"at:2026-03-01T09:00:00" → one-shot ISO timestamp (naive = wall clock in
|
||||
the schedule tz / PRAISONAI_SCHEDULE_TIMEZONE /
|
||||
the local zone; stored with that zone attached)
|
||||
"in 20 minutes" → one-shot ~20min from now
|
||||
"at 9am" → one-shot next 09:00
|
||||
"every day at 9am" → daily at 09:00 (cron)
|
||||
@@ -21,6 +23,7 @@ Supported formats:
|
||||
"every monday 9am" → day-of-week at clock time (cron)
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -146,20 +149,22 @@ def _natural_to_cron(expr: str):
|
||||
def _clock_to_next_at(hour: int, minute: int, tz: str | None) -> str:
|
||||
"""Return the next ISO timestamp for the given clock time (one-shot).
|
||||
|
||||
The clock time denotes local time in the schedule's timezone (``tz`` or
|
||||
the process default), so ``at 9am`` fires at 09:00 in that zone rather
|
||||
than 09:00 UTC. Falls back to UTC when the timezone cannot be resolved.
|
||||
The clock time is a wall-clock reading in the schedule's zone -- the
|
||||
explicit ``tz``, else ``PRAISONAI_SCHEDULE_TIMEZONE``, else the system's
|
||||
local zone -- so ``at 9am`` typed in London fires at 09:00 London time,
|
||||
not 09:00 UTC. The result is always timezone-aware, so ``is_due()`` never
|
||||
has to guess which zone the person meant.
|
||||
"""
|
||||
try:
|
||||
from .due import resolve_schedule_timezone
|
||||
tzinfo = resolve_schedule_timezone(tz)
|
||||
except Exception:
|
||||
tzinfo = timezone.utc
|
||||
now = datetime.now(tzinfo)
|
||||
from .due import localize_wall_clock, resolve_schedule_timezone
|
||||
|
||||
name = tz or os.environ.get("PRAISONAI_SCHEDULE_TIMEZONE")
|
||||
now = datetime.now(resolve_schedule_timezone(name)) if name else datetime.now()
|
||||
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if target <= now:
|
||||
target += timedelta(days=1)
|
||||
return target.isoformat()
|
||||
# Re-localise a naive (local-zone) target so its offset is the one in
|
||||
# force on the target date, not the one in force right now (DST edges).
|
||||
return localize_wall_clock(target, tz).isoformat()
|
||||
|
||||
|
||||
def _cron_schedule(cron_expr: str, tz: str | None) -> Schedule:
|
||||
@@ -206,11 +211,21 @@ def parse_schedule(expr: str, tz: str | None = None) -> Schedule:
|
||||
raise ValueError("Empty cron expression after 'cron:' prefix")
|
||||
return _cron_schedule(cron_expr, tz)
|
||||
|
||||
# At prefix (ISO timestamp)
|
||||
# At prefix (ISO timestamp). A naive timestamp is the user's wall clock:
|
||||
# stamp it with the schedule tz / PRAISONAI_SCHEDULE_TIMEZONE / local zone
|
||||
# now, so the stored value names an unambiguous instant and is_due() never
|
||||
# has to guess. An explicit offset in the string is kept as written.
|
||||
if lower.startswith("at:"):
|
||||
at_str = expr[3:].strip()
|
||||
if not at_str:
|
||||
raise ValueError("Empty timestamp after 'at:' prefix")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(at_str)
|
||||
except ValueError:
|
||||
parsed = None # unparseable: stored verbatim, is_due() logs it
|
||||
if parsed is not None:
|
||||
from .due import localize_wall_clock
|
||||
at_str = localize_wall_clock(parsed, tz).isoformat()
|
||||
return Schedule(kind="at", at=at_str, tz=tz)
|
||||
|
||||
# Interval pattern: */30m, */6h, */10s
|
||||
|
||||
@@ -7,11 +7,10 @@ exchange in a conversation. Uses a lightweight model for fast generation.
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from ..llm.model_providers import default_auxiliary_model
|
||||
|
||||
__all__ = ["generate_title", "generate_title_async"]
|
||||
|
||||
_DEFAULT_SMALL_MODEL = "gpt-4o-mini"
|
||||
|
||||
|
||||
def _resolve_small_model(llm_model: Optional[str], primary_model: Optional[str]) -> str:
|
||||
"""Resolve the auxiliary model for title generation.
|
||||
@@ -19,18 +18,21 @@ def _resolve_small_model(llm_model: Optional[str], primary_model: Optional[str])
|
||||
An explicit ``llm_model`` always wins. Otherwise the configured
|
||||
``defaults.small_model`` is used, falling back to the primary model and
|
||||
finally to the historical ``gpt-4o-mini`` default so existing behaviour is
|
||||
preserved when nothing is configured.
|
||||
preserved when nothing is configured. The default is resolved at call time
|
||||
so ``PRAISONAI_AUXILIARY_MODEL`` / ``OPENAI_MODEL_NAME`` set after import
|
||||
still take effect.
|
||||
"""
|
||||
if llm_model:
|
||||
return llm_model
|
||||
default_small_model = default_auxiliary_model()
|
||||
try:
|
||||
from ..config.loader import get_small_model
|
||||
resolved = get_small_model(primary_model=primary_model, fallback=_DEFAULT_SMALL_MODEL)
|
||||
resolved = get_small_model(primary_model=primary_model, fallback=default_small_model)
|
||||
if resolved:
|
||||
return resolved
|
||||
except Exception:
|
||||
pass
|
||||
return primary_model or _DEFAULT_SMALL_MODEL
|
||||
return primary_model or default_small_model
|
||||
|
||||
|
||||
def generate_title(
|
||||
|
||||
@@ -10,6 +10,7 @@ import os
|
||||
import time
|
||||
|
||||
from .protocols import TaskStatus, TaskLifecycleManager
|
||||
from ..llm.model_providers import default_auxiliary_model
|
||||
|
||||
# Set up logger
|
||||
logger = get_logger(__name__)
|
||||
@@ -729,7 +730,7 @@ class Task:
|
||||
if hasattr(self.agent.llm_instance, 'model'):
|
||||
llm_model = self.agent.llm_instance.model
|
||||
else:
|
||||
llm_model = "gpt-4o-mini" # Default fallback
|
||||
llm_model = default_auxiliary_model()
|
||||
elif hasattr(self.agent, 'llm') and self.agent.llm:
|
||||
# For standard model strings
|
||||
llm_model = self.agent.llm
|
||||
|
||||
@@ -136,7 +136,7 @@ def github_create_branch(branch_name: str) -> str:
|
||||
# Validate branch name to prevent misinterpretation as git options or invalid refs
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "check-ref-format", "--branch", branch_name],
|
||||
["git", "check-ref-format", f"refs/heads/{branch_name}"],
|
||||
check=True, capture_output=True, text=True
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
|
||||
@@ -205,8 +205,17 @@ class ShellTools:
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error executing command: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
# Name the exception type and log the traceback. Some exceptions
|
||||
# here really are command-execution failures caused by bad caller
|
||||
# input (e.g. a non-string command, or an env value that is not a
|
||||
# string), which must keep returning a result dict the model can
|
||||
# recover from rather than propagating. But an internal programming
|
||||
# error (a signature mismatch, say) lands here too, and reporting it
|
||||
# as a bare "Error executing command: <message>" is how such a bug
|
||||
# gets mistaken for a command failure. The type name plus a
|
||||
# traceback makes the difference visible at a glance.
|
||||
error_msg = f"Error executing command: {type(e).__name__}: {e}"
|
||||
logging.error(error_msg, exc_info=True)
|
||||
return {
|
||||
'stdout': '',
|
||||
'stderr': error_msg,
|
||||
|
||||
@@ -36,6 +36,7 @@ from .workflow_configs import (
|
||||
TaskContextConfig, TaskOutputConfig, TaskExecutionConfig, TaskRoutingConfig,
|
||||
)
|
||||
from ..task.task import Task
|
||||
from ..llm.model_providers import default_auxiliary_model
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -1267,7 +1268,7 @@ class AgentFlow:
|
||||
self._validate_framework()
|
||||
|
||||
# Use default LLM if not specified
|
||||
model = llm or self.llm or "gpt-4o-mini"
|
||||
model = default_auxiliary_model(llm or self.llm)
|
||||
logger.debug(f"Workflow using model: {model} (llm={llm}, default_llm={self.llm})")
|
||||
|
||||
# Agents the flow builds itself already receive the default via
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.3"
|
||||
version = "1.7.4"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -28,6 +28,7 @@ markers =
|
||||
# Infrastructure markers
|
||||
network: Makes outbound network calls
|
||||
local_service: Requires Docker or local service running
|
||||
offline: Fully mocked - opt out of provider/network auto-marking
|
||||
slow: Takes more than 5 seconds consistently
|
||||
flaky: Known intermittent failures - retries allowed
|
||||
allow_sleep: Opt out of fast_sleep fixture
|
||||
|
||||
@@ -255,6 +255,53 @@ class TestEnhancedStickyComment:
|
||||
# File should be clickable link
|
||||
assert "https://github.com/MervinPraison/PraisonAI/blob/feature/test/src/main.py" in body
|
||||
|
||||
def test_failed_branch_creation_clears_sticky_branch(self):
|
||||
"""A rejected branch must not remain rendered as active."""
|
||||
from praisonai.cli.commands.github import StickyComment
|
||||
|
||||
sticky = StickyComment(
|
||||
api_base="https://api.github.com",
|
||||
issue=112,
|
||||
token="fake",
|
||||
task_type="Issue",
|
||||
title="Test",
|
||||
run_url="",
|
||||
repo_name="owner/repo",
|
||||
)
|
||||
|
||||
sticky.set_branch("@{-1}")
|
||||
sticky.on_tool_end("agent", "github_create_branch", "Error: invalid branch name '@{-1}'")
|
||||
|
||||
assert sticky._branch_name is None
|
||||
assert "@{-1}" not in sticky._build_body()
|
||||
|
||||
def test_failed_branch_creation_restores_prior_sticky_branch_and_refreshes(self, monkeypatch):
|
||||
"""A failed later creation restores the displayed active branch."""
|
||||
from praisonai.cli.commands.github import StickyComment
|
||||
|
||||
sticky = StickyComment(
|
||||
api_base="https://api.github.com",
|
||||
issue=113,
|
||||
token="fake",
|
||||
task_type="Issue",
|
||||
title="Test",
|
||||
run_url="",
|
||||
repo_name="owner/repo",
|
||||
)
|
||||
sticky.set_branch("feature/active")
|
||||
sticky.on_tool_start("agent", "github_create_branch", {"branch_name": "@{-1}"})
|
||||
sticky.set_branch("@{-1}")
|
||||
refreshes = []
|
||||
monkeypatch.setattr(sticky, "_schedule_push", lambda: refreshes.append(True))
|
||||
|
||||
sticky.on_tool_end("agent", "github_create_branch", "Error: invalid branch name '@{-1}'")
|
||||
|
||||
assert sticky._branch_name == "feature/active"
|
||||
body = sticky._build_body()
|
||||
assert "[🌿 `feature/active`]" in body
|
||||
assert "[🌿 `@{-1}`]" not in body
|
||||
assert refreshes == [True]
|
||||
|
||||
|
||||
@pytest.mark.manual
|
||||
class TestEnhancedIntegration:
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""
|
||||
Tests for architectural fixes from Issue #1392.
|
||||
|
||||
This test suite validates all three gaps:
|
||||
This test suite validates:
|
||||
1. Gap 1: Sync/Async Duplication - unified execution core
|
||||
2. Gap 2: Parallel Tool Execution - concurrent tool calls
|
||||
3. Gap 3: Streaming Protocol - unified streaming adapters
|
||||
|
||||
Tests follow AGENTS.md requirements:
|
||||
- Real agentic tests (actual LLM calls)
|
||||
@@ -20,13 +19,6 @@ from unittest.mock import Mock, patch, MagicMock, AsyncMock
|
||||
from praisonaiagents import Agent, tool
|
||||
from praisonaiagents.config.feature_configs import ToolConfig
|
||||
from praisonaiagents.agent.unified_execution_mixin import UnifiedExecutionMixin
|
||||
from praisonaiagents.llm.streaming_protocol import (
|
||||
get_streaming_adapter,
|
||||
DefaultStreamingAdapter,
|
||||
OllamaStreamingAdapter,
|
||||
AnthropicStreamingAdapter,
|
||||
GeminiStreamingAdapter
|
||||
)
|
||||
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
|
||||
from praisonaiagents.tools.call_executor import (
|
||||
create_tool_call_executor,
|
||||
@@ -232,111 +224,6 @@ class TestGap2ParallelToolExecution:
|
||||
assert getattr(default_agent, 'parallel_tool_calls', False) is False
|
||||
|
||||
|
||||
class TestGap3StreamingProtocol:
|
||||
"""Test Gap 3: Streaming Protocol unification."""
|
||||
|
||||
def test_streaming_adapter_registry(self):
|
||||
"""Test that streaming adapters are properly registered."""
|
||||
|
||||
# Test default adapters are available
|
||||
default_adapter = get_streaming_adapter("default")
|
||||
ollama_adapter = get_streaming_adapter("ollama")
|
||||
anthropic_adapter = get_streaming_adapter("anthropic")
|
||||
gemini_adapter = get_streaming_adapter("gemini")
|
||||
|
||||
assert isinstance(default_adapter, DefaultStreamingAdapter)
|
||||
assert isinstance(ollama_adapter, OllamaStreamingAdapter)
|
||||
assert isinstance(anthropic_adapter, AnthropicStreamingAdapter)
|
||||
assert isinstance(gemini_adapter, GeminiStreamingAdapter)
|
||||
|
||||
# Test provider name matching
|
||||
claude_adapter = get_streaming_adapter("claude-3-sonnet")
|
||||
assert isinstance(claude_adapter, AnthropicStreamingAdapter)
|
||||
|
||||
ollama_model_adapter = get_streaming_adapter("ollama/llama2")
|
||||
assert isinstance(ollama_model_adapter, OllamaStreamingAdapter)
|
||||
|
||||
def test_provider_specific_streaming_capabilities(self):
|
||||
"""Test that each adapter reports correct streaming capabilities."""
|
||||
|
||||
# Default adapter - supports most streaming scenarios
|
||||
default = get_streaming_adapter("default")
|
||||
assert default.can_stream() is True
|
||||
assert default.can_stream(tools=[{"name": "test"}]) is True
|
||||
|
||||
# Ollama adapter - doesn't support streaming with tools
|
||||
ollama = get_streaming_adapter("ollama")
|
||||
assert ollama.can_stream() is True
|
||||
assert ollama.can_stream(tools=[{"name": "test"}]) is False
|
||||
|
||||
# Anthropic adapter - disabled due to litellm bug
|
||||
anthropic = get_streaming_adapter("anthropic")
|
||||
assert anthropic.can_stream() is False
|
||||
assert anthropic.can_stream(tools=[{"name": "test"}]) is False
|
||||
|
||||
# Gemini adapter - supports basic streaming but not with tools
|
||||
gemini = get_streaming_adapter("gemini")
|
||||
assert gemini.can_stream() is True
|
||||
assert gemini.can_stream(tools=[{"name": "test"}]) is False
|
||||
|
||||
def test_stream_unavailable_events(self):
|
||||
"""Test that adapters emit proper unavailable events."""
|
||||
|
||||
# Test Anthropic unavailable event
|
||||
anthropic = get_streaming_adapter("anthropic")
|
||||
event = anthropic.create_stream_unavailable_event()
|
||||
|
||||
assert event.type == StreamEventType.STREAM_UNAVAILABLE
|
||||
assert "litellm" in event.error.lower()
|
||||
assert event.metadata["provider"] == "anthropic"
|
||||
|
||||
# Test Gemini unavailable event with tools
|
||||
gemini = get_streaming_adapter("gemini")
|
||||
event = gemini.create_stream_unavailable_event("tools present")
|
||||
|
||||
assert event.type == StreamEventType.STREAM_UNAVAILABLE
|
||||
assert "tools present" in event.error
|
||||
assert event.metadata["provider"] == "gemini"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_adapter_integration(self):
|
||||
"""Test streaming adapter integration with mock LLM responses."""
|
||||
|
||||
default_adapter = get_streaming_adapter("default")
|
||||
|
||||
# Mock the litellm.acompletion to return test stream chunks
|
||||
mock_chunks = [
|
||||
Mock(choices=[Mock(delta=Mock(content="Hello", tool_calls=None))]),
|
||||
Mock(choices=[Mock(delta=Mock(content=" world", tool_calls=None))]),
|
||||
Mock(choices=[Mock(delta=Mock(content="!", tool_calls=None))])
|
||||
]
|
||||
|
||||
async def mock_acompletion(**kwargs):
|
||||
for chunk in mock_chunks:
|
||||
yield chunk
|
||||
|
||||
with patch('litellm.acompletion', mock_acompletion):
|
||||
events = []
|
||||
async for event in default_adapter.stream_completion(
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
model="gpt-3.5-turbo",
|
||||
temperature=1.0
|
||||
):
|
||||
events.append(event)
|
||||
|
||||
# Verify event sequence
|
||||
assert len(events) >= 4 # REQUEST_START, FIRST_TOKEN, DELTA_TEXT, STREAM_END
|
||||
assert events[0].type == StreamEventType.REQUEST_START
|
||||
assert events[-1].type == StreamEventType.STREAM_END
|
||||
|
||||
# Find text events
|
||||
text_events = [e for e in events if e.type in [StreamEventType.FIRST_TOKEN, StreamEventType.DELTA_TEXT]]
|
||||
assert len(text_events) == 3
|
||||
assert text_events[0].content == "Hello"
|
||||
assert text_events[1].content == " world"
|
||||
assert text_events[2].content == "!"
|
||||
|
||||
|
||||
class TestRealAgenticIntegration:
|
||||
"""Real agentic tests as required by AGENTS.md."""
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Regression tests for the streaming-error fallback (issue #4719).
|
||||
|
||||
When a streaming request raises, `_start_stream_impl` falls back to the sync
|
||||
`self.chat(...)` via `_stream_fallback_chat`. `start()` has already merged
|
||||
`stream=True` (and knobs like `max_tokens`/`top_p`) into the kwargs it forwards,
|
||||
so a naive fallback:
|
||||
|
||||
1. reached the sync adapter with `stream=True` ("Streaming is not supported"),
|
||||
2. raised `TypeError` for kwargs `chat()` cannot name (e.g. `max_tokens`),
|
||||
3. surfaced the fallback's error instead of the real streaming cause.
|
||||
|
||||
These tests exercise the fix without a live LLM by driving the private helper
|
||||
`_stream_fallback_chat` directly.
|
||||
"""
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.agent.chat_mixin import ChatMixin
|
||||
|
||||
|
||||
class _Stub(ChatMixin):
|
||||
"""Minimal object exposing ChatMixin's helper + a recording chat()."""
|
||||
|
||||
def __init__(self):
|
||||
self.recorded = None
|
||||
|
||||
def chat(self, prompt, temperature=None, tools=None, output_json=None,
|
||||
output_pydantic=None, reasoning_steps=False, stream=None,
|
||||
task_name=None, task_description=None, task_id=None, config=None,
|
||||
force_retrieval=False, skip_retrieval=False, attachments=None,
|
||||
tool_choice=None, seed=None, cancel_token=None):
|
||||
self.recorded = {
|
||||
"prompt": prompt,
|
||||
"stream": stream,
|
||||
"temperature": temperature,
|
||||
"tools": tools,
|
||||
}
|
||||
return "sync answer"
|
||||
|
||||
|
||||
def test_fallback_strips_stream_and_unknown_kwargs():
|
||||
"""stream is forced False; kwargs chat() cannot name are dropped."""
|
||||
stub = _Stub()
|
||||
kwargs = {
|
||||
"stream": True,
|
||||
"max_tokens": 128, # not a chat() parameter
|
||||
"top_p": 0.9, # not a chat() parameter
|
||||
"temperature": 0.5, # accepted
|
||||
"tools": ["t"], # accepted
|
||||
}
|
||||
|
||||
result = stub._stream_fallback_chat("hello", kwargs, RuntimeError("boom"))
|
||||
|
||||
assert result == "sync answer"
|
||||
assert stub.recorded is not None
|
||||
assert stub.recorded["prompt"] == "hello"
|
||||
assert stub.recorded["stream"] is False, "sync path must not be asked to stream"
|
||||
assert stub.recorded["temperature"] == 0.5
|
||||
assert stub.recorded["tools"] == ["t"]
|
||||
|
||||
|
||||
def test_fallback_never_asks_sync_path_to_stream():
|
||||
stub = _Stub()
|
||||
kwargs = {"stream": True, "max_tokens": 64, "temperature": 0.2}
|
||||
|
||||
stub._stream_fallback_chat("hi", kwargs, RuntimeError("boom"))
|
||||
|
||||
assert stub.recorded is not None
|
||||
assert stub.recorded["stream"] is False
|
||||
assert stub.recorded["temperature"] == 0.2
|
||||
|
||||
|
||||
def test_original_streaming_exception_is_surfaced_when_fallback_fails():
|
||||
"""When the fallback also fails, the ORIGINAL cause is the chained root."""
|
||||
original = ValueError("context length exceeded")
|
||||
|
||||
def failing_chat(self, prompt, **kwargs):
|
||||
raise RuntimeError("Streaming is not supported in sync OpenAIAdapter")
|
||||
|
||||
stub = _Stub()
|
||||
stub.chat = types.MethodType(failing_chat, stub)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
stub._stream_fallback_chat("hi", {"stream": True}, original)
|
||||
|
||||
# The fallback failure is raised, but chained from the original streaming
|
||||
# cause so the real reason (context length) is not lost.
|
||||
assert exc_info.value.__cause__ is original
|
||||
# And the outer handler is told the fallback was already exhausted so it
|
||||
# does not run chat() a second time.
|
||||
assert getattr(exc_info.value, "_praisonai_stream_fallback_exhausted", False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -33,7 +33,7 @@ def test_agent_runtime_integration():
|
||||
preferred_runtime="native",
|
||||
)
|
||||
agent = Agent(instructions="Test agent", runtime=config)
|
||||
assert agent._runtime_config is config
|
||||
assert agent._runtime_config == config
|
||||
|
||||
config_no_validation = RuntimeConfig(
|
||||
required_capabilities=["native_hooks"],
|
||||
|
||||
@@ -7,7 +7,15 @@ from unittest.mock import MagicMock, patch
|
||||
from praisonaiagents.agent.tool_execution import ToolExecutionMixin
|
||||
|
||||
|
||||
class _HookRegistry:
|
||||
def has_hooks(self, event):
|
||||
return False
|
||||
|
||||
|
||||
class _HookRunner:
|
||||
def __init__(self):
|
||||
self.registry = _HookRegistry()
|
||||
|
||||
def execute_sync(self, *args, **kwargs):
|
||||
return []
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for praisonai.cli.features.approval — resolve_approval_backend."""
|
||||
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
@@ -22,13 +23,30 @@ class TestResolveApprovalBackend:
|
||||
assert self._resolve("none") is None
|
||||
assert self._resolve("None") is None
|
||||
|
||||
def test_console_returns_console_backend(self):
|
||||
b = self._resolve("console")
|
||||
assert type(b).__name__ == "ConsoleBackend"
|
||||
def test_console_returns_the_interactive_cli_backend(self):
|
||||
""""console" resolves to the interactive CLI backend, not ConsoleBackend.
|
||||
|
||||
def test_true_returns_console_backend(self):
|
||||
b = self._resolve("true")
|
||||
assert type(b).__name__ == "ConsoleBackend"
|
||||
These two tests asserted `type(b).__name__ == "ConsoleBackend"` and had
|
||||
been red ever since #2037 replaced it with the persisting interactive
|
||||
backend and c9 moved the resolver into praisonai-bot. Nothing caught it:
|
||||
praisonai-agents' unit tree is run by no CI workflow (#4748).
|
||||
|
||||
Asserting a class *name* is what made this brittle, so assert the thing
|
||||
that actually matters instead: the resolver hands back the real class,
|
||||
and that class satisfies the backend contract callers rely on.
|
||||
"""
|
||||
from praisonai_code.cli.approval_backend import InteractiveCLIApprovalBackend
|
||||
|
||||
for value in ("console", "true"):
|
||||
b = self._resolve(value)
|
||||
assert isinstance(b, InteractiveCLIApprovalBackend), value
|
||||
# registry.py and tool_execution.py both probe for the sync variant
|
||||
# with hasattr and fall back when it is absent, so only the async
|
||||
# entry point is part of the contract every backend must honour —
|
||||
# and the async path awaits it, so it must be a coroutine function.
|
||||
assert inspect.iscoroutinefunction(
|
||||
getattr(b, "request_approval", None)
|
||||
), value
|
||||
|
||||
def test_auto_returns_auto_approve_backend(self):
|
||||
b = self._resolve("auto")
|
||||
@@ -82,10 +100,11 @@ class TestResolveApprovalBackend:
|
||||
self._resolve("foobar")
|
||||
|
||||
def test_case_insensitive(self):
|
||||
b = self._resolve("Console")
|
||||
assert type(b).__name__ == "ConsoleBackend"
|
||||
b = self._resolve("AUTO")
|
||||
assert type(b).__name__ == "AutoApproveBackend"
|
||||
"""Casing must not change which backend is chosen."""
|
||||
from praisonai_code.cli.approval_backend import InteractiveCLIApprovalBackend
|
||||
|
||||
assert isinstance(self._resolve("Console"), InteractiveCLIApprovalBackend)
|
||||
assert type(self._resolve("AUTO")).__name__ == "AutoApproveBackend"
|
||||
|
||||
|
||||
class TestValidBackends:
|
||||
|
||||
@@ -392,7 +392,7 @@ class TestContextCompactor:
|
||||
{"role": "user", "content": "More content here."},
|
||||
]
|
||||
|
||||
compacted, result = compactor.compact_async(messages)
|
||||
compacted, result = await compactor.compact_async(messages)
|
||||
|
||||
assert result.strategy_used == CompactionStrategy.LLM_SUMMARIZE
|
||||
assert len(compacted) < len(messages)
|
||||
@@ -423,7 +423,7 @@ class TestContextCompactor:
|
||||
{"role": "assistant", "content": "This is another long response."},
|
||||
]
|
||||
|
||||
compacted, result = compactor.compact_async(messages)
|
||||
compacted, result = await compactor.compact_async(messages)
|
||||
|
||||
# Should fallback to naive summarization and not crash
|
||||
assert result.strategy_used == CompactionStrategy.LLM_SUMMARIZE
|
||||
@@ -434,8 +434,24 @@ class TestContextCompactor:
|
||||
assert len(fallback_msgs) <= 1 # May have fallback summary
|
||||
|
||||
def test_compactor_format_messages_for_summary(self):
|
||||
"""Test _format_messages_for_summary method."""
|
||||
compactor = ContextCompactor()
|
||||
"""_format_messages_for_summary delegates to the injected formatter."""
|
||||
class _Formatter:
|
||||
def format_for_summary(self, messages):
|
||||
lines = []
|
||||
for i, m in enumerate(messages, 1):
|
||||
role = m.get("role")
|
||||
if m.get("tool_calls"):
|
||||
names = ", ".join(
|
||||
tc["function"]["name"] for tc in m["tool_calls"]
|
||||
)
|
||||
lines.append(f"{i}. {role}: Called tools: {names}")
|
||||
elif role == "tool":
|
||||
lines.append(f"{i}. tool {m.get('tool_call_id')}: {m.get('content')}")
|
||||
else:
|
||||
lines.append(f"{i}. {role}: {m.get('content')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
compactor = ContextCompactor(message_formatter=_Formatter())
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello, how are you?"},
|
||||
|
||||
@@ -114,8 +114,9 @@ class TestAgentContextPerformance:
|
||||
Agent(instructions="test", context=False)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
# Should be very fast (< 50ms for 10 inits)
|
||||
assert elapsed < 0.5, f"Init too slow: {elapsed}s for 10 agents"
|
||||
# Should be fast; use a generous bound so the guard is not flaky on
|
||||
# loaded CI runners while still catching order-of-magnitude regressions.
|
||||
assert elapsed < 2.0, f"Init too slow: {elapsed}s for 10 agents"
|
||||
|
||||
def test_context_true_no_init_until_access(self):
|
||||
"""context=True should not initialize manager until accessed."""
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Regression tests for issue #4714.
|
||||
|
||||
The streaming entry point (``_start_stream_impl``) used to build the outbound
|
||||
message list and send it to the provider without ever calling
|
||||
``_apply_context_management``. A long-running chat therefore replayed its whole
|
||||
transcript on every streamed turn until the provider rejected the request,
|
||||
while the non-streaming path (``_chat_impl``) compacted correctly.
|
||||
|
||||
These tests drive the OpenAI streaming path with a fake client that captures
|
||||
the outbound messages and assert that context management runs and bounds the
|
||||
request.
|
||||
"""
|
||||
|
||||
|
||||
class _FakeDelta:
|
||||
def __init__(self, content):
|
||||
self.content = content
|
||||
self.tool_calls = None
|
||||
|
||||
|
||||
class _FakeChoice:
|
||||
def __init__(self, content):
|
||||
self.delta = _FakeDelta(content)
|
||||
|
||||
|
||||
class _FakeChunk:
|
||||
def __init__(self, content):
|
||||
self.choices = [_FakeChoice(content)]
|
||||
|
||||
|
||||
class _FakeCompletions:
|
||||
def __init__(self, capture):
|
||||
self._capture = capture
|
||||
|
||||
def create(self, **kwargs):
|
||||
self._capture["messages"] = kwargs.get("messages")
|
||||
return iter([_FakeChunk("ok")])
|
||||
|
||||
|
||||
class _FakeChat:
|
||||
def __init__(self, capture):
|
||||
self.completions = _FakeCompletions(capture)
|
||||
|
||||
|
||||
class _FakeSyncClient:
|
||||
def __init__(self, capture):
|
||||
self.chat = _FakeChat(capture)
|
||||
|
||||
|
||||
class _FakeOpenAIClient:
|
||||
"""Minimal stand-in for the internal OpenAI client wrapper."""
|
||||
|
||||
def __init__(self, capture):
|
||||
self.sync_client = _FakeSyncClient(capture)
|
||||
|
||||
def build_messages(self, prompt, system_prompt=None, chat_history=None,
|
||||
output_json=None, output_pydantic=None):
|
||||
messages = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
if chat_history:
|
||||
messages.extend(chat_history)
|
||||
messages.append({"role": "user", "content": str(prompt)})
|
||||
return messages, str(prompt)
|
||||
|
||||
|
||||
def _make_streaming_agent(capture):
|
||||
from praisonaiagents import Agent
|
||||
|
||||
def sample_tool(query: str) -> str:
|
||||
"""A sample tool."""
|
||||
return f"Result for {query}"
|
||||
|
||||
# Tools present -> context management auto-enabled (auto_compact, SMART).
|
||||
agent = Agent(instructions="You are helpful", tools=[sample_tool])
|
||||
agent._using_custom_llm = False
|
||||
# ``_openai_client`` is a read-only lazy property backed by the
|
||||
# name-mangled ``__openai_client`` attribute; seed it directly.
|
||||
agent._Agent__openai_client = _FakeOpenAIClient(capture)
|
||||
return agent
|
||||
|
||||
|
||||
class TestStreamingContextManagement:
|
||||
def test_streaming_path_invokes_context_management(self):
|
||||
"""The streamed request must call _apply_context_management (#4714)."""
|
||||
capture = {}
|
||||
agent = _make_streaming_agent(capture)
|
||||
|
||||
calls = {"n": 0}
|
||||
original = agent._apply_context_management
|
||||
|
||||
def _counting(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
return original(*args, **kwargs)
|
||||
|
||||
agent._apply_context_management = _counting
|
||||
|
||||
# Seed a long transcript, the way the desktop replays persisted history.
|
||||
agent.chat_history = []
|
||||
for i in range(500):
|
||||
agent.chat_history.append(
|
||||
{"role": "user", "content": f"question {i} " * 40}
|
||||
)
|
||||
agent.chat_history.append(
|
||||
{"role": "assistant", "content": f"answer {i} " * 40}
|
||||
)
|
||||
|
||||
list(agent._start_stream_impl("final question"))
|
||||
|
||||
assert calls["n"] >= 1, "streaming path never called _apply_context_management"
|
||||
|
||||
def test_streaming_request_is_bounded(self):
|
||||
"""Outbound message list must be smaller than the raw replayed history."""
|
||||
capture = {}
|
||||
agent = _make_streaming_agent(capture)
|
||||
|
||||
agent.chat_history = []
|
||||
for i in range(500):
|
||||
agent.chat_history.append(
|
||||
{"role": "user", "content": f"question {i} " * 40}
|
||||
)
|
||||
agent.chat_history.append(
|
||||
{"role": "assistant", "content": f"answer {i} " * 40}
|
||||
)
|
||||
raw_len = len(agent.chat_history)
|
||||
|
||||
list(agent._start_stream_impl("final question"))
|
||||
|
||||
sent = capture.get("messages")
|
||||
assert sent is not None, "no request was sent"
|
||||
# Compaction must bound the outbound list well below the raw history.
|
||||
assert len(sent) < raw_len, (
|
||||
f"outbound messages ({len(sent)}) not bounded below raw history "
|
||||
f"({raw_len}) - compaction did not run"
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""The custom-LLM streaming branch must compact too.
|
||||
|
||||
#4729 added context compaction to `_start_stream_impl`, but only inside the
|
||||
OpenAI-client branch. Every agent routed through the custom-LLM path still
|
||||
streamed its entire history uncompacted, so a long conversation was sent in
|
||||
full and rejected by the provider for exceeding the context window.
|
||||
|
||||
The trigger is the routing flag, not the vendor: `llm="openai/gpt-4o-mini"`
|
||||
takes this branch too, so this was not limited to Anthropic or Ollama users.
|
||||
|
||||
test_stream_context_management.py pins the OpenAI branch and hardcodes
|
||||
`_using_custom_llm = False`, which is exactly why it stayed green while this
|
||||
half was broken.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("OPENAI_API_KEY", "sk-test-not-real")
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
|
||||
def _agent_with_history(messages: int, words: int):
|
||||
agent = Agent(name="t", instructions="x", llm="openai/gpt-4o-mini",
|
||||
tools=[lambda x: x], context=True)
|
||||
agent.chat_history = [
|
||||
{"role": "user" if i % 2 == 0 else "assistant", "content": "filler " * words}
|
||||
for i in range(messages)
|
||||
]
|
||||
sent = {}
|
||||
|
||||
def fake_stream(**kwargs):
|
||||
sent["history"] = list(kwargs.get("chat_history") or [])
|
||||
yield "ok"
|
||||
|
||||
agent._using_custom_llm = True
|
||||
agent.llm_instance = type("L", (), {"get_response_stream": staticmethod(fake_stream)})()
|
||||
return agent, sent
|
||||
|
||||
|
||||
def _drain(agent, sent):
|
||||
list(agent._start_stream_impl("hello", stream=True))
|
||||
return sent.get("history")
|
||||
|
||||
|
||||
class TestCustomLlmStreamCompaction:
|
||||
|
||||
def test_an_oversized_history_is_compacted_before_sending(self):
|
||||
"""Without this, 801 messages (~1.7M tokens) go at a 128k-token model."""
|
||||
agent, sent = _agent_with_history(800, 1200)
|
||||
history = _drain(agent, sent)
|
||||
assert history is not None, "the custom-LLM stream was never called"
|
||||
assert len(history) < 801, (
|
||||
"the whole history was streamed uncompacted; the provider would "
|
||||
"reject this turn for exceeding the context window"
|
||||
)
|
||||
|
||||
def test_a_small_history_is_left_alone(self):
|
||||
"""Compaction must not fire when it is not needed."""
|
||||
agent, sent = _agent_with_history(6, 5)
|
||||
history = _drain(agent, sent)
|
||||
assert len(history) == 7, "a short history was compacted unnecessarily"
|
||||
|
||||
def test_the_context_manager_is_actually_configured_here(self):
|
||||
"""Guards the premise: tools present auto-enable context management."""
|
||||
agent, _ = _agent_with_history(4, 5)
|
||||
assert agent.context_manager is not None
|
||||
|
||||
def test_the_system_prompt_still_reaches_the_provider(self):
|
||||
"""The prompt is built once now and reused; it must still be passed."""
|
||||
agent = Agent(name="t", instructions="be terse", llm="openai/gpt-4o-mini",
|
||||
tools=[lambda x: x], context=True)
|
||||
agent.chat_history = []
|
||||
seen = {}
|
||||
|
||||
def fake_stream(**kwargs):
|
||||
seen.update(kwargs)
|
||||
yield "ok"
|
||||
|
||||
agent._using_custom_llm = True
|
||||
agent.llm_instance = type("L", (), {"get_response_stream": staticmethod(fake_stream)})()
|
||||
list(agent._start_stream_impl("hello", stream=True))
|
||||
assert "system_prompt" in seen
|
||||
assert seen["system_prompt"], "system_prompt was dropped when it was hoisted"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Indexing a file must store its contents, not its path.
|
||||
|
||||
`_process_single_input` decided what to do from a hard-coded extension table
|
||||
(.pdf .doc .txt .md .csv .json .xml .html, media, .zip). Anything else fell to
|
||||
a branch commented "Treat as raw text content only if no file extension" —
|
||||
|
||||
memories = [self.normalize_content(input_path)]
|
||||
|
||||
— which stored the PATH, lowercased. Every source file lands there: .py .js
|
||||
.ts .go .rs .java .yaml .toml .sh .ipynb .rst. So indexing a repository built
|
||||
a knowledge base of lowercased filenames and reported success, and retrieval
|
||||
found nothing, with no error at any point.
|
||||
|
||||
The raw-text case the branch was written for still has to work: a caller may
|
||||
pass a string of prose rather than a path.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("OPENAI_API_KEY", "sk-test-not-real")
|
||||
|
||||
from praisonaiagents.knowledge.chunking import Chunking
|
||||
from praisonaiagents.knowledge.knowledge import Knowledge
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def capture():
|
||||
"""A Knowledge with the vector store replaced by a list."""
|
||||
stored = []
|
||||
calls = []
|
||||
|
||||
def build():
|
||||
k = Knowledge.__new__(Knowledge)
|
||||
k._verbose = 0
|
||||
|
||||
def _store(content, **kw):
|
||||
calls.append((content, kw))
|
||||
stored.append(content)
|
||||
return {"ok": True}
|
||||
|
||||
k.store = _store
|
||||
k.normalize_content = lambda c: c.strip().lower()
|
||||
k.__dict__["chunker"] = Chunking(
|
||||
chunker_type="recursive", chunk_size=200, chunk_overlap=20)
|
||||
return k
|
||||
|
||||
return build, stored, calls
|
||||
|
||||
|
||||
def _write(tmpdir, name, body):
|
||||
path = os.path.join(tmpdir, name)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(body)
|
||||
return path
|
||||
|
||||
|
||||
class TestUnlistedExtensionsAreRead:
|
||||
|
||||
@pytest.mark.parametrize("name", ["auth.py", "app.ts", "main.go", "conf.yaml"])
|
||||
def test_source_files_store_their_contents(self, capture, name):
|
||||
build, stored, _ = capture
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = _write(d, name, "def authenticate(user):\n return bcrypt.check(user)\n")
|
||||
build()._process_single_input(path)
|
||||
assert stored, f"{name} indexed nothing"
|
||||
blob = "\n".join(stored)
|
||||
assert "bcrypt" in blob, f"{name} stored something other than its contents"
|
||||
assert path.lower() not in blob, f"{name} stored its own path as content"
|
||||
|
||||
def test_the_path_is_never_the_content(self, capture):
|
||||
build, stored, _ = capture
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = _write(d, "notes.rst", "Release process\n===============\n")
|
||||
build()._process_single_input(path)
|
||||
assert stored[0].strip() != path.lower()
|
||||
|
||||
def test_an_empty_file_is_refused_rather_than_stored(self, capture):
|
||||
build, _, _ = capture
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = _write(d, "empty.py", " \n")
|
||||
with pytest.raises(ValueError):
|
||||
build()._process_single_input(path)
|
||||
|
||||
def test_a_binary_file_is_refused_loudly(self, capture):
|
||||
"""Better a clear error than a filename silently stored as knowledge."""
|
||||
build, _, _ = capture
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "blob.bin")
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(b"\xff\xfe\x00\x01binary")
|
||||
with pytest.raises(ValueError):
|
||||
build()._process_single_input(path)
|
||||
|
||||
def test_metadata_records_the_real_extension(self, capture):
|
||||
build, stored, calls = capture
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = _write(d, "svc.py", "x = 1\n")
|
||||
build()._process_single_input(path, metadata={})
|
||||
assert stored
|
||||
assert calls[0][1]["metadata"]["file_type"] == "py"
|
||||
|
||||
def test_suffixless_file_still_sets_file_type(self, capture):
|
||||
"""A file such as `Dockerfile` or `.gitignore` has no suffix; the new
|
||||
metadata contract must still populate file_type (empty string) rather
|
||||
than omit it."""
|
||||
build, stored, calls = capture
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = _write(d, "Dockerfile", "FROM python:3.12\nRUN pip install x\n")
|
||||
build()._process_single_input(path, metadata={})
|
||||
assert stored
|
||||
assert "file_type" in calls[0][1]["metadata"]
|
||||
assert calls[0][1]["metadata"]["file_type"] == ""
|
||||
|
||||
def test_chunk_ending_in_txt_is_not_reopened_as_path(self, capture):
|
||||
"""A source-file chunk that happens to end in `.txt`/`.pdf`/`.doc`/`.docx`
|
||||
must be stored verbatim, not re-dispatched through store()'s file-path
|
||||
heuristic where a non-existent path would be silently dropped."""
|
||||
build, stored, calls = capture
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = _write(d, "loader.py", "open('missing_config.txt')")
|
||||
build()._process_single_input(path)
|
||||
assert stored, "chunk ending in .txt indexed nothing"
|
||||
assert any("missing_config.txt" in c for c in stored)
|
||||
assert all(kw.get("is_content") is True for _, kw in calls)
|
||||
|
||||
|
||||
class TestExistingBehaviourIsPreserved:
|
||||
|
||||
def test_raw_text_is_still_accepted(self, capture):
|
||||
"""The branch was written for a caller passing prose, not a path."""
|
||||
build, stored, _ = capture
|
||||
build()._process_single_input("just some raw text, not a path")
|
||||
assert stored == ["just some raw text, not a path"]
|
||||
|
||||
def test_a_listed_text_extension_still_works(self, capture):
|
||||
build, stored, _ = capture
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = _write(d, "notes.md", "# Design\nAuthentication uses bcrypt.\n")
|
||||
build()._process_single_input(path)
|
||||
assert "bcrypt" in "\n".join(stored)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Indexing a directory must tell the caller which files failed.
|
||||
|
||||
The directory walk caught per-file exceptions, logged them at WARNING, and
|
||||
dropped them:
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to process file {file_path}: {e}")
|
||||
|
||||
then returned `{'results': [...], 'relations': []}`. So a directory whose
|
||||
every file failed to embed -- a wrong model, a revoked key, an exhausted
|
||||
quota -- returned a success-shaped result with an empty list, and the caller
|
||||
believed the knowledge base was populated. The only trace was a warning in a
|
||||
log nobody reads during a bulk import.
|
||||
|
||||
This is how the repo's own knowledge tests came to fail confusingly: their
|
||||
skip-guard never fired, because no exception ever escaped for it to catch.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
# These tests build a bare Knowledge instance via __new__ and mock store(), so
|
||||
# no embedding backend is ever reached. Do NOT install a fake OPENAI_API_KEY at
|
||||
# import time -- a module-level env mutation persists for the whole test process
|
||||
# and can make later provider-backed tests mistake it for a real credential.
|
||||
from praisonaiagents.knowledge.chunking import Chunking
|
||||
from praisonaiagents.knowledge.knowledge import Knowledge
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def docs_dir():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
for name in ("a.md", "b.md", "c.md"):
|
||||
with open(os.path.join(d, name), "w", encoding="utf-8") as fh:
|
||||
fh.write("Authentication uses bcrypt.\n")
|
||||
yield d
|
||||
|
||||
|
||||
def _index(docs_dir, store_fn):
|
||||
k = Knowledge.__new__(Knowledge)
|
||||
k._verbose = 0
|
||||
k.store = store_fn
|
||||
k.normalize_content = lambda c: c.strip().lower()
|
||||
k.__dict__["chunker"] = Chunking(
|
||||
chunker_type="recursive", chunk_size=200, chunk_overlap=20)
|
||||
k._emit_knowledge_event = lambda *a, **kw: None
|
||||
return k._process_single_input(docs_dir)
|
||||
|
||||
|
||||
def _boom(content, **kw):
|
||||
raise RuntimeError("Failed to generate embedding (model=text-embedding-3-small)")
|
||||
|
||||
|
||||
def _ok(content, **kw):
|
||||
return {"results": ["id"]}
|
||||
|
||||
|
||||
def _swallowed(content, **kw):
|
||||
# Mirrors the real store(): it catches the embedding exception and returns an
|
||||
# empty list instead of raising. Without surfacing, this reads as success.
|
||||
return []
|
||||
|
||||
|
||||
class TestFailuresReachTheCaller:
|
||||
|
||||
def test_a_wholly_failed_import_is_not_silent(self, docs_dir):
|
||||
result = _index(docs_dir, _boom)
|
||||
assert result.get("errors"), (
|
||||
"every file failed and the caller was told nothing"
|
||||
)
|
||||
|
||||
def test_every_failure_is_listed(self, docs_dir):
|
||||
result = _index(docs_dir, _boom)
|
||||
assert len(result["errors"]) == 3
|
||||
|
||||
def test_the_error_names_the_file_and_the_reason(self, docs_dir):
|
||||
entry = _index(docs_dir, _boom)["errors"][0]
|
||||
assert entry["file"].endswith(".md")
|
||||
assert "embedding" in entry["error"].lower()
|
||||
|
||||
def test_a_clean_import_reports_no_errors(self, docs_dir):
|
||||
result = _index(docs_dir, _ok)
|
||||
assert result["errors"] == []
|
||||
assert len(result["results"]) == 3
|
||||
|
||||
def test_partial_failure_reports_both_sides(self, docs_dir):
|
||||
seen = {"n": 0}
|
||||
|
||||
def flaky(content, **kw):
|
||||
seen["n"] += 1
|
||||
if seen["n"] == 1:
|
||||
raise RuntimeError("Failed to generate embedding")
|
||||
return {"results": ["id"]}
|
||||
|
||||
result = _index(docs_dir, flaky)
|
||||
assert len(result["errors"]) == 1
|
||||
assert len(result["results"]) == 2, "the surviving files were lost too"
|
||||
|
||||
def test_the_existing_result_shape_is_preserved(self, docs_dir):
|
||||
"""Callers already read 'results' and 'relations'."""
|
||||
result = _index(docs_dir, _ok)
|
||||
assert "results" in result and "relations" in result
|
||||
|
||||
def test_store_that_swallows_the_error_is_still_reported(self, docs_dir):
|
||||
"""The production store() returns [] on embedding failure rather than
|
||||
raising. That must still surface as an error, not a false success."""
|
||||
result = _index(docs_dir, _swallowed)
|
||||
assert result["results"] == []
|
||||
assert len(result["errors"]) == 3, (
|
||||
"store() swallowed the failure and the caller was told nothing"
|
||||
)
|
||||
|
||||
def test_list_input_propagates_directory_errors(self, docs_dir):
|
||||
"""add([dir]) must not drop the per-file errors the directory collected."""
|
||||
k = Knowledge.__new__(Knowledge)
|
||||
k._verbose = 0
|
||||
k.store = _boom
|
||||
k.normalize_content = lambda c: c.strip().lower()
|
||||
k.__dict__["chunker"] = Chunking(
|
||||
chunker_type="recursive", chunk_size=200, chunk_overlap=20)
|
||||
k._emit_knowledge_event = lambda *a, **kw: None
|
||||
result = k.add([docs_dir])
|
||||
assert len(result["errors"]) == 3
|
||||
assert result["results"] == []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Text sources must be chunked, like every other source.
|
||||
|
||||
`_process_single_input` has three branches. The document/media branch chunks.
|
||||
The unlisted-extension branch chunks. The `text` branch -- .txt .csv .json
|
||||
.xml .md .html .htm, which is what most people actually index -- stored the
|
||||
whole file as ONE memory and lowercased it:
|
||||
|
||||
memories = [self.normalize_content(content)]
|
||||
|
||||
So `chunk_size` and `chunk_overlap` were inert on the commonest path. A 20 KB
|
||||
guide became a single 20 KB memory that no retrieval could rank usefully, and
|
||||
the case of every identifier and proper noun was destroyed on the way to the
|
||||
model. Measured before the fix, with chunk_size=200:
|
||||
|
||||
guide.md memories= 1 largest=20817 lowercased=True
|
||||
notes.rst memories= 30 largest= 695 lowercased=False <- other branch
|
||||
|
||||
The asymmetry is the tell: the same content, indexed two ways, depending only
|
||||
on the file extension.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.knowledge.chunking import Chunking
|
||||
from praisonaiagents.knowledge.knowledge import Knowledge
|
||||
|
||||
BODY = "\n\n".join(
|
||||
f"Section {i}: The Quick Brown Fox jumped over the LAZY dog. " * 12
|
||||
for i in range(30)
|
||||
)
|
||||
|
||||
|
||||
def _index(name, body=BODY, chunk_size=200):
|
||||
stored = []
|
||||
k = Knowledge.__new__(Knowledge)
|
||||
k._verbose = 0
|
||||
k.store = lambda content, **kw: stored.append(content) or {"ok": True}
|
||||
k.normalize_content = lambda c: c.strip().lower()
|
||||
k.__dict__["chunker"] = Chunking(
|
||||
chunker_type="recursive", chunk_size=chunk_size, chunk_overlap=20)
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, name)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(body)
|
||||
k._process_single_input(path)
|
||||
return stored
|
||||
|
||||
|
||||
class TestTextSourcesAreChunked:
|
||||
|
||||
@pytest.mark.parametrize("name", ["guide.md", "data.txt", "notes.html"])
|
||||
def test_a_large_file_becomes_many_memories(self, name):
|
||||
stored = _index(name)
|
||||
assert len(stored) > 1, (
|
||||
f"{name} was stored as a single memory; chunk_size was ignored"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("name", ["guide.md", "data.txt"])
|
||||
def test_no_memory_is_the_whole_file(self, name):
|
||||
stored = _index(name)
|
||||
assert max(len(s) for s in stored) < len(BODY), (
|
||||
f"{name} produced a memory as large as the source file"
|
||||
)
|
||||
|
||||
def test_chunk_size_actually_changes_the_result(self):
|
||||
"""The setting must do something, not merely be accepted."""
|
||||
small = _index("guide.md", chunk_size=200)
|
||||
large = _index("guide.md", chunk_size=4000)
|
||||
assert len(small) > len(large), (
|
||||
"chunk_size had no effect on the number of chunks"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("name", ["guide.md", "data.txt"])
|
||||
def test_case_is_preserved(self, name):
|
||||
"""Lowercasing destroys identifiers and proper nouns in the answer."""
|
||||
assert "LAZY" in " ".join(_index(name)), f"{name} was lowercased"
|
||||
|
||||
def test_all_three_branches_agree(self):
|
||||
"""Same content, three extensions, one behaviour.
|
||||
|
||||
.md goes through the text branch and .rst through the unlisted-extension
|
||||
branch. They disagreed completely before this fix.
|
||||
"""
|
||||
counts = {n: len(_index(n)) for n in ("guide.md", "data.txt", "notes.rst")}
|
||||
assert len(set(counts.values())) == 1, (
|
||||
f"the same content indexed differently by extension: {counts}"
|
||||
)
|
||||
|
||||
def test_a_short_file_still_produces_content(self):
|
||||
"""Chunking must not lose a file smaller than one chunk."""
|
||||
stored = _index("tiny.md", body="Authentication uses bcrypt.")
|
||||
assert stored and "bcrypt" in " ".join(stored)
|
||||
|
||||
def test_an_empty_file_is_still_refused(self):
|
||||
with pytest.raises(ValueError):
|
||||
_index("empty.md", body=" \n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,132 @@
|
||||
"""reasoning_effort must reach the provider, not just the kwargs dict.
|
||||
|
||||
The desktop's Settings -> Models -> "Reasoning effort" control is persisted,
|
||||
forwarded into `agent.start(**kwargs)`, and was then discarded: neither
|
||||
streaming branch of `_start_stream_impl` read it. `grep -c reasoning_effort
|
||||
chat_mixin.py` returned 0. Every level from minimal to high produced a
|
||||
byte-identical provider request, with no error and no warning, while the UI
|
||||
promised "Maps to each backend's native knob".
|
||||
|
||||
The guard that was supposed to catch this asserts against a *stub agent* that
|
||||
records the kwargs it was handed, so it passed while the real Agent dropped
|
||||
the value. These tests assert on what the transport actually receives.
|
||||
|
||||
resolve_reasoning_params returns {} for "off", for an unset level, and for a
|
||||
model with no reasoning control, so wiring it through stays a no-op except
|
||||
where it means something.
|
||||
"""
|
||||
import os
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("OPENAI_API_KEY", "sk-test-not-real")
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.llm import get_openai_client
|
||||
|
||||
|
||||
def _request_for(model, **start_kwargs):
|
||||
"""Drive a real Agent, faking only the transport, and return the request."""
|
||||
seen = {}
|
||||
real = get_openai_client(api_key="sk-test-not-real")
|
||||
|
||||
class _Completions:
|
||||
def create(self, **kw):
|
||||
seen.update(kw)
|
||||
delta = types.SimpleNamespace(content="ok", tool_calls=None)
|
||||
return iter([types.SimpleNamespace(
|
||||
choices=[types.SimpleNamespace(delta=delta, finish_reason="stop")])])
|
||||
|
||||
class _Proxy:
|
||||
sync_client = types.SimpleNamespace(
|
||||
chat=types.SimpleNamespace(completions=_Completions()))
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(real, name)
|
||||
|
||||
agent = Agent(name="t", instructions="x", llm=model)
|
||||
agent._Agent__openai_client = _Proxy()
|
||||
list(agent.start("hi", stream=True, **start_kwargs))
|
||||
return seen
|
||||
|
||||
|
||||
class TestReasoningEffortReachesTheProvider:
|
||||
|
||||
def test_a_reasoning_model_gets_the_native_knob(self):
|
||||
req = _request_for("o4-mini", reasoning_effort="high")
|
||||
assert req.get("reasoning_effort") == "high", (
|
||||
"reasoning_effort never reached the provider request"
|
||||
)
|
||||
|
||||
def test_off_sends_nothing(self):
|
||||
req = _request_for("o4-mini", reasoning_effort="off")
|
||||
assert "reasoning_effort" not in req
|
||||
assert "thinking" not in req
|
||||
|
||||
def test_an_unset_level_sends_nothing(self):
|
||||
req = _request_for("o4-mini")
|
||||
assert "reasoning_effort" not in req
|
||||
|
||||
def test_a_model_without_a_reasoning_knob_is_untouched(self):
|
||||
"""Wiring this through must not change ordinary chat models."""
|
||||
req = _request_for("gpt-4o-mini", reasoning_effort="high")
|
||||
assert "reasoning_effort" not in req
|
||||
assert "thinking" not in req
|
||||
|
||||
def test_extended_thinking_budget_goes_through_extra_body(self):
|
||||
"""A ``thinking`` budget is not a native OpenAI SDK keyword.
|
||||
|
||||
resolve_reasoning_params emits ``{"thinking": ...}`` for an
|
||||
extended-thinking-named model. On this direct path the request is
|
||||
handed to the raw OpenAI SDK, which rejects unknown top-level kwargs
|
||||
with TypeError, so the budget must ride inside ``extra_body`` while
|
||||
``reasoning_effort`` stays top-level.
|
||||
"""
|
||||
req = _request_for("claude-3-7-sonnet", reasoning_effort="high")
|
||||
assert "thinking" not in req, (
|
||||
"thinking must not be a top-level kwarg; the OpenAI SDK rejects it"
|
||||
)
|
||||
assert req.get("extra_body", {}).get("thinking") == {
|
||||
"type": "enabled", "budget_tokens": 16000,
|
||||
}
|
||||
|
||||
def test_native_effort_stays_top_level_not_in_extra_body(self):
|
||||
"""reasoning_effort is native; it must not be buried in extra_body."""
|
||||
req = _request_for("o4-mini", reasoning_effort="high")
|
||||
assert req.get("reasoning_effort") == "high"
|
||||
assert "reasoning_effort" not in req.get("extra_body", {})
|
||||
|
||||
def test_the_ordinary_request_still_looks_right(self):
|
||||
"""Guards against the merge clobbering the rest of the payload."""
|
||||
req = _request_for("gpt-4o-mini")
|
||||
for key in ("model", "messages", "stream"):
|
||||
assert key in req, key
|
||||
|
||||
def test_the_agent_attribute_is_used_when_no_kwarg_is_passed(self):
|
||||
"""Construction-time configuration must work on this path too."""
|
||||
seen = {}
|
||||
real = get_openai_client(api_key="sk-test-not-real")
|
||||
|
||||
class _Completions:
|
||||
def create(self, **kw):
|
||||
seen.update(kw)
|
||||
delta = types.SimpleNamespace(content="ok", tool_calls=None)
|
||||
return iter([types.SimpleNamespace(
|
||||
choices=[types.SimpleNamespace(delta=delta, finish_reason="stop")])])
|
||||
|
||||
class _Proxy:
|
||||
sync_client = types.SimpleNamespace(
|
||||
chat=types.SimpleNamespace(completions=_Completions()))
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(real, name)
|
||||
|
||||
agent = Agent(name="t", instructions="x", llm="o4-mini", reasoning_effort="high")
|
||||
agent._Agent__openai_client = _Proxy()
|
||||
list(agent.start("hi", stream=True))
|
||||
assert seen.get("reasoning_effort") == "high"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Tool calls must survive both paths of get_response_stream.
|
||||
|
||||
Two defects made streaming tool calls do nothing at all, and they compounded:
|
||||
|
||||
1. `_create_tool_message` was called from the streaming tool loop
|
||||
(llm.py, twice) and never defined anywhere in the package. The first tool
|
||||
result raised AttributeError. The broad `except Exception` around that loop
|
||||
swallowed it, logged a warning, set `use_streaming = False`, and fell
|
||||
through to the non-streaming branch.
|
||||
|
||||
2. That non-streaming branch read `message.content` and nothing else. It sent
|
||||
`tools=` in the request, so the model answered with `tool_calls` and empty
|
||||
content -- and every call was discarded. The tool never ran, nothing was
|
||||
yielded, and no error surfaced.
|
||||
|
||||
Net effect: on a turn that needed a tool, the user got an empty answer. The
|
||||
second branch is not an edge case -- Anthropic, Gemini and Ollama all report
|
||||
False from `_supports_streaming_tools()`, so they take it on every tools turn.
|
||||
|
||||
The two tests that covered the streaming loop assigned `_create_tool_message`
|
||||
onto the instance themselves, so they passed against a method production did
|
||||
not have. These tests never patch it.
|
||||
"""
|
||||
import os
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("OPENAI_API_KEY", "sk-test-not-real")
|
||||
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
|
||||
def _tool_call(name="get_weather", args='{"city":"Paris"}', call_id="call_1"):
|
||||
return types.SimpleNamespace(
|
||||
index=0, id=call_id, type="function",
|
||||
function=types.SimpleNamespace(name=name, arguments=args))
|
||||
|
||||
|
||||
def get_weather(city: str) -> str:
|
||||
return f"sunny in {city}"
|
||||
|
||||
|
||||
class TestCreateToolMessageExists:
|
||||
"""Guards the premise: production must own this method, not the tests."""
|
||||
|
||||
def test_the_method_is_defined_on_the_class(self):
|
||||
assert hasattr(LLM, "_create_tool_message"), (
|
||||
"_create_tool_message is called by the streaming tool loop; if only "
|
||||
"tests define it, every streaming tool turn raises AttributeError"
|
||||
)
|
||||
|
||||
def test_it_builds_a_standard_tool_message(self):
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
msg = llm._create_tool_message("get_weather", "sunny", "call_1", False)
|
||||
assert msg["role"] == "tool"
|
||||
assert msg["tool_call_id"] == "call_1"
|
||||
assert "sunny" in msg["content"]
|
||||
|
||||
def test_an_error_result_is_reported_as_an_error(self):
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
msg = llm._create_tool_message("f", {"error": "boom"}, "call_1", False)
|
||||
assert "boom" in msg["content"]
|
||||
|
||||
def test_an_empty_result_is_named_rather_than_dropped(self):
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
msg = llm._create_tool_message("f", None, "call_1", False)
|
||||
assert msg["content"], "an empty tool result must still say something"
|
||||
|
||||
|
||||
class TestNonStreamingFallbackRunsTools:
|
||||
"""Anthropic, Gemini and Ollama take this branch on every tools turn."""
|
||||
|
||||
def _llm_with_two_turns(self, model="anthropic/claude-sonnet-4-20250514"):
|
||||
llm = LLM(model=model)
|
||||
state = {"turns": 0, "ran": 0}
|
||||
|
||||
def fake_completion(**kwargs):
|
||||
state["turns"] += 1
|
||||
if state["turns"] == 1:
|
||||
message = types.SimpleNamespace(content=None, tool_calls=[_tool_call()])
|
||||
else:
|
||||
message = types.SimpleNamespace(content="It is sunny in Paris.",
|
||||
tool_calls=None)
|
||||
return types.SimpleNamespace(
|
||||
choices=[types.SimpleNamespace(message=message, finish_reason="stop")])
|
||||
|
||||
def execute(name, args, *a, **kw):
|
||||
state["ran"] += 1
|
||||
return get_weather(**args)
|
||||
|
||||
llm._completion_with_retry = fake_completion
|
||||
return llm, state, execute
|
||||
|
||||
def test_the_provider_really_takes_this_branch(self):
|
||||
for model in ("anthropic/claude-sonnet-4-20250514",
|
||||
"gemini/gemini-2.0-flash", "ollama/llama3.2"):
|
||||
assert LLM(model=model)._supports_streaming_tools() is False, model
|
||||
|
||||
def test_the_tool_actually_runs(self):
|
||||
llm, state, execute = self._llm_with_two_turns()
|
||||
list(llm.get_response_stream(prompt="weather?", system_prompt="x",
|
||||
tools=[get_weather], execute_tool_fn=execute))
|
||||
assert state["ran"] == 1, "the tool call was discarded"
|
||||
|
||||
def test_the_answer_reaches_the_caller(self):
|
||||
llm, state, execute = self._llm_with_two_turns()
|
||||
out = list(llm.get_response_stream(prompt="weather?", system_prompt="x",
|
||||
tools=[get_weather], execute_tool_fn=execute))
|
||||
assert "".join(out).strip(), "a tool turn produced an empty stream"
|
||||
|
||||
def test_it_asks_the_model_again_with_the_result(self):
|
||||
llm, state, execute = self._llm_with_two_turns()
|
||||
list(llm.get_response_stream(prompt="weather?", system_prompt="x",
|
||||
tools=[get_weather], execute_tool_fn=execute))
|
||||
assert state["turns"] == 2, "the tool result was never sent back"
|
||||
|
||||
def test_a_turn_with_no_tool_calls_still_yields_its_prose(self):
|
||||
llm = LLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
message = types.SimpleNamespace(content="plain answer", tool_calls=None)
|
||||
llm._completion_with_retry = lambda **kw: types.SimpleNamespace(
|
||||
choices=[types.SimpleNamespace(message=message, finish_reason="stop")])
|
||||
out = list(llm.get_response_stream(prompt="hi", system_prompt="x",
|
||||
tools=[get_weather],
|
||||
execute_tool_fn=lambda *a, **k: None))
|
||||
assert "".join(out) == "plain answer"
|
||||
|
||||
def test_a_failing_tool_is_reported_not_raised(self):
|
||||
"""A broken tool must reach the model as an error, not abort the run."""
|
||||
llm, state, _ = self._llm_with_two_turns()
|
||||
|
||||
def boom(name, args, *a, **kw):
|
||||
raise RuntimeError("tool exploded")
|
||||
|
||||
out = list(llm.get_response_stream(prompt="weather?", system_prompt="x",
|
||||
tools=[get_weather], execute_tool_fn=boom))
|
||||
assert state["turns"] == 2, "a failing tool aborted the turn"
|
||||
assert "".join(out).strip()
|
||||
|
||||
def test_it_stops_at_max_tool_calls_per_turn(self):
|
||||
"""The fallback must honour the same per-turn guardrail as the other loops."""
|
||||
llm = LLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
state = {"ran": 0}
|
||||
|
||||
def always_calls(**kwargs):
|
||||
message = types.SimpleNamespace(content=None, tool_calls=[_tool_call()])
|
||||
return types.SimpleNamespace(
|
||||
choices=[types.SimpleNamespace(message=message, finish_reason="tool_calls")])
|
||||
|
||||
def execute(name, args, *a, **kw):
|
||||
state["ran"] += 1
|
||||
return get_weather(**args)
|
||||
|
||||
llm._completion_with_retry = always_calls
|
||||
list(llm.get_response_stream(prompt="weather?", system_prompt="x",
|
||||
tools=[get_weather], execute_tool_fn=execute,
|
||||
max_tool_calls_per_turn=3))
|
||||
assert state["ran"] == 3, (
|
||||
"the fallback ran tools past max_tool_calls_per_turn")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -14,7 +14,7 @@ from praisonaiagents.lsp.types import (
|
||||
)
|
||||
from praisonaiagents.lsp.config import (
|
||||
LSPConfig, DEFAULT_SERVERS, detect_language, detect_root_uri, probe,
|
||||
path_to_uri,
|
||||
path_to_uri, resolve_servers,
|
||||
)
|
||||
from praisonaiagents.lsp.client import LSPClient
|
||||
|
||||
@@ -449,5 +449,137 @@ class TestPathToUri:
|
||||
assert "%20" in uri and "%23" in uri
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Configurable server registry tests
|
||||
# =============================================================================
|
||||
|
||||
class TestResolveServers:
|
||||
"""Tests for resolve_servers() merge of user config over DEFAULT_SERVERS."""
|
||||
|
||||
def test_none_returns_defaults_unchanged(self):
|
||||
assert resolve_servers(None) is DEFAULT_SERVERS
|
||||
assert resolve_servers({}) is DEFAULT_SERVERS
|
||||
|
||||
def test_adds_new_language(self):
|
||||
user = {
|
||||
"java": {
|
||||
"command": "jdtls",
|
||||
"args": ["--stdio"],
|
||||
"extensions": [".java"],
|
||||
"root_markers": ["pom.xml", ".git"],
|
||||
"install_hint": "install eclipse.jdt.ls",
|
||||
}
|
||||
}
|
||||
merged = resolve_servers(user)
|
||||
assert "java" in merged
|
||||
assert merged["java"]["command"] == "jdtls"
|
||||
# Built-ins preserved and untouched.
|
||||
assert merged["python"]["command"] == "pylsp"
|
||||
assert "java" not in DEFAULT_SERVERS
|
||||
|
||||
def test_overrides_shipped_language(self):
|
||||
user = {"python": {"command": "pyright-langserver", "args": ["--stdio"]}}
|
||||
merged = resolve_servers(user)
|
||||
assert merged["python"]["command"] == "pyright-langserver"
|
||||
# Fields not overridden fall back to the built-in entry.
|
||||
assert merged["python"]["extensions"] == [".py", ".pyi"]
|
||||
# Original default is not mutated.
|
||||
assert DEFAULT_SERVERS["python"]["command"] == "pylsp"
|
||||
|
||||
def test_ignores_non_dict_entries(self):
|
||||
merged = resolve_servers({"bogus": "not-a-dict"})
|
||||
assert "bogus" not in merged
|
||||
|
||||
def test_command_only_override_drops_inherited_args(self):
|
||||
# Overriding only the command must not leak the replaced server's args
|
||||
# (e.g. typescript-language-server's `--stdio`) to a different binary.
|
||||
user = {"typescript": {"command": "my-ts-server"}}
|
||||
merged = resolve_servers(user)
|
||||
assert merged["typescript"]["command"] == "my-ts-server"
|
||||
assert merged["typescript"]["args"] == []
|
||||
# Non-command fields still fall back to the built-in entry.
|
||||
assert merged["typescript"]["extensions"] == [".ts", ".tsx"]
|
||||
# Original default is untouched.
|
||||
assert DEFAULT_SERVERS["typescript"]["args"] == ["--stdio"]
|
||||
|
||||
def test_command_override_with_explicit_args_kept(self):
|
||||
user = {"typescript": {"command": "my-ts-server", "args": ["--lsp"]}}
|
||||
merged = resolve_servers(user)
|
||||
assert merged["typescript"]["args"] == ["--lsp"]
|
||||
|
||||
def test_same_command_override_keeps_args(self):
|
||||
# Overriding other fields while keeping the same command retains args.
|
||||
user = {"typescript": {"root_markers": ["deno.json", ".git"]}}
|
||||
merged = resolve_servers(user)
|
||||
assert merged["typescript"]["args"] == ["--stdio"]
|
||||
|
||||
|
||||
class TestConfigurableRegistry:
|
||||
"""LSPConfig / detection / probe consult the merged registry."""
|
||||
|
||||
JAVA = {
|
||||
"java": {
|
||||
"command": "jdtls",
|
||||
"args": ["--stdio"],
|
||||
"extensions": [".java"],
|
||||
"root_markers": ["pom.xml", ".git"],
|
||||
"install_hint": "install eclipse.jdt.ls",
|
||||
"initialization_options": {"foo": "bar"},
|
||||
}
|
||||
}
|
||||
|
||||
def test_configured_language_resolves(self):
|
||||
config = LSPConfig(language="java", servers=self.JAVA)
|
||||
assert config.command == "jdtls"
|
||||
assert config.args == ["--stdio"]
|
||||
assert config.initialization_options == {"foo": "bar"}
|
||||
|
||||
def test_unconfigured_unknown_still_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
LSPConfig(language="cobol", servers=self.JAVA)
|
||||
|
||||
def test_override_replaces_default_command(self):
|
||||
user = {"python": {"command": "pyright-langserver", "args": ["--stdio"]}}
|
||||
config = LSPConfig(language="python", servers=user)
|
||||
assert config.command == "pyright-langserver"
|
||||
assert config.args == ["--stdio"]
|
||||
|
||||
def test_detect_language_honours_custom_extension(self):
|
||||
assert detect_language("App.java", self.JAVA) == "java"
|
||||
# Without config the same extension is unknown.
|
||||
assert detect_language("App.java") is None
|
||||
|
||||
def test_probe_uses_configured_server(self, monkeypatch):
|
||||
import praisonaiagents.lsp.config as cfg
|
||||
monkeypatch.setattr(cfg.shutil, "which", lambda cmd: "/usr/bin/" + cmd)
|
||||
available, command, hint = probe("java", self.JAVA)
|
||||
assert available is True
|
||||
assert command == "jdtls"
|
||||
|
||||
def test_detect_root_uri_uses_custom_markers(self, tmp_path):
|
||||
(tmp_path / "pom.xml").write_text("<project/>\n")
|
||||
sub = tmp_path / "src" / "main"
|
||||
sub.mkdir(parents=True)
|
||||
f = sub / "App.java"
|
||||
f.write_text("class App {}\n")
|
||||
root = detect_root_uri(str(f), servers=self.JAVA)
|
||||
assert root == f"file://{tmp_path}"
|
||||
|
||||
def test_client_threads_servers_into_config(self):
|
||||
client = LSPClient(language="java", servers=self.JAVA)
|
||||
assert client.config.command == "jdtls"
|
||||
|
||||
def test_client_configured_language_start_preflight(self, monkeypatch):
|
||||
import asyncio
|
||||
import praisonaiagents.lsp.client as client_mod
|
||||
monkeypatch.setattr(client_mod.shutil, "which", lambda cmd: None)
|
||||
client = LSPClient(language="java", servers=self.JAVA)
|
||||
ok = asyncio.run(client.start())
|
||||
assert ok is False
|
||||
assert client.last_error is not None
|
||||
assert "jdtls" in client.last_error
|
||||
assert "eclipse.jdt.ls" in client.last_error
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,87 @@
|
||||
"""MemoryConfig.backend must survive being combined with config=.
|
||||
|
||||
MemoryConfig validates `backend` in __post_init__ and echoes it from
|
||||
to_dict(). But Agent's construction path had:
|
||||
|
||||
elif _memory_config.config:
|
||||
memory = _memory_config.config
|
||||
|
||||
handing _init_memory the bare config dict. _init_memory reads the provider as
|
||||
`memory.get("provider", memory.get("backend", "file"))`, so the backend was
|
||||
never seen and the agent quietly built a FileMemory:
|
||||
|
||||
backend='sqlite', no config -> Memory provider=sqlite
|
||||
backend='sqlite' + config={...} -> FileMemory provider=None <- dropped
|
||||
|
||||
...while to_dict() went on reporting 'sqlite'. Adding a config dict silently
|
||||
downgraded the backend, which is the opposite of what supplying more
|
||||
configuration should do.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("OPENAI_API_KEY", "sk-test-not-real")
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import MemoryConfig
|
||||
|
||||
|
||||
def _memory_of(config):
|
||||
agent = Agent(name="t", instructions="x", llm="gpt-4o-mini", memory=config)
|
||||
return getattr(agent, "_memory_instance", None) or getattr(agent, "memory", None)
|
||||
|
||||
|
||||
def _provider(instance):
|
||||
return getattr(instance, "provider", None) or getattr(instance, "_provider", None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_dir():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
yield d
|
||||
|
||||
|
||||
class TestBackendSurvivesAConfigDict:
|
||||
|
||||
def test_backend_is_honoured_alongside_config(self, db_dir):
|
||||
inst = _memory_of(MemoryConfig(
|
||||
backend="sqlite", user_id="u1", config={"short_db": db_dir + "/s.db"}))
|
||||
assert _provider(inst) == "sqlite", (
|
||||
"supplying config= silently discarded the backend"
|
||||
)
|
||||
|
||||
def test_it_matches_the_no_config_case(self, db_dir):
|
||||
"""Adding configuration must not change which backend is chosen."""
|
||||
without = _memory_of(MemoryConfig(backend="sqlite", user_id="u1"))
|
||||
with_cfg = _memory_of(MemoryConfig(
|
||||
backend="sqlite", user_id="u1", config={"short_db": db_dir + "/s.db"}))
|
||||
assert type(with_cfg).__name__ == type(without).__name__
|
||||
assert _provider(with_cfg) == _provider(without)
|
||||
|
||||
def test_an_explicit_provider_in_config_still_wins(self, db_dir):
|
||||
"""The dict is the more specific statement of intent."""
|
||||
inst = _memory_of(MemoryConfig(
|
||||
backend="sqlite", user_id="u1", config={"provider": "file"}))
|
||||
assert type(inst).__name__ == "FileMemory"
|
||||
|
||||
def test_an_explicit_backend_key_in_config_still_wins(self, db_dir):
|
||||
inst = _memory_of(MemoryConfig(
|
||||
backend="sqlite", user_id="u1", config={"backend": "file"}))
|
||||
assert type(inst).__name__ == "FileMemory"
|
||||
|
||||
def test_file_backend_is_unchanged(self, db_dir):
|
||||
"""The default path must not be disturbed."""
|
||||
inst = _memory_of(MemoryConfig(backend="file", user_id="u1"))
|
||||
assert type(inst).__name__ == "FileMemory"
|
||||
|
||||
def test_the_callers_config_dict_is_not_mutated(self, db_dir):
|
||||
"""Injecting the provider must not write into the user's own dict."""
|
||||
cfg = {"short_db": db_dir + "/s.db"}
|
||||
_memory_of(MemoryConfig(backend="sqlite", user_id="u1", config=cfg))
|
||||
assert "provider" not in cfg, "the caller's dict was mutated in place"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Loaded 100 of 683 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user