Compare commits

..
Author SHA1 Message Date
MervinPraison c6d585d86e fix(mobile): the user's settings were stored, displayed, and ignored
Two defects found by an adversarial audit. Together they formed a closed
loop where every step reported success and the user could not escape it.

SETTINGS WERE NEVER USED. main.ts built the engine list from a
`facadeStub()` whose get() returned undefined, so every setting fell
through to a hardcoded default -- while the settings screen read the
real store and displayed the user's saved value back to them. The stub's
own comment said "Replaced by the real one immediately after". It was
not: the engine is constructed once, inside createApp, from that stub.
engineId was a string literal at the same call site, so switching
engines in settings changed nothing either.

Measured before the fix: a saved baseUrl of https://my-engine.example
read back correctly from settings, and the request went to
http://127.0.0.1:8765. An API key typed into the keychain was stored and
never sent.

Fixed structurally rather than by passing the right argument:
`AppDeps.engines` now takes the settings facade as a parameter, so there
is no way to build the engine list without the real one -- the same
shape as the persistence fix, and for the same reason. `engineId` comes
from settings, and `fallbackEngineId` is named for what it is: the value
used when settings name none, on a first launch.

facadeStub is deleted rather than left as a trap.

A FAILURE BEFORE `start` WAS REPORTED AS "no output". The remote engine
synthesises error{kind:"auth"} for a 401 precisely so the UI can offer
the settings link. The reducer dropped it as `before_start`, leaving no
outcome and no text, so finish() substituted error{kind:"empty"} -- "the
engine produced no output" -- which recoveryFor maps to Retry.

So the loop: key never sent -> 401 -> "the engine produced no output"
-> a Retry button that fails identically forever, and never the one link
that could fix it.

A terminal event before `start` now settles the turn. Non-terminal
events are still dropped, because a stray delta from a previous run must
not open a turn that never started.

837 tests, boundaries clean.
2026-08-28 13:47:03 +01:00
946 changed files with 8692 additions and 165766 deletions

No files matched your search

@@ -288,7 +288,7 @@ runs:
fi
# Add allowed tools (include GitHub tools by default)
TOOLS="mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__add_issue_comment,mcp__github__get_pull_request,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,Read,Write,Edit,Bash"
TOOLS="mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__create_comment,Read,Write,Edit,Bash"
if [[ -n "$INPUT_ALLOWED_TOOLS" ]]; then
TOOLS="$TOOLS,$INPUT_ALLOWED_TOOLS"
fi
@@ -328,7 +328,6 @@ runs:
# Execute Claude Code with timeout, using stdin for the prompt
TIMEOUT_SECONDS=$(($INPUT_TIMEOUT_MINUTES * 60))
timeout $TIMEOUT_SECONDS claude "${CMD_ARGS[@]}" < "$PROMPT_FILE" \
| tee "$GITHUB_WORKSPACE/.claude-assess-output.jsonl"
timeout $TIMEOUT_SECONDS claude "${CMD_ARGS[@]}" < "$PROMPT_FILE"
echo "✅ Claude Code execution completed"
+2 -55
View File
@@ -213,33 +213,6 @@ assert(
mg.findMergeGateVerdict(recentVerdictOnHead, null, '2026-06-27T09:55:00Z') === 'APPROVE'
);
assert(
'scan kickoff text is not a verdict',
mg.findMergeGateVerdict([
{
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
created_at: '2026-06-27T10:05:00Z',
},
], null, '2026-06-27T09:55:00Z') === null
);
const finalWithFinished = [
{ user: { login: 'MervinPraison' }, body: '@claude You are the FINAL architecture reviewer.', created_at: '2026-06-27T10:00:00Z' },
{ user: { login: 'praisonai-triage-agent[bot]' }, body: 'Claude finished', created_at: '2026-06-27T10:05:00Z' },
];
assert('final complete requires Claude finished reply', mg.finalClaudeCompletedOnSha(finalWithFinished, '2026-06-27T09:55:00Z'));
assert('final trigger alone is incomplete', !mg.finalClaudeCompletedOnSha(
[{ user: { login: 'MervinPraison' }, body: '@claude You are the FINAL architecture reviewer.', created_at: '2026-06-27T10:00:00Z' }],
'2026-06-27T09:55:00Z'
));
assert('recent scan comment detected', mg.hasRecentMergeGateScanComment([
{ body: '**Merge gate scan** — eligible', created_at: new Date().toISOString() },
]));
assert('old scan comment not recent', !mg.hasRecentMergeGateScanComment([
{ body: '**Merge gate scan** — eligible', created_at: '2020-01-01T00:00:00Z' },
]));
// Sensitive + secrets
assert('workflow path sensitive', mg.sensitivePathReasons([{ filename: '.github/workflows/foo.yml' }]).length === 1);
assert('ci-only label exempts workflows', mg.sensitivePathReasons(
@@ -284,13 +257,8 @@ const finalAfterRebase = {
body: '@claude You are the FINAL architecture reviewer.',
created_at: conflictIso(-20 * 60 * 1000),
};
const claudeFinishedAfterRebase = {
user: { login: 'praisonai-triage-agent[bot]' },
body: 'Claude finished',
created_at: conflictIso(-19 * 60 * 1000),
};
const headAfterRebase = conflictIso(-21 * 60 * 1000);
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase, claudeFinishedAfterRebase];
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase];
assert('conflict blocks before rebase done', mg.hasRecentConflictComment([conflictTrigger], headAfterRebase));
assert('conflict clears after rebase + FINAL on HEAD', !mg.hasRecentConflictComment(rebaseComments, headAfterRebase));
assert('conflict still blocks without FINAL on HEAD', mg.hasRecentConflictComment(
@@ -357,26 +325,5 @@ assert('substantive dispatches still counted', mg.countSubstantiveMergeGateRuns(
{ event: 'schedule' },
]) === 2);
assert('countSubstantiveMergeGateRuns null-safe', mg.countSubstantiveMergeGateRuns(null) === 0);
assert('rate limit threshold is 200', mg.MIN_CORE_RATE_LIMIT_REMAINING === 200);
(async () => {
const skipLow = await mg.shouldSkipMergeGateDispatch(
{
rest: {
rateLimit: {
get: async () => ({ data: { resources: { core: { remaining: 50 } } } }),
},
},
},
'owner',
'repo',
1,
{ info: () => {} }
);
assert('rate limit low skips dispatch', skipLow === true);
process.exit(failed ? 1 : 0);
})().catch((err) => {
console.error(err);
process.exit(1);
});
process.exit(failed ? 1 : 0);
+4 -50
View File
@@ -315,16 +315,7 @@ function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin =
function finalClaudeCompletedOnSha(comments, headPushedAt) {
if (!hasFinalClaudeReviewTrigger(comments)) return false;
if (isStaleFinalAfterPush(comments, headPushedAt)) return false;
const finals = comments.filter(isFinalClaudeTriggerComment);
if (finals.length === 0) return false;
const latestFinal = finals.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const finalTime = new Date(latestFinal.created_at).getTime();
return comments.some((c) => {
if (!isClaudeFinalReplyComment(c)) return false;
return new Date(c.created_at).getTime() >= finalTime - 60000;
});
return true;
}
const FINAL_CLAUDE_REVIEW_BODY =
@@ -487,7 +478,6 @@ function hasBlockingClaudeRunForPr(runs, headRef) {
const MERGE_GATE_WORKFLOW_FILE = 'claude-merge-gate.yml';
const MERGE_GATE_ACTIVE_LABEL = 'claude-merge-gate-active';
const MAX_PENDING_MERGE_GATE_RUNS = 3;
const MIN_CORE_RATE_LIMIT_REMAINING = 200;
function mergeGateDispatchBlockedReason({ labels, pendingRuns, maxPending = MAX_PENDING_MERGE_GATE_RUNS }) {
if ((labels || []).includes(MERGE_GATE_ACTIVE_LABEL)) {
@@ -524,25 +514,8 @@ async function countPendingMergeGateRuns(github, owner, repo) {
return pending;
}
async function getCoreRateLimitRemaining(github) {
try {
const { data } = await github.rest.rateLimit.get();
return data?.resources?.core?.remaining ?? null;
} catch {
return null;
}
}
async function shouldSkipMergeGateDispatch(github, owner, repo, prNumber, core, options = {}) {
const maxPending = options.maxPending ?? MAX_PENDING_MERGE_GATE_RUNS;
const minCoreRemaining = options.minCoreRemaining ?? MIN_CORE_RATE_LIMIT_REMAINING;
const remaining = await getCoreRateLimitRemaining(github);
if (remaining !== null && remaining < minCoreRemaining) {
core?.info?.(
`Skip merge gate dispatch for PR #${prNumber}: rate limit low (${remaining} core remaining)`
);
return true;
}
const ctx = await loadPrContext(github, owner, repo, prNumber);
const pendingRuns = await countPendingMergeGateRuns(github, owner, repo);
const reason = mergeGateDispatchBlockedReason({
@@ -1029,33 +1002,18 @@ async function selectMergeGateCandidates(github, owner, repo, prNumbers, maxCand
}
const AUTOMATED_FALLBACK_MARKER = 'Automated fallback —';
const MERGE_GATE_SCAN_MARKER = 'Merge gate scan';
function isAutomatedFallbackVerdict(body) {
return (body || '').includes(AUTOMATED_FALLBACK_MARKER);
}
function isMergeGateVerdictComment(body) {
const text = body || '';
if (text.includes(MERGE_GATE_SCAN_MARKER)) return false;
return /(?:^|\n)\s*MERGE_GATE_VERDICT:\s*(APPROVE|BLOCK)\b/m.test(text);
}
function hasRecentMergeGateScanComment(comments, withinMinutes = 30) {
const cutoff = Date.now() - withinMinutes * 60 * 1000;
return (comments || []).some((c) => {
if (!(c.body || '').includes(MERGE_GATE_SCAN_MARKER)) return false;
return new Date(c.created_at).getTime() > cutoff;
});
}
function findMergeGateVerdict(comments, minCreatedAt = null, headPushedAt = null, options = {}) {
const { excludeAutomatedFallback = false } = options || {};
const minTime = minCreatedAt ? new Date(minCreatedAt).getTime() : 0;
const headTime = headPushedAt ? new Date(headPushedAt).getTime() - 60000 : 0;
const gateComments = comments
.filter((c) => {
if (!isMergeGateVerdictComment(c.body)) return false;
if (!(c.body || '').includes('MERGE_GATE_VERDICT:')) return false;
if (excludeAutomatedFallback && isAutomatedFallbackVerdict(c.body)) return false;
const created = new Date(c.created_at).getTime();
if (minTime && created < minTime) return false;
@@ -1065,8 +1023,8 @@ function findMergeGateVerdict(comments, minCreatedAt = null, headPushedAt = null
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
if (gateComments.length === 0) return null;
const body = gateComments[0].body || '';
if (/MERGE_GATE_VERDICT:\s*APPROVE\b/.test(body)) return 'APPROVE';
if (/MERGE_GATE_VERDICT:\s*BLOCK\b/.test(body)) return 'BLOCK';
if (body.includes('MERGE_GATE_VERDICT: APPROVE')) return 'APPROVE';
if (body.includes('MERGE_GATE_VERDICT: BLOCK')) return 'BLOCK';
return null;
}
@@ -1137,14 +1095,10 @@ module.exports = {
selectMergeGateCandidates,
isAutomatedFallbackVerdict,
AUTOMATED_FALLBACK_MARKER,
isMergeGateVerdictComment,
hasRecentMergeGateScanComment,
findMergeGateVerdict,
MERGE_GATE_WORKFLOW_FILE,
MERGE_GATE_ACTIVE_LABEL,
MAX_PENDING_MERGE_GATE_RUNS,
MIN_CORE_RATE_LIMIT_REMAINING,
getCoreRateLimitRemaining,
mergeGateDispatchBlockedReason,
countSubstantiveMergeGateRuns,
countPendingMergeGateRuns,
+28 -118
View File
@@ -201,26 +201,17 @@ jobs:
'**Actions:** wait for CI and the Claude review chain, or add label `needs-manual-review` and merge manually.',
'**Opt out:** label `no-auto-merge`.',
].join('\n');
const ctx = await mergeGate.loadPrContext(github, owner, repo, skipped[0].pr);
if (!mergeGate.hasRecentMergeGateScanComment(ctx.comments)) {
await github.rest.issues.createComment({
owner, repo, issue_number: skipped[0].pr, body,
});
}
await github.rest.issues.createComment({
owner, repo, issue_number: skipped[0].pr, body,
});
} else if (inputPr && candidates.length === 1) {
const prNum = candidates[0].pr_number;
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNum);
if (!mergeGate.hasRecentMergeGateScanComment(ctx.comments)) {
await github.rest.issues.createComment({
owner, repo, issue_number: prNum,
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge on an APPROVE verdict.',
});
}
await github.rest.issues.createComment({
owner, repo, issue_number: candidates[0].pr_number,
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
});
}
await ps.syncOpenPullRequests(
github, owner, repo, { maxPrs: 10, dispatchMergeGate: false }, core
);
await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 10 }, core);
claude-assess:
needs: scan-candidates
@@ -276,7 +267,6 @@ jobs:
- name: Claude merge gate assessment (read-only)
id: assess
continue-on-error: true
uses: ./.github/actions/claude-code-action
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
@@ -318,97 +308,15 @@ jobs:
Write
Replace
allowed_tools: |
mcp__github__add_issue_comment
mcp__github__get_pull_request
mcp__github__get_pull_request_files
mcp__github__get_pull_request_reviews
mcp__github__get_pull_request_status
mcp__github__get_issue
mcp__github__get_issue_comments
Glob
Grep
View
GlobTool
GrepTool
Read
timeout_minutes: 15
- name: Post Opus verdict from assess output if MCP missed
id: relay_verdict
if: always() && steps.assess.conclusion != 'skipped'
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const fs = require('fs');
const mergeGate = require('/tmp/merge-gate.js');
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = ${{ matrix.pr_number }};
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
const existing = mergeGate.findMergeGateVerdict(
ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
);
if (existing) {
core.info(`Opus verdict already on PR: ${existing}`);
return;
}
const outPath = `${process.env.GITHUB_WORKSPACE}/.claude-assess-output.jsonl`;
if (!fs.existsSync(outPath)) {
core.info('No assess output file to relay');
return;
}
const lines = fs.readFileSync(outPath, 'utf8').split('\n').filter(Boolean);
let verdict = null;
let rationale = '';
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (line.includes('MERGE_GATE_VERDICT: APPROVE')) {
verdict = 'APPROVE';
rationale = line;
break;
}
if (line.includes('MERGE_GATE_VERDICT: BLOCK')) {
verdict = 'BLOCK';
rationale = line;
break;
}
try {
const evt = JSON.parse(line);
const text = JSON.stringify(evt);
if (text.includes('MERGE_GATE_VERDICT: APPROVE')) {
verdict = 'APPROVE';
rationale = text;
break;
}
if (text.includes('MERGE_GATE_VERDICT: BLOCK')) {
verdict = 'BLOCK';
rationale = text;
break;
}
} catch {
// Not JSON — keep scanning.
}
}
if (!verdict) {
core.info('Assess output had no MERGE_GATE_VERDICT to relay');
return;
}
const body = [
`MERGE_GATE_VERDICT: ${verdict}`,
'',
'Relayed from Opus merge gate assess output (GitHub MCP comment post was unavailable).',
].join('\n');
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber, body,
});
core.info(`Relayed Opus verdict to PR comment: ${verdict}`);
- name: Post fallback verdict if Opus did not comment
- name: Post fallback BLOCK if Opus did not comment
id: fallback_verdict
if: always() && steps.assess.conclusion != 'skipped'
if: steps.assess.outcome != 'success'
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
@@ -424,21 +332,20 @@ jobs:
ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
);
if (existing) {
core.info(`Verdict already posted: ${existing}`);
core.info(`Opus verdict already posted: ${existing}`);
return;
}
const check = await mergeGate.evaluatePipelineQuiescent(
github, owner, repo, prNumber, core, { forMergeStep: true }
);
const verdict = check.ready ? 'APPROVE' : 'BLOCK';
const lines = [
`MERGE_GATE_VERDICT: ${verdict}`,
'MERGE_GATE_VERDICT: BLOCK',
'',
'Automated fallback — Opus merge gate did not post a PR comment.',
'Automated fallback — Opus merge gate assessment did not complete.',
];
if (check.ready) {
lines.push('All deterministic merge gates passed.');
lines.push('Deterministic gates passed, but Opus MERGE_GATE_VERDICT is required before merge.');
} else {
lines.push('Blockers:', ...check.reasons.map((r) => `- ${r}`));
}
@@ -446,10 +353,9 @@ jobs:
owner, repo, issue_number: prNumber,
body: lines.join('\n'),
});
core.info(`Posted fallback verdict: ${verdict}`);
core.info('Posted fallback BLOCK — Opus verdict required');
- name: Capture Opus MERGE_GATE_VERDICT
id: capture_verdict
- name: Require Opus MERGE_GATE_VERDICT
uses: actions/github-script@v7
with:
github-token: ${{ steps.app-token.outputs.token }}
@@ -465,14 +371,14 @@ jobs:
const verdict = mergeGate.findMergeGateVerdict(
comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
);
core.setOutput('verdict', verdict || 'NONE');
if (!verdict) {
core.warning(
'Opus MERGE_GATE_VERDICT not posted on PR (GitHub MCP tools may be blocked). Merge waits for APPROVE.'
core.setFailed(
'Opus merge gate assessment must post MERGE_GATE_VERDICT on this PR (automated fallback APPROVE is not accepted).'
);
} else {
core.info(`Opus merge gate verdict: ${verdict}`);
return;
}
core.info(`Opus merge gate verdict: ${verdict}`);
core.setOutput('verdict', verdict);
- name: Remove merge gate active label
if: always()
@@ -493,6 +399,10 @@ jobs:
core.info(`Label claude-merge-gate-active already removed or missing on PR #${prNumber}`);
}
- name: Fail job if assessment failed
if: steps.assess.outcome == 'failure'
run: exit 1
merge-only:
needs: [scan-candidates, claude-assess]
if: |
+4 -23
View File
@@ -140,32 +140,13 @@ jobs:
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }}
run: |
$expected = $env:RELEASE_TAG -replace '^v', ''
$core = ($expected -split '[-+]')[0]
$exe = "target/${{ matrix.target }}/release/praisonai-desktop.exe"
$version = (Get-Item $exe).VersionInfo
Write-Host "ProductVersion=$($version.ProductVersion) FileVersion=$($version.FileVersion)"
$numeric = ($expected -split '[-+]')[0] + '.0'
$version = (Get-Item "target/${{ matrix.target }}/release/praisonai-desktop.exe").VersionInfo
if ($version.ProductVersion -ne $expected) {
throw "ProductVersion $($version.ProductVersion) does not match $expected"
}
# Compare the three components that carry meaning, not the string.
#
# This gate blocked the v4.7.4 Windows build with
# FileVersion 4.7.4 does not match 4.7.4.0
# It appended ".0" and compared literally, but Windows reports what
# was stamped -- three parts here. The stamping was correct and the
# check was not, so the release shipped with no Windows installer at
# all: the one platform it was cut to fix.
#
# [version] normalises a missing fourth component to -1, so both
# "4.7.4" and "4.7.4.0" compare equal on major.minor.build.
$got = [version]$version.FileVersion
$want = [version]$core
if ($got.Major -ne $want.Major -or
$got.Minor -ne $want.Minor -or
$got.Build -ne $want.Build) {
throw "FileVersion $($version.FileVersion) does not match $core"
if ($version.FileVersion -ne $numeric) {
throw "FileVersion $($version.FileVersion) does not match $numeric"
}
- name: The macOS bundle must carry a valid signature
-351
View File
@@ -1,351 +0,0 @@
# The mobile package's PLATFORM builds: a real iOS app and a real Android app.
#
# mobile.yml proves the crate type-checks for a phone (`cargo check` against
# the two mobile targets). This proves the two generated platform projects,
# src-tauri/gen/apple and src-tauri/gen/android, still turn that crate into an
# installable app -- which is a different thing, and the thing that had never
# happened: until #4673 neither project existed.
#
# What each job produces, and why that shape:
#
# ios-simulator -- an UNSIGNED arm64 simulator .app. A device build needs
# an Apple Developer Team and a signing identity, which are
# the maintainer's and not in this repo (bundle.iOS.
# developmentTeam is unset in tauri.conf.json). A simulator
# build needs neither, so it is the build that can run on
# every PR. It is not decoration: it links the Rust
# staticlib into the Xcode target, packages the webview
# into the bundle and exercises Info.plist, i.e. every step
# short of the certificate.
# android-apk -- a debug APK, arm64-v8a. Debug because a release APK
# needs a signing keystore, which is likewise the
# maintainer's; the debug keystore is generated on the
# runner. It installs on any arm64 Android 8.0+ device with
# `adb install`.
#
# Both jobs fail if the thing they exist to produce is missing. The bundler
# exits zero on a target it silently skipped (desktop-release.yml learned this
# the hard way), so "the step passed" is not evidence; "the file exists and
# contains the Rust library" is.
#
# The Xcode project is generated on the macOS runner the first time (this
# laptop has no Xcode, only the Command Line Tools) and then committed, so
# the init step is a no-op once gen/apple is in the tree. The Android project
# was generated locally and is committed; the same guard keeps that job
# honest if someone deletes it.
name: mobile platforms
on:
workflow_dispatch:
pull_request:
paths: &watched
- 'src/praisonai-mobile/**'
- 'src/praisonai-ts/src/**'
- '.github/workflows/mobile-platforms.yml'
# Without this, no platform build had ever run on main -- so a break
# introduced by two PRs that were each green apart could sit on main
# unnoticed. That is exactly what happened: #4737 branched from before
# #4727 added the `file:` dependency, so the two were never installed
# together until they had both landed.
push:
branches: [main]
paths: *watched
concurrency:
# Per-ref on a PR, per-SHA on main. This is the crux of the fix, not
# cancel-in-progress alone: a concurrency group holds at most ONE
# running run and ONE pending run, and a newer arrival always replaces
# the pending one -- cancel-in-progress: false spares only the RUNNING
# run, never the queued one. So with a single mobile-platforms-main
# group, three merges in one ~3-minute window would still drop the
# middle commit's run (it is only ever pending), which is precisely the
# blindness the push trigger was added to remove. Giving each main SHA
# its own group makes every commit a candidate release with its own
# answer, with nothing to contend for and so nothing to cancel.
group: mobile-platforms-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }}
# On a PR, superseding an in-flight build is right: only the tip
# matters and these jobs cost ~3 minutes on two runners. On main each
# group has a single run, so this is a no-op there -- left true because
# the condition that mattered is now expressed in the group name above,
# and this keeps the PR behaviour explicit.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
defaults:
run:
working-directory: src/praisonai-mobile
jobs:
ios-simulator:
name: ios simulator (.app, unsigned)
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22.18'
cache: npm
cache-dependency-path: src/praisonai-mobile/package-lock.json
- uses: dtolnay/rust-toolchain@stable
with:
# Both: `tauri ios init` installs whatever it thinks is missing via
# rustup, and having them already present keeps that step quiet and
# deterministic. aarch64-apple-ios-sim is the one the build below
# actually links against.
targets: aarch64-apple-ios,aarch64-apple-ios-sim
- uses: Swatinem/rust-cache@v2
with:
workspaces: src/praisonai-mobile/src-tauri
key: ios-sim
- name: "Build praisonai-ts (the file: dependency)"
# praisonai-mobile depends on praisonai by `file:`, so `npm ci`
# here runs praisonai-ts's own `prepare` -> `rimraf dist`. On a
# fresh runner praisonai-ts has no node_modules, so rimraf is not
# installed and `npm ci` dies with code 127 before a single
# platform step runs. mobile.yml gives all four of its jobs this
# step; these two were written against the tree from before the
# dependency existed and never got it.
working-directory: src/praisonai-ts
run: |
npm install --no-audit --no-fund --legacy-peer-deps
npm run build
- run: npm ci
- name: Generate the Xcode project (only if gen/apple is not committed)
# `tauri ios init` renders the template and then runs `xcodegen`, a
# brew package the CLI would otherwise try to install itself, mid-run,
# with a prompt. Installing it up front is the same outcome without
# the prompt. cocoapods is only invoked when the config declares pods;
# it declares none.
run: |
set -euo pipefail
if [ -d src-tauri/gen/apple ]; then
echo "src-tauri/gen/apple is committed; not regenerating it"
exit 0
fi
brew list xcodegen >/dev/null 2>&1 || brew install xcodegen
npx tauri ios init --ci --skip-targets-install
- name: Put the real AppIcon set into the Xcode project
# `tauri ios init` renders the CLI's OWN placeholder AppIcon PNGs into
# gen/apple/Assets.xcassets, and nothing in the CLI ever replaces them:
# `tauri icon` writes to src-tauri/icons/ios/ and stops there (it wires
# up gen/android, but there is no iOS equivalent). So the copy is done
# here, and every filename matches one-for-one -- both sides are named
# by the same Contents.json.
#
# Idempotent, and it runs whether or not init just ran, so a
# regenerated project cannot silently ship Tauri's icon as ours.
run: |
set -euo pipefail
dest=src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset
test -d "$dest" || { echo "::error::$dest does not exist"; find src-tauri/gen/apple -maxdepth 3 -print; exit 1; }
cp src-tauri/icons/ios/*.png "$dest/"
ls "$dest" | wc -l
- name: Build the simulator app, unsigned
# --target aarch64-sim: the iphonesimulator SDK, arm64 (the runner is
# Apple silicon, and so is every Mac that will run the simulator).
# --no-sign: xcodebuild with CODE_SIGNING_ALLOWED=NO. For the
# simulator SDK the CLI then lifts PraisonAI.app straight out of the
# xcarchive instead of exporting an IPA (crates/tauri-cli/src/mobile/
# ios/build.rs, the `target.sdk == "iphonesimulator"` branch), which
# is exactly the artifact a simulator installs.
# --debug: the dev profile links in seconds rather than minutes, and a
# simulator build is never the one that ships.
run: npx tauri ios build --debug --target aarch64-sim --no-sign --ci
- name: Is there an app, and is the Rust inside it?
id: app
run: |
set -euo pipefail
root=src-tauri/gen/apple/build
# Printed before anything is asserted, so a failure here is
# diagnosable from the log alone rather than costing another run.
echo "--- what the build left behind"
find "$root" -maxdepth 3 -print 2>/dev/null | head -40 || echo "(no build directory)"
# Prefer the simulator export dir (build/arm64-sim, the `arch` of the
# aarch64-sim target); fall back to any .app so the error message can
# say what WAS built. The name is not hardcoded: it comes from
# productName via the CLI, and guessing it wrong is what failed here
# the first time.
app="$(find "$root" -type d -name '*.app' -path '*arm64-sim*' -print -quit 2>/dev/null || true)"
[ -n "$app" ] || app="$(find "$root" -type d -name '*.app' -print -quit 2>/dev/null || true)"
if [ -z "$app" ]; then
echo "::error::no .app was produced under $root"
exit 1
fi
echo "app: $app"
# The executable's name comes from the bundle itself for the same
# reason.
exe="$app/$(plutil -extract CFBundleExecutable raw "$app/Info.plist")"
test -f "$exe" || { echo "::error::$app has no executable"; ls -la "$app"; exit 1; }
file "$exe"
file "$exe" | grep -q 'arm64' || { echo "::error::the executable is not arm64"; exit 1; }
# iOS links the Rust in as a staticlib, so unlike Android there is no
# .so to look for -- the crate is INSIDE this binary, and its symbols
# are the only real evidence it got there. An .app that built without
# it would be the Swift template alone.
#
# Dumped to files rather than piped into `grep -q`: grep exits on the
# FIRST match, the dumper then dies of SIGPIPE, and `set -o pipefail`
# reports that as a failed pipeline -- so the check failed precisely
# BECAUSE it found what it was looking for. It cost a CI run.
nm -a "$exe" > "$RUNNER_TEMP/symbols.txt" 2>/dev/null || true
strings -a "$exe" > "$RUNNER_TEMP/strings.txt" 2>/dev/null || true
if grep -qi 'praisonai_mobile\|start_app' "$RUNNER_TEMP/symbols.txt" \
|| grep -qi 'praisonai_mobile' "$RUNNER_TEMP/strings.txt"; then
echo "the Rust crate is linked into the executable"
else
echo "::error::the Rust library is not linked into $exe"
exit 1
fi
# The frontend is embedded in that same binary by generate_context!,
# not copied into the bundle as a resource, so it is checked the same
# way rather than by looking for an index.html that is never there.
plutil -p "$app/Info.plist" | grep -E 'CFBundleIdentifier|CFBundleShortVersionString|MinimumOSVersion|UIDeviceFamily'
test -f "$app/Assets.car" || echo "note: no compiled asset catalog in the bundle"
du -sh "$app"
# ditto, not zip: it keeps the bundle's symlinks and modes, which is
# the difference between an .app the simulator installs and one it
# rejects as an invalid bundle.
ditto -c -k --keepParent "$app" "$RUNNER_TEMP/PraisonAI-ios-simulator.app.zip"
ls -la "$RUNNER_TEMP/PraisonAI-ios-simulator.app.zip"
- name: Upload the simulator app
uses: actions/upload-artifact@v4
with:
name: PraisonAI-ios-simulator-app
path: ${{ runner.temp }}/PraisonAI-ios-simulator.app.zip
if-no-files-found: error
retention-days: 30
- name: Package gen/apple for committing
# always(): the project is generated by `init`, so it is worth having
# even on a run whose BUILD failed -- that is exactly the run where
# someone needs to look at it.
if: always()
# Only what `git add src-tauri/gen/apple` would take: the template's
# own .gitignore excludes build/, xcuserdata/ and Externals/, and this
# respects it instead of re-listing them here. Nothing to upload when
# the project was already committed -- the guard above skipped init.
run: |
set -euo pipefail
files="$(git ls-files --others --exclude-standard src-tauri/gen/apple || true)"
if [ -z "$files" ]; then
echo "gen/apple is tracked already; nothing to package"
exit 0
fi
echo "$files" | tar -czf "$RUNNER_TEMP/gen-apple.tgz" -T -
echo "$files" | wc -l
ls -la "$RUNNER_TEMP/gen-apple.tgz"
- name: Upload gen/apple (only on the run that generated it)
if: always()
uses: actions/upload-artifact@v4
with:
name: gen-apple
path: ${{ runner.temp }}/gen-apple.tgz
if-no-files-found: ignore
retention-days: 30
android-apk:
name: android apk (debug, arm64)
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22.18'
cache: npm
cache-dependency-path: src/praisonai-mobile/package-lock.json
- uses: actions/setup-java@v4
with:
# 17, not the runner's default 21 or a laptop's 25: Gradle 8.14.3's
# Groovy front end rejects class files newer than it knows
# ("Unsupported class file major version 69" on JDK 25), and 17 is
# what AGP 8.11 documents.
distribution: temurin
java-version: '17'
- uses: android-actions/setup-android@v3
with:
# What gen/android/app/build.gradle.kts declares (compileSdk 36),
# plus the build-tools AGP pairs with it. Anything else AGP needs it
# downloads itself; the licences are accepted by this action.
packages: 'platform-tools platforms;android-36 build-tools;36.0.0'
- uses: nttld/setup-ndk@v1
id: ndk
with:
# r28c = 28.2.13676358, the NDK gen/android was generated and first
# built with.
ndk-version: r28c
add-to-path: false
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android
- uses: Swatinem/rust-cache@v2
with:
workspaces: src/praisonai-mobile/src-tauri
key: android-apk
- name: "Build praisonai-ts (the file: dependency)"
# praisonai-mobile depends on praisonai by `file:`, so `npm ci`
# here runs praisonai-ts's own `prepare` -> `rimraf dist`. On a
# fresh runner praisonai-ts has no node_modules, so rimraf is not
# installed and `npm ci` dies with code 127 before a single
# platform step runs. mobile.yml gives all four of its jobs this
# step; these two were written against the tree from before the
# dependency existed and never got it.
working-directory: src/praisonai-ts
run: |
npm install --no-audit --no-fund --legacy-peer-deps
npm run build
- run: npm ci
- name: Generate the Android project (only if gen/android is not committed)
env:
NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
run: |
set -euo pipefail
if [ -d src-tauri/gen/android ]; then
echo "src-tauri/gen/android is committed; not regenerating it"
exit 0
fi
npx tauri android init --ci --skip-targets-install
- name: Build the debug APK
env:
NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
# --target aarch64 only: every phone sold in the last several years is
# arm64-v8a, and one target keeps the Rust build to one link. The
# `universal` in the output path is Gradle's name for "the APK that is
# not split per ABI", not a claim about what is inside it.
run: npx tauri android build --debug --apk --target aarch64 --ci
- name: Is there an APK, and is the Rust inside it?
run: |
set -euo pipefail
root=src-tauri/gen/android/app/build/outputs/apk
apk="$(find "$root" -name '*.apk' -print -quit || true)"
if [ -z "$apk" ]; then
echo "::error::no .apk was produced under $root"
find "$root" -type f -print 2>/dev/null || echo "(the outputs directory does not exist)"
exit 1
fi
echo "apk: $apk"
ls -la "$apk"
unzip -l "$apk" | grep -q 'lib/arm64-v8a/libpraisonai_mobile_lib.so' \
|| { echo "::error::$apk does not contain lib/arm64-v8a/libpraisonai_mobile_lib.so"; unzip -l "$apk" | head -40; exit 1; }
unzip -l "$apk" | grep -E 'lib/|AndroidManifest|classes.dex' | head
cp "$apk" "$RUNNER_TEMP/PraisonAI-android-debug-arm64.apk"
- name: Upload the APK
uses: actions/upload-artifact@v4
with:
name: PraisonAI-android-debug-arm64-apk
path: ${{ runner.temp }}/PraisonAI-android-debug-arm64.apk
if-no-files-found: error
retention-days: 30
-164
View File
@@ -1,164 +0,0 @@
# The mobile package's WEB build: built, booted in a real browser, and
# deployed to GitHub Pages.
#
# mobile.yml proves the package's tests pass. This workflow proves the thing
# that ships to a browser actually ships: dist/ is built, served under the
# repository subpath and driven through first paint, install, and an offline
# reload by tools/web-boot.test.mjs, then published. A PR gets the build and
# the proof; only main deploys.
#
# One setting is outside this file's reach: the repository's Pages source must
# be "GitHub Actions" (Settings -> Pages -> Build and deployment -> Source).
# Until a human turns that on, the deploy job SKIPS with a notice rather than
# failing -- a red main every push because of a setting no workflow can set
# would train people to ignore the mobile checks, which is how the last silent
# breakage in this package survived.
name: mobile web
on:
push:
branches: [main]
paths: &watched
- 'src/praisonai-mobile/**'
- '.github/workflows/mobile-web.yml'
# `praisonai` is a `file:` link to ../praisonai-ts, and the web bundle is
# built FROM it -- so drift on that side changes what ships to a browser
# and must trigger this gate. mobile.yml watches these same paths for the
# same reason; a web gate that watched only praisonai-mobile would go
# quiet exactly when the dependency it bundles moved.
- 'src/praisonai-ts/src/**'
- 'src/praisonai-ts/scripts/**'
- 'src/praisonai-ts/package.json'
pull_request:
paths: *watched
permissions:
contents: read
defaults:
run:
working-directory: src/praisonai-mobile
jobs:
build:
name: build and boot
runs-on: ubuntu-22.04
concurrency:
group: mobile-web-build-${{ github.ref }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22.18'
cache: npm
cache-dependency-path: src/praisonai-mobile/package-lock.json
# `praisonai` is a `file:` link to ../praisonai-ts, so its dist/ must
# exist before this package can even INSTALL: npm runs the linked
# package's prepare/prebuild, which fails without its own node_modules.
# `npm install`, not `npm ci`: praisonai-ts gitignores its lockfile.
# `--legacy-peer-deps` for the bedrock-agentcore -> @strands-agents/sdk
# peer conflict, exactly as mobile.yml and praisonai-ts-webview.yml do.
- name: "Build praisonai-ts (the file: dependency)"
working-directory: src/praisonai-ts
run: |
npm install --no-audit --no-fund --legacy-peer-deps
npm run build
- run: npm ci
- name: Cache the Playwright browser
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('src/praisonai-mobile/package-lock.json') }}
- name: Install Chromium for the boot proof
run: npx playwright install --with-deps chromium
- name: Build
run: npm run build
- name: Boot proof
# Serves dist/ under /PraisonAI/ in headless Chromium: composer and
# Send render, manifest served as application/manifest+json, service
# worker activates with the subpath as scope, an offline reload still
# renders, and an injected inline script is blocked by the CSP.
run: npm run test:web
- name: dist contents
run: ls -lR dist
- uses: actions/upload-artifact@v4
with:
name: mobile-web-dist
path: src/praisonai-mobile/dist
if-no-files-found: error
- uses: actions/upload-pages-artifact@v3
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
with:
path: src/praisonai-mobile/dist
# The readiness check is its OWN job, and deliberately declares no
# `environment:`. A job that names `environment: github-pages` cannot START
# until that environment exists, so when Pages is off the runner fails the
# job outright -- zero steps, about one second -- and every carefully
# written skip inside it is unreachable. Measured: run 33730322658 on main,
# "deploy to GitHub Pages" failed with steps: 0.
#
# Splitting the decision out means the check runs with no preconditions and
# the environment-bearing job is never scheduled unless it can succeed.
pages-ready:
name: is GitHub Pages enabled?
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: build
runs-on: ubuntu-22.04
permissions:
contents: read
# `gh api repos/<owner>/<repo>/pages` needs Pages read. Without it the
# request is rejected on a private repo, `2>/dev/null || echo '{}'`
# swallows the error to `{}`, build_type falls to "none", and the deploy
# SKIPS even though Pages is on -- the exact silent skip this file exists
# to prevent. `contents: read` alone leaves this permission at `none`.
pages: read
outputs:
ready: ${{ steps.check.outputs.ready }}
steps:
# Pages cannot be enabled from a workflow, so this reports the setting
# rather than demanding it. `ready` false SKIPS the deploy with a
# notice; it does not fail the run. deploy-pages would otherwise fail
# with a bare 404 that says nothing about which setting is missing.
- name: Is the Pages source "GitHub Actions"?
id: check
env:
GH_TOKEN: ${{ github.token }}
working-directory: .
run: |
site=$(gh api "repos/${{ github.repository }}/pages" 2>/dev/null || echo '{}')
build_type=$(echo "$site" | jq -r '.build_type // "none"')
cname=$(echo "$site" | jq -r '.cname // ""')
echo "build_type=$build_type cname=${cname:-<none>}"
if [ "$build_type" != "workflow" ]; then
echo "::notice::Skipping the Pages deploy: Pages is not set to build from GitHub Actions (build_type=$build_type). A repository admin must set it at https://github.com/${{ github.repository }}/settings/pages -> Build and deployment -> Source: GitHub Actions. The built site is attached to this run as the mobile-web-dist artifact."
echo "ready=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -n "$cname" ]; then
echo "::notice::Skipping the Pages deploy: a custom domain ($cname) is set, so the app would be served there rather than at the github.io URL."
echo "ready=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "ready=true" >> "$GITHUB_OUTPUT"
deploy:
name: deploy to GitHub Pages
needs: pages-ready
if: needs.pages-ready.outputs.ready == 'true'
runs-on: ubuntu-22.04
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
concurrency:
# Never cancel a deployment in flight; queue behind it instead.
group: pages
cancel-in-progress: false
steps:
- uses: actions/configure-pages@v5
- uses: actions/deploy-pages@v4
id: deployment
+7 -173
View File
@@ -26,22 +26,9 @@ name: mobile
on:
push:
branches: [main]
paths: &watched
- 'src/praisonai-mobile/**'
- '.github/workflows/mobile.yml'
# The `upstream parity` job below typechecks the REAL praisonai-ts Agent
# against the structural contract in engines/src/praisonai-ts/agent-api.ts.
# Watching only praisonai-mobile made that gate fire when the CONSUMER
# changed and stay silent when the thing it watches did -- so upstream
# drift landed on main unchecked. Verified on 71197fa80, which edited
# src/praisonai-ts/src/agent/simple.ts: no parity check ran on it.
- 'src/praisonai-ts/src/**'
# And the pieces of praisonai-ts that decide what `praisonai/mobile`
# RESOLVES to through the file: link -- the esm shim shapes dist/esm.
- 'src/praisonai-ts/scripts/**'
- 'src/praisonai-ts/package.json'
paths: ['src/praisonai-mobile/**', '.github/workflows/mobile.yml']
pull_request:
paths: *watched
paths: ['src/praisonai-mobile/**', '.github/workflows/mobile.yml']
concurrency:
group: mobile-${{ github.ref }}
@@ -70,27 +57,7 @@ jobs:
node-version: ${{ matrix.node }}
cache: npm
cache-dependency-path: src/praisonai-mobile/package-lock.json
# `praisonai` is a `file:` link to ../praisonai-ts (see praisonai-mobile's
# package.json for why), so its dist/ must exist before this package can
# resolve `praisonai/mobile`. `npm install`, not `npm ci`: praisonai-ts
# gitignores its lockfile. `--legacy-peer-deps` for the
# bedrock-agentcore -> @strands-agents/sdk peer conflict, exactly as
# praisonai-ts-webview.yml does.
- name: "Build praisonai-ts (the file: dependency)"
working-directory: src/praisonai-ts
run: |
npm install --no-audit --no-fund --legacy-peer-deps
npm run build
- run: npm ci
- name: Cache the Playwright browser
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('src/praisonai-mobile/package-lock.json') }}
- name: Install Chromium for the boot proof
# `npm test` includes tools/web-boot.test.mjs, which loads the built
# dist/ in a real browser. It fails rather than skips without one.
run: npx playwright install --with-deps chromium
- name: Typecheck
run: npm run typecheck
- name: Layer boundaries
@@ -109,11 +76,11 @@ jobs:
# rather than buried in another job's log.
#
# It needs only THIS package's install. praisonai-ts's own dependencies are
# not needed here (the `check` job installs them, with --legacy-peer-deps,
# to build the file: dependency): every member the check looks at is
# declared locally in simple.ts. That is verified rather than assumed:
# with praisonai-ts's node_modules moved aside, injected drift is still
# caught as TS2741.
# neither needed nor installable -- `npm install` there fails with an
# ERESOLVE peer conflict (bedrock-agentcore -> @strands-agents/sdk) -- and
# every member the check looks at is declared locally in simple.ts. That is
# verified rather than assumed: with praisonai-ts's node_modules moved
# aside, injected drift is still caught as TS2741.
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
@@ -122,142 +89,9 @@ jobs:
node-version: '22.18'
cache: npm
cache-dependency-path: src/praisonai-mobile/package-lock.json
- name: "Build praisonai-ts (the file: dependency)"
working-directory: src/praisonai-ts
run: |
npm install --no-audit --no-fund --legacy-peer-deps
npm run build
- run: npm ci
- name: Does the real Agent still satisfy PraisonAgent?
# Skips loudly if the praisonai-ts checkout is missing, rather than
# passing quietly -- a check that silently stops covering anything is
# worse than no check.
run: npm run check:upstream
shell:
name: rust shell (${{ matrix.os }})
# The Rust crate had 14 tests and no job running them -- the same failure
# this workflow's header says it was created to prevent, one language over.
#
# Both platforms, because the crate is cfg-heavy: the back-gesture fallback
# is #[cfg(target_os = "android")] / ios / not(mobile), and a cfg mistake
# compiles perfectly on whichever host you happened to try.
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, macos-15]
defaults:
run:
working-directory: src/praisonai-mobile/src-tauri
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22.18'
cache: npm
cache-dependency-path: src/praisonai-mobile/package-lock.json
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: src/praisonai-mobile/src-tauri
- name: Install the Linux build dependencies
if: runner.os == 'Linux'
working-directory: .
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev librsvg2-dev libxdo-dev libssl-dev patchelf
# generate_context! expands frontendDist (../dist) at COMPILE time, and
# dist/ is gitignored -- absent on a fresh checkout. Without this the
# crate cannot compile and neither test nor clippy below ever runs.
# `praisonai` is a `file:` link to ../praisonai-ts (see praisonai-mobile's
# package.json for why), so its dist/ must exist before this package can
# resolve `praisonai/mobile`. `npm install`, not `npm ci`: praisonai-ts
# gitignores its lockfile. `--legacy-peer-deps` for the
# bedrock-agentcore -> @strands-agents/sdk peer conflict, exactly as
# praisonai-ts-webview.yml does.
- name: "Build praisonai-ts (the file: dependency)"
working-directory: src/praisonai-ts
run: |
npm install --no-audit --no-fund --legacy-peer-deps
npm run build
- name: Build the webview (creates ../dist for generate_context!)
working-directory: src/praisonai-mobile
run: |
npm ci
npm run build
# The pure-logic tests: back-gesture arbitration, the lifecycle mapping,
# and the event-name contract. None of them needs a phone.
- name: Test
run: cargo test
- name: Clippy
run: cargo clippy --all-targets -- -D warnings
# ------------------------------------------------------------------
# The mobile targets, which nothing compiled.
#
# `src/lib.rs` is `#[cfg_attr(mobile, tauri::mobile_entry_point)]`, and
# `tauri_build::build()` emits `cargo:rustc-cfg=mobile` only for android and
# ios targets. Both legs of `rust shell` above are HOST targets, so that
# attribute -- the one that generates the Android JNI symbol and the iOS
# start_app symbol -- expanded exactly zero times in CI, and neither did the
# `#[cfg(target_os = "android")]` / `ios` arms of `commands.rs`. The job
# above says it covers cfg mistakes; it covers the ones in `shell/`, not
# these.
#
# `cargo check`, not `build`: it type-checks and macro-expands under the
# mobile cfg, which is the thing that was never done, and needs no NDK and no
# Xcode project. Verified at 18s for the android target.
# ------------------------------------------------------------------
cross-check:
name: cross-check (${{ matrix.target }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-linux-android
os: ubuntu-22.04
- target: aarch64-apple-ios
os: macos-15
defaults:
run:
working-directory: src/praisonai-mobile/src-tauri
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22.18'
cache: npm
cache-dependency-path: src/praisonai-mobile/package-lock.json
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: src/praisonai-mobile/src-tauri
key: ${{ matrix.target }}
# generate_context! expands frontendDist at COMPILE time and dist/ is
# gitignored, so the crate cannot be checked without this -- the same
# reason the job above needs it.
# `praisonai` is a `file:` link to ../praisonai-ts (see praisonai-mobile's
# package.json for why), so its dist/ must exist before this package can
# resolve `praisonai/mobile`. `npm install`, not `npm ci`: praisonai-ts
# gitignores its lockfile. `--legacy-peer-deps` for the
# bedrock-agentcore -> @strands-agents/sdk peer conflict, exactly as
# praisonai-ts-webview.yml does.
- name: "Build praisonai-ts (the file: dependency)"
working-directory: src/praisonai-ts
run: |
npm install --no-audit --no-fund --legacy-peer-deps
npm run build
- name: Build the webview (creates ../dist for generate_context!)
working-directory: src/praisonai-mobile
run: |
npm ci
npm run build
- name: Does the crate still expand and type-check for a phone?
run: cargo check --lib --target ${{ matrix.target }}
-106
View File
@@ -1,106 +0,0 @@
name: Parity Gate
# Validates Python <-> TypeScript SDK parity on pull requests and FAILS on drift.
#
# Two named checks, so each kind of drift is visible in the checks list:
# names - the exported-name tracker (PARITY.md / FEATURE_PARITY_TRACKER.json)
# must be regenerated in the PR when the public surface changes.
# behaviour - options accepted for parity but not yet acted on; a ratchet,
# so the count may fall and never rise.
# signatures - constructor/method parameters of the curated surfaces in
# src/praisonai/praisonai/_dev/parity/signatures/surface.yaml must
# match (after camelCase/alias/flattening rules) or be waived with a
# reason in waivers.yaml. Stale or expired waivers also fail.
#
# The regenerate-and-commit job stays in update-parity-tracker.yml (push to main).
# This workflow never commits; it only reports.
on:
pull_request:
paths:
- 'src/praisonai-agents/praisonaiagents/**/*.py'
- 'src/praisonai-ts/src/**/*.ts'
- 'src/praisonai-rust/praisonai/src/**/*.rs'
- 'src/praisonai/praisonai/_dev/parity/**'
- 'src/praisonai/praisonai/cli/features/**'
# The generator the `names` job runs. Editing it changed what the gate
# measured without the gate running.
- 'src/praisonai/scripts/**'
- 'src/praisonai-ts/PARITY.md'
- 'src/praisonai-ts/FEATURE_PARITY_TRACKER.json'
- 'src/praisonai-ts/SIGNATURE_PARITY.md'
- 'src/praisonai-ts/signature-parity.json'
# The behaviour baseline itself. These two carry the ratchet floor, so a
# pull request that edited only them raised the floor with no job running.
- 'src/praisonai-ts/BEHAVIOUR_PARITY.md'
- 'src/praisonai-ts/behaviour-parity.json'
- 'src/praisonai-rust/PARITY.md'
- 'src/praisonai-rust/FEATURE_PARITY_TRACKER.json'
- '.github/workflows/parity-gate.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
names:
name: names (exported-symbol tracker up to date)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install pyyaml
- name: Tracker files match the sources
run: |
if ! python3 src/praisonai/scripts/generate_parity_tracker.py --check; then
echo "::error::Parity tracker is out of date. Run: python3 src/praisonai/scripts/generate_parity_tracker.py and commit src/praisonai-ts/PARITY.md, FEATURE_PARITY_TRACKER.json and the Rust pair."
exit 1
fi
signatures:
name: signatures (constructor and method parameters)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: pip install pyyaml
- name: Install the TypeScript compiler only
# praisonai-ts's full dependency tree is not needed (and does not
# install cleanly); the extractor needs only the `typescript` package.
run: |
mkdir -p "$RUNNER_TEMP/parity-ts"
npm install --prefix "$RUNNER_TEMP/parity-ts" --no-audit --no-fund [email protected]
echo "PARITY_TS_NODE_MODULES=$RUNNER_TEMP/parity-ts/node_modules" >> "$GITHUB_ENV"
- name: Signatures match, or are waived with a reason
env:
PYTHONPATH: src/praisonai
run: |
if ! python3 -m praisonai._dev.parity.signatures --check; then
echo "::error::Signature parity failed. See the output above for the surface and parameter. Fix the TypeScript side, or add a waiver with a reason to src/praisonai/praisonai/_dev/parity/signatures/waivers.yaml, then run: PYTHONPATH=src/praisonai python3 -m praisonai._dev.parity.signatures --write"
exit 1
fi
behaviour:
name: behaviour (options accepted but not acted on)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install pyyaml
- name: The count of ignored options has not grown
env:
PYTHONPATH: src/praisonai
run: |
if ! python3 -m praisonai._dev.parity.behaviour --check; then
echo "::error::An option accepted for Python parity is not acted on, and the total grew. The name and signature gates cannot see this: they check that a parameter EXISTS. Implement the behaviour and delete its entry from src/praisonai-ts/src/utils/parity-notice.ts, then run: PYTHONPATH=src/praisonai python3 -m praisonai._dev.parity.behaviour --write"
exit 1
fi
-70
View File
@@ -1,70 +0,0 @@
# Does praisonai-ts still pass its own tests?
#
# It has a suite -- 2,000+ tests -- and until now nothing ran it. The only
# workflow with `working-directory: src/praisonai-ts` builds the package and
# checks the webview bundle; the two other mentions of `npm test` in this
# directory are a comment and a line of prose telling a reviewer to run it by
# hand. So the suite was green the way an unread report is green.
#
# That mattered the moment the suite grew: PR #4755 added ~880 tests for the
# Python-parity port and not one of them would have run here. A test nobody
# executes is worse than no test, because it is counted as cover.
#
# Deliberately narrow, like its webview sibling: it runs only when
# praisonai-ts changes, installs only what it needs, and asserts two things --
# the package typechecks, and its tests pass.
name: praisonai-ts tests
on:
push:
branches: [main]
paths:
- 'src/praisonai-ts/src/**'
- 'src/praisonai-ts/tests/**'
- 'src/praisonai-ts/package.json'
- 'src/praisonai-ts/jest.config.ts'
- 'src/praisonai-ts/tsconfig*.json'
- '.github/workflows/praisonai-ts-tests.yml'
pull_request:
paths:
- 'src/praisonai-ts/src/**'
- 'src/praisonai-ts/tests/**'
- 'src/praisonai-ts/package.json'
- 'src/praisonai-ts/jest.config.ts'
- 'src/praisonai-ts/tsconfig*.json'
- '.github/workflows/praisonai-ts-tests.yml'
concurrency:
group: praisonai-ts-tests-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
tests:
name: unit tests
runs-on: ubuntu-22.04
defaults:
run:
working-directory: src/praisonai-ts
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22.18'
# `npm install --legacy-peer-deps`, matching praisonai-ts-webview.yml:
# the lockfile is gitignored so `npm ci` has nothing to read, and the
# dependency tree does not resolve without the flag (bedrock-agentcore
# wants a peer of @strands-agents/sdk that is not installed).
- name: Install
run: npm install --no-audit --no-fund --legacy-peer-deps
- name: Typecheck
run: npx tsc --noEmit -p .
# PRAISONAI_PARITY_SILENT quiets the one-line notices that options
# accepted for Python parity print when they are not yet honoured; they
# are deliberate output, not failures, and they drown the test log.
- name: Tests
run: npm test
env:
PRAISONAI_PARITY_SILENT: '1'
+1 -121
View File
@@ -759,126 +759,6 @@ jobs:
if: steps.check-key.outputs.has_key == 'false'
run: echo "✅ Cohere tests skipped (no API key available)"
# ============================================
# OLLAMA LIVE TESTS - Real local model, no API key
# Nightly / extended only. Installs Ollama on the runner and pulls a small
# tools-capable model. Unlike the other live jobs this one needs no secret,
# so its required step has no "|| echo" fallback: a failure here is a real
# regression in local-model tool calling, not a missing credential.
# ============================================
extended-ollama:
if: github.event_name == 'schedule' || github.event.inputs.tier == 'extended' || github.event.inputs.tier == 'nightly'
runs-on: ubuntu-latest
timeout-minutes: 15
env:
# Bump deliberately: this is the version everything in this job was
# verified against. Latest at time of writing is v0.33.3.
OLLAMA_VERSION: v0.33.2
# 522 MB, capabilities: completion, tools, thinking.
OLLAMA_TEST_MODEL: qwen3:0.6b
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-ollama-${{ hashFiles('**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Cache Ollama model blobs
uses: actions/cache@v4
with:
path: ~/.ollama/models
# No restore-keys on purpose: a partial hit from a different model would
# silently test something other than OLLAMA_TEST_MODEL.
key: ${{ runner.os }}-ollama-models-${{ env.OLLAMA_TEST_MODEL }}
- name: Install Ollama
shell: bash
run: |
set -euo pipefail
# The official installer, but VERSION-PINNED via OLLAMA_VERSION (which it
# honours) so this job cannot silently change what it tests. Fetching the
# release asset directly is not a workable alternative: since v0.33 the
# linux bundle ships as a 1.4 GB .tar.zst with GPU runtimes bundled in,
# and the installer already handles the zst/tgz split across versions.
sudo apt-get update -qq && sudo apt-get install -y -qq zstd
curl -fsSL https://ollama.com/install.sh | sh
ollama --version
- name: Start Ollama and pull model
shell: bash
run: |
set -euo pipefail
ollama serve > /tmp/ollama-serve.log 2>&1 &
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:11434/api/tags > /dev/null; then
echo "Ollama is up after ${i}s"
break
fi
sleep 1
done
curl -sf http://127.0.0.1:11434/api/tags > /dev/null
ollama pull "$OLLAMA_TEST_MODEL"
ollama list
- name: Install dependencies
run: |
pip install --upgrade pip
pip install pytest pytest-asyncio pytest-timeout
- uses: ./.github/actions/install-monorepo-packages
with:
agents-extras: ".[all]"
- name: Run Ollama minimum contract (required)
env:
PRAISONAI_TEST_TIER: extended
PRAISONAI_ALLOW_NETWORK: '1'
PRAISONAI_LIVE_TESTS: '1'
PRAISONAI_TEST_PROVIDERS: 'ollama'
PRAISONAI_TEST_OLLAMA: '1'
PRAISONAI_OLLAMA_TEST_MODEL: ${{ env.OLLAMA_TEST_MODEL }}
shell: bash
run: |
set -euo pipefail
cd src/praisonai
# No "|| echo" here: no secret is involved, so a failure is a real
# regression rather than a missing credential.
python -m pytest tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
-v --tb=short \
--timeout=120
- name: Run remaining Ollama live tests (informational)
if: always()
env:
PRAISONAI_TEST_TIER: extended
PRAISONAI_ALLOW_NETWORK: '1'
PRAISONAI_LIVE_TESTS: '1'
PRAISONAI_TEST_PROVIDERS: 'ollama'
PRAISONAI_TEST_OLLAMA: '1'
PRAISONAI_OLLAMA_TEST_MODEL: ${{ env.OLLAMA_TEST_MODEL }}
run: |
cd src/praisonai
python -m pytest tests/integration/test_ollama_tool_calling_live.py \
--deselect tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
-v --tb=short \
--timeout=180 \
|| echo "Ollama exploratory tests completed (small models are non-deterministic)"
- name: Ollama server log
if: failure()
run: tail -100 /tmp/ollama-serve.log
# ============================================
# LEGACY UNIT TESTS - Post-C6 re-enabled suite
# Nightly / extended tier only (~401 tests)
@@ -956,7 +836,7 @@ jobs:
# TEST SUMMARY
# ============================================
test-summary:
needs: [smoke, main, extended-ollama]
needs: [smoke, main]
if: always()
runs-on: ubuntu-latest
+1 -5
View File
@@ -6,11 +6,7 @@ on:
paths:
- 'src/praisonai-agents/praisonaiagents/**/*.py'
- 'src/praisonai-ts/src/**/*.ts'
# The crate lives at src/praisonai-rust/praisonai/src -- the old
# 'src/praisonai-rust/src/**' path does not exist, so a Rust change
# never triggered either tracker and the Rust numbers only ever
# refreshed incidentally, on an unrelated push.
- 'src/praisonai-rust/praisonai/src/**/*.rs'
- 'src/praisonai-rust/src/**/*.rs'
- 'src/praisonai/praisonai/_dev/parity/**/*.py'
- '.github/workflows/update-docs-parity.yml'
workflow_dispatch: # Allow manual trigger
+7 -50
View File
@@ -1,35 +1,21 @@
name: Update Feature Parity Tracker
# Regenerates the exported-name parity trackers on push to main and commits them.
# Validation on pull requests (fail on drift) lives in parity-gate.yml.
on:
push:
branches: [ "main" ]
paths:
- 'src/praisonai-agents/praisonaiagents/**/*.py'
- 'src/praisonai-ts/src/**/*.ts'
# The crate lives at src/praisonai-rust/praisonai/src -- the old
# 'src/praisonai-rust/src/**' path does not exist, so a Rust change
# never triggered either tracker and the Rust numbers only ever
# refreshed incidentally, on an unrelated push.
- 'src/praisonai-rust/praisonai/src/**/*.rs'
# The generator's own code and the wrapper features it counts. Change
# either and nothing regenerated until an unrelated push landed.
- 'src/praisonai/praisonai/_dev/parity/**/*.py'
- 'src/praisonai/praisonai/cli/features/**/*.py'
- 'src/praisonai-rust/src/**/*.rs'
- '.github/workflows/update-parity-tracker.yml'
permissions:
contents: write
jobs:
update-parity-tracker:
# Push only. On a PR the parity-gate.yml `names` job CHECKS instead --
# committing a regenerated tracker onto a contributor's branch is not this
# workflow's business, and would fail on a fork besides.
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -46,45 +32,15 @@ jobs:
- name: Install dependencies
run: pip install pyyaml
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install the TypeScript compiler only
# The signature extractor needs `typescript` and nothing else;
# praisonai-ts's own dependency tree does not install cleanly.
run: |
mkdir -p "$RUNNER_TEMP/parity-ts"
npm install --prefix "$RUNNER_TEMP/parity-ts" --no-audit --no-fund [email protected]
echo "PARITY_TS_NODE_MODULES=$RUNNER_TEMP/parity-ts/node_modules" >> "$GITHUB_ENV"
- name: Generate parity trackers
run: |
cd $GITHUB_WORKSPACE
python3 src/praisonai/scripts/generate_parity_tracker.py
- name: Regenerate the signature report
# Source locations in the report drift whenever a covered file is edited.
# The pull-request gate ignores line-number-only drift so it never reddens
# a build over it; refreshing here keeps the committed line numbers true.
env:
PYTHONPATH: src/praisonai
run: |
cd $GITHUB_WORKSPACE
python3 -m praisonai._dev.parity.signatures --write
- name: Regenerate the behaviour report
env:
PYTHONPATH: src/praisonai
run: |
cd $GITHUB_WORKSPACE
python3 -m praisonai._dev.parity.behaviour --write
- name: Check for changes
id: check_changes
run: |
if git diff --quiet src/praisonai-ts/FEATURE_PARITY_TRACKER.json src/praisonai-ts/PARITY.md src/praisonai-rust/FEATURE_PARITY_TRACKER.json src/praisonai-rust/PARITY.md src/praisonai-ts/SIGNATURE_PARITY.md src/praisonai-ts/signature-parity.json src/praisonai-ts/BEHAVIOUR_PARITY.md src/praisonai-ts/behaviour-parity.json 2>/dev/null; then
if git diff --quiet src/praisonai-ts/FEATURE_PARITY_TRACKER.json src/praisonai-ts/PARITY.md src/praisonai-rust/FEATURE_PARITY_TRACKER.json src/praisonai-rust/PARITY.md 2>/dev/null; then
echo "changes=false" >> $GITHUB_OUTPUT
echo "No changes to parity trackers"
else
@@ -98,7 +54,8 @@ jobs:
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "docs: auto-update feature parity trackers [skip ci]"
file_pattern: 'src/praisonai-ts/FEATURE_PARITY_TRACKER.json src/praisonai-ts/PARITY.md src/praisonai-rust/FEATURE_PARITY_TRACKER.json src/praisonai-rust/PARITY.md src/praisonai-ts/SIGNATURE_PARITY.md src/praisonai-ts/signature-parity.json src/praisonai-ts/BEHAVIOUR_PARITY.md src/praisonai-ts/behaviour-parity.json'
file_pattern: 'src/praisonai-ts/FEATURE_PARITY_TRACKER.json src/praisonai-ts/PARITY.md src/praisonai-rust/FEATURE_PARITY_TRACKER.json src/praisonai-rust/PARITY.md'
commit_user_name: "MervinPraison"
commit_user_email: "[email protected]"
commit_author: "MervinPraison <[email protected]>"
+1 -4
View File
@@ -115,7 +115,4 @@ Cargo.lock
src/praisonai/build*.egg-info/
src/praisonai/PraisonAI.egg-info/
jobs/*
*.egg-info
# Local Android SDK/NDK/JDK for building praisonai-mobile on a laptop without
# Android Studio (how: .github/workflows/mobile-platforms.yml). Several GB.
.android-sdk/
*.egg-info
+12 -12
View File
@@ -222,15 +222,15 @@ The Python SDK is organised as three publishable tiers (see [§2](#2-python-thre
Core execution modules live in `praisonaiagents`:
- `src/praisonai-agents/praisonaiagents/agent/` — Agent class, handoff, autonomy
- `src/praisonai-agents/praisonaiagents/llm/` — Model runtime with provider routing, rate limiting, failover
- `src/praisonai-agents/praisonaiagents/tools/` — Tool runtime with sandbox, approval, retry
- `src/praisonai-agents/praisonaiagents/memory/` — Memory runtime (in-memory, SQLite, MongoDB, Mem0 adapters)
- `src/praisonai-agents/praisonaiagents/knowledge/` — Knowledge management (indexing, retrieval, chunking)
- `src/praisonai-agents/praisonaiagents/workflows/` — Workflow engine (YAML/SDK-based orchestration)
- `src/praisonai-agents/praisonaiagents/hooks/`, `src/praisonai-agents/praisonaiagents/bus/` — Hook system and event bus
- `src/praisonai-agents/praisonaiagents/mcp/` — MCP protocol support
- `src/praisonai-agents/praisonaiagents/ui/a2a/`, `src/praisonai-agents/praisonaiagents/ui/a2ui/` — Agent-to-agent and agent-to-UI protocols
- `agent/` — Agent class, handoff, autonomy
- `llm/` — Model runtime with provider routing, rate limiting, failover
- `tools/` — Tool runtime with sandbox, approval, retry
- `memory/` — Memory runtime (in-memory, SQLite, MongoDB, Mem0 adapters)
- `knowledge/` — Knowledge management (indexing, retrieval, chunking)
- `workflows/` — Workflow engine (YAML/SDK-based orchestration)
- `hooks/`, `bus/` — Hook system and event bus
- `mcp/` — MCP protocol support
- `a2a/`, `a2ui/` — Agent-to-agent and agent-to-UI protocols (wrapper/SDK)
Wrapper-only surfaces remain in `src/praisonai/`:
@@ -243,9 +243,9 @@ Wrapper-only surfaces remain in `src/praisonai/`:
- **Python Core SDK** — `src/praisonai-agents/` (`praisonaiagents`)
- **Python Terminal CLI** — `src/praisonai-code/` (`praisonai-code`)
- **Python Wrapper** — `src/praisonai/` (`praisonai`)
- **TypeScript SDK** — JS/TS runtime (`src/praisonai-ts/`)
- **Rust SDK** — High-performance Rust runtime (`src/praisonai-rust/`)
- **UI** — Web UI applications (`src/praisonai/praisonai/ui_chat/`, `src/praisonai/praisonai/ui_agents/`, `src/praisonai/praisonai/ui_bot/`, `src/praisonai/praisonai/ui_realtime/`); shared support code lives in `src/praisonai/praisonai/ui/`.
- **TypeScript SDK** — JS/TS runtime (`ts-sdk/`)
- **Rust SDK** — High-performance Rust runtime (`rust-sdk/`)
- **UI** — Web UI (`ui/`)
---
+4 -43
View File
@@ -35,19 +35,6 @@ curl -fsSL https://praison.ai/install.sh | bash
<img src="https://img.shields.io/badge/Highlighted_by_Elon_Musk-000000?style=for-the-badge&logo=x&logoColor=white" alt="Highlighted by Elon Musk" />
</a>
<br>
<br>
<a href="https://github.com/MervinPraison/PraisonAI/releases/latest">
<img src="https://img.shields.io/badge/Download_for_macOS-000000?style=for-the-badge&logo=apple&logoColor=white" alt="Download for macOS" />
</a>
&nbsp;&nbsp;
<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>
&nbsp;&nbsp;
<a href="https://github.com/MervinPraison/PraisonAI/releases/latest">
<img src="https://img.shields.io/badge/Download_for_Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download for Linux" />
</a>
<br>
</div>
<p align="center">
@@ -1112,39 +1099,13 @@ For other providers, see [Models docs](https://docs.praison.ai/docs/models).
<summary><strong>How do I use a local model (Ollama)?</strong></summary>
```bash
# Start Ollama and pull a model
# Start Ollama server first
ollama serve
ollama pull llama3.2
# Set environment variable
export OPENAI_BASE_URL=http://localhost:11434/v1
```
```python
from praisonaiagents import Agent
agent = Agent(instructions="You are a helpful assistant", llm="ollama/llama3.2")
agent.start("Why is the sky blue?")
```
The `ollama/` prefix is what selects Ollama's handling — tool-call repair,
tool-result formatting and the streaming rules small local models need. Or set
it by environment instead:
```bash
export OPENAI_MODEL_NAME=ollama/llama3.2
```
Setting only `OPENAI_BASE_URL` is not enough: with no model named, the OpenAI
default (`gpt-4o-mini`) is sent to Ollama, which answers
`404 model 'gpt-4o-mini' not found`. Always name the model.
Point at a non-default host with `base_url=` or `OLLAMA_HOST`:
```python
agent = Agent(instructions="...", llm="ollama/llama3.2", base_url="http://192.168.1.10:11434")
```
The same shape works for other local runtimes — `lm_studio/`, `vllm/` and
`hosted_vllm/` with their server's `base_url`.
See [Models docs](https://docs.praison.ai/docs/models) for more details.
</details>
+33 -63
View File
@@ -309,7 +309,7 @@ Methods:
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">normalize_content</a>(content)</code>
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">reset</a>()</code>
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">search</a>(query, user_id = None, agent_id = None, run_id = None, rerank = None, **kwargs)</code>
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">store</a>(content, user_id = None, agent_id = None, run_id = None, metadata = None, is_content = False)</code>
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">store</a>(content, user_id = None, agent_id = None, run_id = None, metadata = None)</code>
* <code title="class Knowledge">Knowledge.<a href="./src/praisonai-agents/praisonaiagents/knowledge/knowledge.py">update</a>(memory_id, data)</code>
* <code title="class KnowledgeConfig">KnowledgeConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
@@ -387,7 +387,6 @@ Methods:
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">validate_input</a>(content: str, **kwargs) -> Tuple[bool, str]</code>
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">validate_output</a>(content: str, **kwargs) -> Tuple[bool, str]</code>
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">validate_tool_call</a>(tool_name: str, arguments: Dict[str, Any], **kwargs) -> Tuple[bool, Dict[str, Any]]</code>
* <code title="class LLMGuardrail">LLMGuardrail.<a href="./src/praisonai-agents/praisonaiagents/guardrails/llm_guardrail.py">validate_tool_result</a>(tool_name: str, result: Any, **kwargs) -> Tuple[bool, Any]</code>
# Planning
@@ -782,7 +781,7 @@ Methods:
Types:
```python
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, DB, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run
```
# CLI
@@ -926,23 +925,17 @@ Methods:
Types/Exports:
```ts
export { Agent, AgentTeam, Agents, CodeAgent, EmbeddingAgent, OCRAgent, PraisonAIAgents, RealtimeAgent, Router, TASK_STATUS, VideoAgent, VisionAgent, createCodeAgent, createEmbeddingAgent, createOCRAgent, createRealtimeAgent, createVideoAgent, createVisionAgent } from "./agent";
export type { AgentChatCallOptions, AgentChatOptions, AgentEvent, AgentExecuteTask, AgentGuardrailEntry, AgentGuardrailFunction, AgentGuardrailInput, AgentHooksInput, AgentMemoryStore, AgentMessage, AgentRetryConfig, AgentStreamOptions, AgentTaskLike, AgentTeamConfig, AgentTeamProcess, AgentTeamStartDictOptions, AgentTeamStartOptions, AgentTeamStartOptionsInput, AgentWebConfig, PraisonAIAgentsConfig, SimpleAgentConfig, SimpleRouteConfig, SimpleRouterConfig, StopReason, TaskCallback, TaskGuardrail, TaskOnError } from "./agent";
export { Agent, AgentTeam, Agents, PraisonAIAgents, Router } from "./agent";
export type { AgentEvent, AgentMessage, AgentStreamOptions, AgentTeamConfig, PraisonAIAgentsConfig, SimpleAgentConfig, SimpleRouteConfig, SimpleRouterConfig, StopReason } from "./agent";
export { AudioAgent, createAudioAgent } from "./agent/audio";
export type { AudioAgentConfig, AudioConfig, AudioProvider, AudioSpeakOptions, AudioSpeakResult, AudioTranscribeOptions, AudioTranscribeResult } from "./agent/audio";
export { ContextAgent, createContextAgent, create_context_agent } from "./agent/context";
export { ContextPolicy, DEFAULT_HANDOFF_TOOL_POLICY, Handoff, HandoffCycleError, HandoffDepthError, HandoffError, HandoffTimeoutError, HandoffToolPolicyMode, RECOMMENDED_PROMPT_PREFIX, handoff, handoffFilters, handoffToolName, handoff_filters, listAgentTools, parallelHandoffs, parallel_handoffs, promptWithHandoffInstructions, prompt_with_handoff_instructions, resolveHandoffToolPolicy } from "./agent/handoff";
export { Heartbeat, HeartbeatConfig, parseHeartbeatSchedule } from "./agent/heartbeat";
export type { HeartbeatAgent, HeartbeatOnError, HeartbeatOptions } from "./agent/heartbeat";
export type { AudioAgentConfig, AudioProvider, AudioSpeakOptions, AudioSpeakResult, AudioTranscribeOptions, AudioTranscribeResult } from "./agent/audio";
export { ContextAgent, createContextAgent } from "./agent/context";
export { ContextPolicy, Handoff, HandoffCycleError, HandoffDepthError, HandoffError, HandoffTimeoutError, RECOMMENDED_PROMPT_PREFIX, handoff, handoffFilters, promptWithHandoffInstructions } from "./agent/handoff";
export { ImageAgent, createImageAgent } from "./agent/image";
export { PromptExpanderAgent, createPromptExpanderAgent } from "./agent/prompt-expander";
export { QueryRewriterAgent, createQueryRewriterAgent } from "./agent/query-rewriter";
export { DeepResearchAgent, Provider, createDeepResearchAgent } from "./agent/research";
export { RetryBackoffConfig, interruptibleSleep, jitteredBackoff } from "./agent/retry-utils";
export type { JitteredBackoffOptions, RetryBackoffConfigOptions } from "./agent/retry-utils";
export { DeepResearchAgent, createDeepResearchAgent } from "./agent/research";
export { RouterAgent, createRouter, routeConditions } from "./agent/router";
export { AGENT_RUN_STATUSES, AgentRunOutcome, PROVIDER_BLOCK_REASONS, RunOutcome, TERMINAL_REASON_PRECEDENCE, TERMINATION_TO_RUN_STATUS, TerminationReason, classifyFinishReason, terminationToRunStatus, termination_to_run_status, validateDecisionString, validate_decision_string } from "./agent/run-outcome";
export type { AgentRunOutcomeFailureOptions, AgentRunOutcomeInit, AgentRunOutcomeOptions, AgentRunStatus, RunOutcomeInit, TerminalReason } from "./agent/run-outcome";
export { // Agent loop
createAgentLoop, // DevTools
enableDevTools, // MCP
@@ -959,7 +952,6 @@ export { // Agent loop
defineTool, // UI Message (AI SDK v6 parity)
convertToModelMessages, AIAgentStep, AIEmbedManyResult, AIEmbedOptions, AIEmbedResult, AIFilePart, AIGenerateImageOptions, AIGenerateImageResult, AIGenerateObjectOptions, AIGenerateObjectResult, AIGenerateTextOptions, AIGenerateTextResult, AIImagePart, AIMiddleware, AIMiddlewareConfig, AIModelMessage, AISpan, AISpanKind, AISpanOptions, AISpanStatus, AIStreamObjectOptions, AIStreamObjectResult, AIStreamTextOptions, AIStreamTextResult, AITelemetryEvent, AITelemetrySettings, AITextPart, AIToolDefinition, AITracer, AgentLoop, DANGEROUS_PATTERNS, MCPClientType, MODEL_ALIASES, SPEECH_MODELS, TRANSCRIPTION_MODELS, ToolApprovalDeniedError, ToolApprovalTimeoutError, aiEmbed, aiEmbedMany, aiGenerateImage, aiGenerateObject, aiGenerateText, aiStreamObject, aiStreamText, applyMiddleware, autoEnableDevTools, base64ToUint8Array, clearAICache, clearEvents, closeAllMCPClients, closeMCPClient, convertToUIMessages, createAILoggingMiddleware, createAISpan, createApprovalResponse, createDangerousPatternChecker, createDevToolsMiddleware, createExpressHandler, createFastifyHandler, createFilePart, createHonoHandler, createMultimodalMessage, createNestHandler, createPagesHandler, createPdfPart, createSystemMessage, createTelemetryMiddleware, createTelemetrySettings, createTextMessage, createTextPart, createToolSet, disableAITelemetry, disableDevTools, enableAITelemetry, functionToTool, getAICacheStats, getApprovalManager, getDevToolsState, getDevToolsUrl, getEvents, getMCPClient, getModel, getTelemetrySettings, getToolsNeedingApproval, getTracer, hasModelAlias, hasPendingApprovals, initOpenTelemetry, isDangerous, isDataUrl, isDevToolsEnabled, isTelemetryEnabled, isUrl, listModelAliases, mcpToolsToAITools, parseModel, pipeUIMessageStreamToResponse, recordEvent, resolveModelAlias, safeValidateUIMessages, setApprovalManager, stopAfterSteps, stopWhen, stopWhenNoToolCalls, toMessageContent, toUIMessageStreamResponse, transcribe, uint8ArrayToBase64, validateUIMessages, withApproval, withSpan, wrapModel } from "./ai";
export { AutoAgents, AutoTaskConfig, createAutoAgents } from "./auto";
export { BotRunStatus } from "./bots/protocols";
export { BaseCache, FileCache, MemoryCache, createFileCache, createMemoryCache } from "./cache";
export { CLI_SPEC_VERSION, executeCommand, parseArgs } from "./cli";
export { // Autonomy Mode
@@ -981,27 +973,23 @@ export { // Autonomy Mode
export { // Classes
DictCondition, // Functions
evaluateCondition, // Types
type ConditionProtocol, ExpressionCondition, FunctionCondition, andConditions, createCondition, evaluate_condition, notCondition, orConditions } from "./conditions";
type ConditionProtocol, ExpressionCondition, FunctionCondition, andConditions, createCondition, notCondition, orConditions } from "./conditions";
export { // Enums
MemoryBackend, // Errors
ConfigValidationError, // Parse utilities
detect_url_scheme, // Presets
MEMORY_PRESETS, // Resolver functions
resolve, AUTONOMY_PRESETS, ArrayMode, CACHING_PRESETS, CONTEXT_PRESETS, ChunkingStrategy, EXECUTION_PRESETS, ExecutionPreset, FeatureMemoryConfig, GUARDRAIL_PRESETS, GuardrailAction, KNOWLEDGE_PRESETS, LearnBackend, LearnMode, LearnScope, MEMORY_URL_SCHEMES, MULTI_AGENT_EXECUTION_PRESETS, MULTI_AGENT_OUTPUT_PRESETS, OUTPUT_PRESETS, OutputPreset, PLANNING_PRESETS, PreCompactionMemoryFlushConfig, REFLECTION_PRESETS, RulesConfig, ToolSearchConfig, WEB_PRESETS, WebSearchProvider, apply_config_defaults, clean_triple_backticks, get_config, get_config_path, get_default, get_defaults_config, get_plugins_config, is_path_like, is_policy_string, parse_policy_string, resolve_autonomy, resolve_caching, resolve_context, resolve_execution, resolve_guardrails, resolve_hooks, resolve_knowledge, resolve_memory, resolve_output, resolve_planning, resolve_reflection, resolve_routing, resolve_skills, resolve_web, suggest_similar, validate_config } from "./config";
resolve, AUTONOMY_PRESETS, ArrayMode, CACHING_PRESETS, CONTEXT_PRESETS, ChunkingStrategy, EXECUTION_PRESETS, ExecutionPreset, FeatureMemoryConfig, GUARDRAIL_PRESETS, GuardrailAction, KNOWLEDGE_PRESETS, MEMORY_URL_SCHEMES, MULTI_AGENT_EXECUTION_PRESETS, MULTI_AGENT_OUTPUT_PRESETS, OUTPUT_PRESETS, OutputPreset, PLANNING_PRESETS, REFLECTION_PRESETS, WEB_PRESETS, WebSearchProvider, apply_config_defaults, clean_triple_backticks, get_config, get_config_path, get_default, get_defaults_config, get_plugins_config, is_path_like, is_policy_string, parse_policy_string, resolve_autonomy, resolve_caching, resolve_context, resolve_execution, resolve_guardrails, resolve_hooks, resolve_knowledge, resolve_memory, resolve_output, resolve_planning, resolve_reflection, resolve_routing, resolve_skills, resolve_web, suggest_similar, validate_config } from "./config";
export { createDbAdapter, db, getDefaultDbAdapter, setDefaultDbAdapter } from "./db";
export type { DbAdapter, DbConfig, DbMessage, DbRun, DbTrace } from "./db";
export { MemoryPostgresAdapter, NeonPostgresAdapter, PostgresSessionStorage, createMemoryPostgres, createNeonPostgres, createPostgresSessionStorage } from "./db/postgres";
export { MemoryRedisAdapter, UpstashRedisAdapter, createMemoryRedis, createUpstashRedis } from "./db/redis";
export { SQLiteAdapter, createSQLiteAdapter } from "./db/sqlite";
export { // Functions
// NB: the unaliased registerDisplayCallback name belongs to the per-type
// callback registry (callbacks module, // Types
type DisplayCallback, DisplayFlow, DisplayFlowConfig, asyncDisplayCallbacks, async_display_callbacks, clearDisplayCallbacks, clearErrorLogs, displayError, displayGenerating, displayInstruction, displayInteraction, displaySelfReflection, displayToolCall, display_error, display_generating, display_instruction, display_interaction, display_self_reflection, display_tool_call, errorLogs, error_logs, logError, registerDisplay, register_display_callback, syncDisplayCallbacks, sync_display_callbacks } from "./display";
export { // Types
type DisplayCallback, DisplayFlow, DisplayFlowConfig, asyncDisplayCallbacks, clearDisplayCallbacks, clearErrorLogs, displayError, displayGenerating, displayInstruction, displayInteraction, displaySelfReflection, displayToolCall, errorLogs, logError, registerDisplay, syncDisplayCallbacks } from "./display";
export { // Functions
embed, // Types
type EmbeddingResult, aembed, aembedding, aembeddings, cosineSimilarity, embedding, embeddings, euclideanDistance, getDimensions, get_dimensions, normalizeEmbedding, setEmbeddingConfig } from "./embeddings";
export { AGENT_ERROR_KINDS, FailoverDecision, IdleTimeoutBreaker, LEGACY_ERROR_CATEGORY_MAP, LLMError, NetworkError, PraisonAIConfigError, PraisonAIError, ToolExecutionError, ValidationError, isAgentErrorKind, isErrorContext, resolveErrorCategory } from "./errors";
export type { AgentErrorKind, ErrorContextProtocol, FailoverAction, FailoverDecisionOptions, LLMErrorOptions, LegacyErrorCategory, NetworkErrorOptions, PraisonAIConfigErrorOptions, PraisonAIErrorOptions, ToolExecutionErrorOptions, ValidationErrorOptions } from "./errors";
type EmbeddingResult, aembed, aembedding, aembeddings, cosineSimilarity, embedding, embeddings, euclideanDistance, getDimensions, normalizeEmbedding, setEmbeddingConfig } from "./embeddings";
export { // LLM-as-Judge
Judge, AccuracyJudge, CriteriaJudge, EvalResults, EvalSuite, Evaluator, RecipeJudge, accuracyEval, addJudge, addOptimizationRule, containsKeywordsCriterion, createDefaultEvaluator, createEvalResults, createEvaluator, getJudge, getOptimizationRule, lengthCriterion, listJudges, listOptimizationRules, noHarmfulContentCriterion, parseJudgeResponse, performanceEval, relevanceCriterion, reliabilityEval, removeJudge, removeOptimizationRule } from "./eval";
export { AgentEventBus, AgentEvents, EventEmitterPubSub, PubSub, createEventBus, createPubSub } from "./events";
@@ -1010,7 +998,7 @@ export { // Bot types
FailoverManager, // Enums
SandboxStatus, // Gateway types
type GatewayConfig, // Other types
type ProviderStatus, AutonomyLevel, GatewayEventType, RagRetrievalPolicy } from "./gateway";
type ProviderStatus, AutonomyLevel, RagRetrievalPolicy } from "./gateway";
export { LLMGuardrail, createLLMGuardrail } from "./guardrails/llm-guardrail";
export { DisplayTypes, HooksManager, WorkflowHooksExecutor, clearAllCallbacks, clearApprovalCallback, createHooksManager, createLoggingOperationHooks, createLoggingWorkflowHooks, createTimingWorkflowHooks, createValidationOperationHooks, createWorkflowHooks, executeCallback, executeSyncCallback, getRegisteredDisplayTypes, hasApprovalCallback, registerApprovalCallback, registerDisplayCallback, requestApproval, unregisterDisplayCallback } from "./hooks";
export { // Computer Use
@@ -1022,11 +1010,7 @@ export { // Slack
createSlackBot, SlackBot, parseSlackMessage, verifySlackSignature } from "./integrations/slack";
export { BaseVectorStore, ChromaVectorStore, MemoryVectorStore, PineconeVectorStore, QdrantVectorStore, VectorQueryResult, WeaviateVectorStore, createChromaStore, createMemoryVectorStore, createPineconeStore, createQdrantStore, createWeaviateStore } from "./integrations/vector";
export { BaseVoiceProvider, ElevenLabsVoiceProvider, OpenAIVoiceProvider, createElevenLabsVoice, createOpenAIVoice } from "./integrations/voice";
export type { KnowledgeRecord } from "./knowledge";
export { GraphRAG, GraphStore, createGraphRAG } from "./knowledge/graph-rag";
export { Knowledge } from "./knowledge/knowledge";
export type { KnowledgeIndexOptions, KnowledgeSearchCallOptions, KnowledgeStoreConfig, KnowledgeStoreOptions } from "./knowledge/knowledge";
export { KnowledgeBase, KnowledgeDocument, KnowledgeSearchResult, createKnowledgeBase } from "./knowledge/rag";
export { BaseReranker, CohereReranker, CrossEncoderReranker, LLMReranker, createCohereReranker, createCrossEncoderReranker, createLLMReranker } from "./knowledge/reranker";
export { // Provider classes
OpenAIProvider, // Provider factory and utilities
@@ -1034,22 +1018,14 @@ export { // Provider classes
ProviderRegistry, // Types
type LLMProvider, AnthropicProvider, BaseProvider, GoogleProvider, ProviderMessage, ProviderToolDefinition, createProviderRegistry, getAvailableProviders, getDefaultProvider, getDefaultRegistry, hasProvider, isProviderAvailable, listProviders, parseModelString, registerBuiltinProviders, registerProvider, unregisterProvider } from "./llm/providers";
export { ADAPTERS, AISDK_PROVIDERS, COMMUNITY_PROVIDERS, PROVIDER_ALIASES } from "./llm/providers/ai-sdk/types";
export { AgentMessageEvent, CustomToolUseEvent, ManagedEvent, SessionErrorEvent, SessionIdleEvent, SessionRunningEvent, ToolConfirmationEvent, ToolUseEvent, UsageEvent, isManagedBackend } from "./managed";
export type { AgentMessageEventInit, CustomToolUseEventInit, ManagedBackendKwargs, ManagedBackendProtocol, ManagedContentBlock, ManagedEventInit, ManagedEventType, ManagedStopReason, SessionErrorEventInit, SessionIdleEventInit, ToolConfirmationEventInit, ToolUseEventInit, UsageEventInit } from "./managed";
export { MCPClient, MCPSecurity, MCPServer, MCPSessionManager, createApiKeyPolicy, createMCPClient, createMCPSecurity, createMCPServer, createMCPSession, createRateLimitPolicy, getMCPTools } from "./mcp";
export { ChromaMemory, InMemoryAdapter, MEMORY_PROVIDER_ALIASES, MemoryAdapterRegistry, addMemoryAdapter, addMemoryFactory, add_memory_adapter, add_memory_factory, createChromaMemoryAdapter, createDakeraMemoryAdapter, createMem0MemoryAdapter, createMongodbMemoryAdapter, createSqliteMemoryAdapter, getDefaultMemoryRegistry, getFirstAvailableMemoryAdapter, getMemoryAdapter, get_memory_adapter, hasMemoryAdapter, has_memory_adapter, listMemoryAdapters, list_memory_adapters, registerMemoryAdapter, registerMemoryFactory, register_memory_adapter, register_memory_factory, resetDefaultMemoryRegistry, resolveMemoryAdapterName, sanitizeChromaMetadata } from "./memory/adapters";
export type { ChromaMemoryConfig, MaybePromise, MemoryEmbedder, MemoryProtocol, MemoryRecord } from "./memory/adapters";
export { AutoMemory, AutoMemoryKnowledgeBase, AutoMemoryVectorStore, DEFAULT_POLICIES, createAutoMemory, createLLMSummarizer } from "./memory/auto-memory";
export { DocsManager, createDocsManager } from "./memory/docs-manager";
export { FileMemory, createFileMemory } from "./memory/file-memory";
export { MemoryHooks, createEncryptionHooks, createLoggingHooks, createMemoryHooks, createValidationHooks } from "./memory/hooks";
export { BaseStore, DecisionStore, FeedbackStore, ImprovementStore, InsightStore, LearnBackendNotAvailableError, LearnEntry, LearnError, LearnManager, LearnManagerMode, MongoDBLearnBackend, PatternStore, PersonaStore, RedisLearnBackend, SQLiteLearnBackend, ThreadStore, getDataDir, getLearnDir, resolveLearnConfig, toLearnEntryDict } from "./memory/learn";
export type { AsyncLearnProtocol, BaseStoreOptions, LearnEntryConvertible, LearnEntryData, LearnEntryInit, LearnEntryLike, LearnExtractor, LearnManagerConfig, LearnManagerProtocol, LearnMessage, LearnProtocol, LearnStorageBackend, LearnStore, ProcessConversationResult, ResolvedLearnConfig } from "./memory/learn";
export { Memory, createMemory } from "./memory/memory";
export type { MemoryConfig, MemoryEntry } from "./memory/memory";
export { RulesManager, createRulesManager, createSafetyRules } from "./memory/rules-manager";
export { DEFAULT_PROFILE, HarnessProfile, listHarnessProfiles, registerProfile, register_profile, resetHarnessRegistry, resolveHarness, resolve_harness } from "./model-harness";
export type { HarnessProfileConfig, HarnessRegistryEntry, HarnessResolverProtocol } from "./model-harness";
export { // Adapters
NoopObservabilityAdapter, // Constants
OBSERVABILITY_TOOLS, // Global adapter management
@@ -1057,6 +1033,15 @@ export { // Adapters
type SpanKind, ConsoleObservabilityAdapter, MemoryObservabilityAdapter, clearAdapterCache, createConsoleAdapter, createMemoryAdapter, createObservabilityAdapter, getObservabilityAdapter, getObservabilityToolInfo, hasObservabilityToolEnvVar, listObservabilityTools, noopAdapter, resetObservabilityAdapter, trace } from "./observability";
export { AgentApp, AgentAppConfig, AgentAppProtocol, AgentOS, AgentOSConfig, AgentOSProtocol, DEFAULT_AGENTOS_CONFIG, mergeConfig } from "./os";
export type { AgentAppOptions, AgentOSOptions } from "./os";
export { // P0: Call Types (new)
type MCPCall, // P0: Specialized Agent Configs (new)
type AudioConfig, // P0: Specialized Agents (new)
CodeAgent, // P1: Workflow Patterns (new)
Chunking, // P2: Context Types (new - only items not already exported)
ContextManager, // P3: Display callbacks (snake_case for Python parity)
register_display_callback, // P3: Plugin functions
get_plugin_manager, // P3: Trace & condition functions
evaluate_condition, EmbeddingAgent, If, Knowledge, MCP, OCRAgent, Parallel, RealtimeAgent, Route, Session, VideoAgent, VisionAgent, async_display_callbacks, cleanup_telemetry_resources, create_context_agent, disable_performance_mode, disable_telemetry, discover_and_load_plugins, discover_plugins, display_error, display_generating, display_instruction, display_interaction, display_self_reflection, display_tool_call, enable_performance_mode, enable_telemetry, ensure_plugin_dir, error_logs, get_default_plugin_dirs, get_dimensions, get_plugin_template, get_telemetry, handoff_filters, load_plugin, parse_plugin_header, parse_plugin_header_from_file, prompt_with_handoff_instructions, resolve_guardrail_policies, sync_display_callbacks, trace_context, track_workflow, when } from "./parity";
export { // Core classes
Plan, // Python parity additions
ApprovalCallback, PlanStep, PlanStorage, PlanningAgent, READ_ONLY_TOOLS, RESEARCH_TOOLS, RESTRICTED_TOOLS, TaskAgent, TodoItem, TodoList, createApprovalCallback, createPlan, createPlanStorage, createPlanningAgent, createTaskAgent, createTodoList } from "./planning";
@@ -1064,51 +1049,36 @@ export { // Classes
Plugin, // Enums
PluginHook, // Functions
getPluginManager, // Interfaces
type PluginMetadata, FunctionPlugin, PluginManager, PluginParseError, PluginType, disablePlugins, discoverAndLoadPlugins, discoverPlugins, discover_and_load_plugins, discover_plugins, enablePlugins, ensurePluginDir, ensure_plugin_dir, getDefaultPluginDirs, getPluginTemplate, get_default_plugin_dirs, get_plugin_manager, get_plugin_template, isPluginEnabled, listPlugins, loadPlugin, load_plugin, parsePluginHeader, parsePluginHeaderFromFile, parse_plugin_header, parse_plugin_header_from_file } from "./plugins";
type PluginMetadata, FunctionPlugin, PluginManager, PluginParseError, PluginType, disablePlugins, discoverAndLoadPlugins, discoverPlugins, enablePlugins, ensurePluginDir, getDefaultPluginDirs, getPluginTemplate, isPluginEnabled, listPlugins, loadPlugin, parsePluginHeader, parsePluginHeaderFromFile } from "./plugins";
export { // A2A Protocol
A2ATaskState, // AGUI Protocol
AGUI, // AgentManager alias type
type AgentManager, // Global singletons
config, // Guardrail policies
type GuardrailPolicy, // Tools class
type ToolDefinition, A2A, A2ARole, AutoRagAgent, AutoRetrievalPolicy, DEFAULT_AUTO_KEYWORDS, GUARDRAIL_POLICY_PRESETS, Tools, memory, obs, resolveGuardrailPolicies, resolve_guardrail_policies, workflows } from "./protocols";
type ToolDefinition, A2A, A2ARole, AutoRagAgent, AutoRetrievalPolicy, DEFAULT_AUTO_KEYWORDS, GUARDRAIL_POLICY_PRESETS, Tools, memory, obs, resolveGuardrailPolicies, workflows } from "./protocols";
export { CitationsMode, DEFAULT_RAG_TEMPLATE, RAG, RAGCitation, RAGContextPack, RetrievalPolicy, RetrievalStrategy, createCitation, createContextPack, createRAG, createRAGConfig, createRAGResult, createRetrievalConfig, createSimpleRetrievalConfig, createSmartRetrievalConfig, formatAnswerWithCitations, formatCitation, formatContextPackForPrompt, hasCitations } from "./rag";
export { PraisonAIRuntime, RuntimeRegistry, RuntimeRegistryEntry, RuntimeRegistryError, addRuntimeAlias, getRuntimeRegistry, isAgentRuntime, isRuntimeAvailable, listRuntimes, list_runtimes, registerRuntime, register_runtime, resolveRuntime, resolve_runtime, unregisterRuntime } from "./runtime";
export type { AgentRuntimeProtocol, RunTurnOptions, RuntimeCapabilityMatrix, RuntimeConfig, RuntimeDelta, RuntimeDeltaType, RuntimeFactory, RuntimeMode, RuntimeRegistryEntryInit, RuntimeRegistryProtocol, RuntimeResult, TurnContextBuilderProtocol, TurnRuntimeProtocol } from "./runtime";
export type { RunStatus } from "./session";
export { Session } from "./session/session";
export { // Python parity additions
SkillLoader, EnforcementLevel, SkillManager, SkillParseError, SkillState, createSkillLoader, createSkillManager, createSkillProperties, discoverSkill, discoverSkills, discover_skills, findSkillMd, getDefaultSkillDirs, loadSkill, load_skill, parseFrontmatter, parseSkillFile, validate, validateMetadata, validateSkill, validate_metadata } from "./skills";
export type { RemoteSkillSource } from "./skills";
SkillLoader, SkillManager, createSkillLoader, createSkillManager, createSkillProperties, parseSkillFile } from "./skills";
export { AgentTask, AgentTaskConfig, BaseTask, createTaskOutput } from "./task";
export { // Python parity additions
MinimalTelemetry, AgentTelemetry, PerformanceMonitor, TelemetryCollector, TelemetryIntegration, cleanupTelemetry, cleanupTelemetryResources, cleanup_telemetry_resources, createAgentTelemetry, createConsoleSink, createHTTPSink, createPerformanceMonitor, createTelemetryIntegration, disablePerformanceMode, disableTelemetry, disable_performance_mode, disable_telemetry, enablePerformanceMode, enableTelemetry, enable_performance_mode, enable_telemetry, getMinimalTelemetry, getTelemetry, get_telemetry } from "./telemetry";
MinimalTelemetry, AgentTelemetry, PerformanceMonitor, TelemetryCollector, TelemetryIntegration, cleanupTelemetry, cleanupTelemetryResources, createAgentTelemetry, createConsoleSink, createHTTPSink, createPerformanceMonitor, createTelemetryIntegration, disablePerformanceMode, disableTelemetry, enablePerformanceMode, enableTelemetry, getMinimalTelemetry, getTelemetry } from "./telemetry";
export { // Subagent Tool (agent-as-tool pattern)
SubagentTool, BaseTool, FunctionTool, TOOL_TRUST_LEVELS, ToolRegistry, ToolResult, ToolValidationError, add_tool, createDelegator, createSubagentTool, createSubagentTools, createTool, getRegistry, getTool, getToolDefinitions, get_registry, get_tool, get_tool_definitions, hasTool, has_tool, listTools, list_tools, registerTool, register_tool, removeTool, remove_tool, tool, validateTool, validate_tool } from "./tools";
SubagentTool, BaseTool, FunctionTool, ToolRegistry, ToolResult, ToolValidationError, createDelegator, createSubagentTool, createSubagentTools, createTool, getRegistry, getTool, registerTool, tool, validateTool } from "./tools";
export { airweaveSearch, bedrockBrowserClick, bedrockBrowserFill, bedrockBrowserNavigate, bedrockCodeInterpreter, codeExecution, codeMode, createCustomTool, crwCrawl, crwScrape, exaSearch, firecrawlCrawl, firecrawlScrape, parallelSearch, perplexitySearch, registerCustomTool, registerLocalTool, registerNpmTool, superagentGuard, superagentRedact, superagentVerify, tavilyCrawl, tavilyExtract, tavilySearch, valyuBioSearch, valyuCompanyResearch, valyuEconomicsSearch, valyuFinanceSearch, valyuPaperSearch, valyuPatentSearch, valyuSecSearch, valyuWebSearch } from "./tools/builtins";
export { MCP, MCPToolTransportType } from "./tools/mcp";
export { BudgetExceededError, MissingDependencyError, MissingEnvVarError, ToolConstructionError, ToolNotRegisteredError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolInstance, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getToolsRegistry, registerToolFactory, resetToolsRegistry, tryCreateToolInstance, validateToolInstall } from "./tools/registry";
export { BudgetExceededError, MissingDependencyError, MissingEnvVarError, ToolsRegistry, composeMiddleware, createLoggingMiddleware, createRateLimitMiddleware, createRedactionMiddleware, createRetryMiddleware, createTimeoutMiddleware, createToolsRegistry, createTracingMiddleware, createValidationMiddleware, getToolsRegistry, get_registry, get_tool, register_tool, resetToolsRegistry, validate_tool } from "./tools/registry";
export type { InstallHints, PraisonTool, RedactionHooks, RegisteredTool, ToolCapabilities, ToolExecutionContext, ToolExecutionResult, ToolFactory, ToolHooks, ToolInstallStatus, ToolLimits, ToolLogger, ToolMetadata, ToolMiddleware, ToolParameterProperty, ToolParameterSchema } from "./tools/registry";
export { registerBuiltinTools, tools } from "./tools/tools";
export { TOOLSET_TOOL_ID_MAP, ToolsetRegistry, ToolsetSpec, getToolset, getToolsetRegistry, get_toolset, get_toolset_registry, hasToolset, has_toolset, listToolsets, list_toolsets, registerToolset, register_toolset, resolveToolset, resolveToolsetBuiltinIds, resolveToolsets, resolveToolsetsForModel, resolve_toolset, resolve_toolsets, resolve_toolsets_for_model, toolsetToolId, unregisterToolset, unregister_toolset } from "./toolsets";
export type { ToolsetSpecConfig } from "./toolsets";
export { // Classes
TraceSink, // Enums
ContextEventType, // Functions
createContextEvent, // Types
type ContextEvent, ContextListSink, ContextNoOpSink, ContextTraceEmitter, ContextTraceSink, EventType, MessageType, TraceCtx, traceContext, trace_context, trackWorkflow, track_workflow } from "./trace";
export { A2UI, A2UINotInstalledError, A2UI_MIME_TYPE, createA2uiPart, getSchemaManager, isA2uiPart, parseA2uiResponse } from "./ui/a2ui";
export type { A2UIAdapter, A2UISystemPromptOptions, A2UIToolResultProtocol } from "./ui/a2ui";
export { AdapterBackendNotAvailableError, AdapterCreationError, AdapterRegistry, isBackendUnavailableError } from "./utils/adapter-registry";
export type { AdapterClass, AdapterFactory, AdapterKwargs } from "./utils/adapter-registry";
export { Logger, PraisonLogger, ROOT_LOGGER_NAME, StructuredFormatter, configureStructuredLogging, configure_structured_logging, getLogger, get_logger, normalizeLoggerName } from "./utils/logger";
export type { GetLoggerOptions, LogFormatter, LogLevelName, LogRecord, StructuredFormatterOptions } from "./utils/logger";
export { VERSION, __version__, getVersion } from "./version";
type ContextEvent, ContextListSink, ContextNoOpSink, ContextTraceEmitter, ContextTraceSink, EventType, MessageType, TraceCtx, traceContext, trackWorkflow } from "./trace";
export { // New: Python-parity Loop and Repeat classes
Loop, // Task class
Task, AgentFlow, DEFAULT_MAX_PARALLEL_WORKERS, If, Include, MAX_NESTING_DEPTH, NestingDepthError, PARALLEL_ON_FAILURE_MODES, Parallel, Pipeline, Repeat, Route, Workflow, WorkflowStepError, evaluateWorkflowCondition, getRecipeResolver, ifStep, if_, include, isControlFlowPattern, loop, loopPattern, parallel, parallelPattern, repeat, repeatPattern, route, routePattern, setRecipeResolver, substituteWorkflowVariables, when } from "./workflows";
export type { AgentLikeStep, FlowStep, IncludableWorkflow, LoopConfig, LoopResult, ParallelBranchError, ParallelOnFailure, RecipeResolver, RepeatConfig, RepeatContext, RepeatResult, StepContextConfig, StepExecutionConfig, StepOutputConfig, StepResult, StepRoutingConfig, TaskConfig, WorkflowContext } from "./workflows";
export { YAMLWorkflowParser, createWorkflowFromYAML, loadWorkflowFromFile, parseYAMLWorkflow, validateWorkflowDefinition } from "./workflows/yaml-parser";
Task, AgentFlow, Pipeline, Repeat, Workflow, loop, loopPattern, parallel, repeat, repeatPattern, route } from "./workflows";
export type { LoopConfig, LoopResult, RepeatConfig, RepeatContext, RepeatResult, StepContextConfig, StepExecutionConfig, StepOutputConfig, StepResult, StepRoutingConfig, TaskConfig, WorkflowContext } from "./workflows";
export { createWorkflowFromYAML, loadWorkflowFromFile, parseYAMLWorkflow, validateWorkflowDefinition } from "./workflows/yaml-parser";
```
# Optional Plugins
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.6" \
"praisonai>=4.7.1" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.6" \
"praisonai>=4.7.1" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.6" \
"praisonai>=4.7.1" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -1,73 +0,0 @@
"""
Meeting Notes Agent
An AI meeting-notes pipeline composed entirely from existing PraisonAI
primitives — no new SDK surface required:
1. AudioAgent.transcribe() -> Whisper speech-to-text
2. Agent (summary) -> structured summary + action items
3. Agent (knowledge=[...]) -> index transcript for cross-meeting Q&A
Usage:
python meeting-notes-agent.py path/to/recording.mp3
Requires: OPENAI_API_KEY (or any LiteLLM-supported STT/LLM provider).
"""
import sys
from praisonaiagents import Agent, AudioAgent
def transcribe_meeting(audio_path: str) -> str:
"""Speech-to-text using the built-in Whisper AudioAgent."""
stt = AudioAgent(llm="openai/whisper-1")
return stt.transcribe(audio_path)
def summarize_meeting(transcript: str) -> str:
"""Summarize decisions, topics, and action items."""
notetaker = Agent(
name="Meeting Notetaker",
instructions=(
"You are an expert meeting notetaker. Given a raw transcript, produce:\n"
"1. A concise summary (3-5 sentences).\n"
"2. Key decisions made.\n"
"3. Action items as a checklist with owners when mentioned.\n"
"Only include action items that were actually spoken."
),
)
return notetaker.start(f"Transcript:\n\n{transcript}")
def build_qa_agent(transcript_path: str) -> Agent:
"""Index the transcript for semantic search and cross-meeting Q&A."""
return Agent(
name="Meeting Q&A",
instructions="Answer questions using only the indexed meeting transcripts. Cite what was said.",
knowledge=[transcript_path],
)
def main() -> None:
if len(sys.argv) < 2:
print("Usage: python meeting-notes-agent.py <audio_file>")
sys.exit(1)
audio_path = sys.argv[1]
transcript = transcribe_meeting(audio_path)
transcript_path = "meeting_transcript.txt"
with open(transcript_path, "w", encoding="utf-8") as f:
f.write(transcript)
notes = summarize_meeting(transcript)
print("\n=== Meeting Notes ===\n")
print(notes)
qa = build_qa_agent(transcript_path)
print("\n=== Q&A ===\n")
print(qa.start("What were the main action items and who owns them?"))
if __name__ == "__main__":
main()
+1 -1
View File
@@ -15,4 +15,4 @@ if completed.status == "completed":
# List all videos
videos = agent.list()
# Models: openai/sora-2, gemini/veo-3.1-generate-preview, azure/sora-2
# Models: openai/sora-2, gemini/veo-3.0-generate-preview, azure/sora-2
-7
View File
@@ -2,13 +2,6 @@
> **For AI Agents and Developers**: This document provides the complete context needed to work with the PraisonAI ecosystem, including design principles, architecture, repository structure, and implementation guidelines.
> **Active workstream — local model runtimes.** If your task touches local inference
> (Ollama, LM Studio, llama.cpp, vLLM, MLX, `transformers serve`), provider detection,
> `llm/adapters/`, or the `praisonaiagents/local/` package, read
> [`docs/local-model-layer/README.md`](docs/local-model-layer/README.md) **before editing**.
> It carries the measured ground truth, the invariants, and a file-ownership table that
> prevents concurrent work orders from corrupting each other.
---
## 1. What is PraisonAI?
+43 -43
View File
@@ -18,77 +18,77 @@ This report compares **Python SDK feature categories** against **Python document
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 511 |
| ✅ Agent | 22 | 57 | 18177 |
| ✅ Agent | 22 | 54 | 17350 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 701 |
| ✅ Approval | 1 | 7 | 3616 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
| ✅ Approval | 1 | 6 | 3071 |
| ✅ Audio | 2 | 12 | 1000 |
| ✅ Auto Generation | 5 | 12 | 3879 |
| ✅ Autonomy | 3 | 7 | 2794 |
| ✅ Bots | 7 | 33 | 13928 |
| ✅ Auto Generation | 5 | 12 | 3747 |
| ✅ Autonomy | 3 | 7 | 2790 |
| ✅ Bots | 7 | 32 | 13622 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 123 | 38201 |
| ✅ Chunking | 2 | 2 | 424 |
| ✅ CLI | 5 | 122 | 37626 |
| ✅ Chunking | 2 | 2 | 420 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 2 | 14 | 4855 |
| ✅ Conditions | 1 | 3 | 1386 |
| ✅ Configuration | 3 | 8 | 3323 |
| ✅ Context Management | 16 | 36 | 14104 |
| ✅ Conditions | 1 | 3 | 1347 |
| ✅ Configuration | 3 | 8 | 3292 |
| ✅ Context Management | 16 | 36 | 14071 |
| ✅ Deep Research | 8 | 2 | 587 |
| ✅ Display | 6 | 3 | 844 |
| ✅ Embeddings | 6 | 23 | 2051 |
| ✅ Evaluation | 1 | 8 | 3220 |
| ✅ Display | 6 | 3 | 741 |
| ✅ Embeddings | 6 | 23 | 2049 |
| ✅ Evaluation | 1 | 7 | 2920 |
| ✅ Events | 1 | 2 | 767 |
| ✅ Execution | 3 | 4 | 1631 |
| ✅ Failover | 2 | 1 | 480 |
| ✅ Files | 2 | 7 | 2376 |
| ✅ Execution | 3 | 4 | 1606 |
| ✅ Failover | 2 | 1 | 452 |
| ✅ Files | 2 | 7 | 2275 |
| ✅ Flow | 1 | 3 | 788 |
| ✅ Gateway | 7 | 86 | 32228 |
| ✅ Guardrails | 4 | 4 | 2388 |
| ✅ Handoffs | 11 | 6 | 2718 |
| ✅ Hooks | 2 | 9 | 4690 |
| ✅ Gateway | 7 | 83 | 30723 |
| ✅ Guardrails | 4 | 4 | 2253 |
| ✅ Handoffs | 11 | 6 | 2626 |
| ✅ Hooks | 2 | 9 | 4542 |
| ✅ Image | 1 | 11 | 1255 |
| ✅ Knowledge | 4 | 18 | 5562 |
| ✅ LLM | 3 | 15 | 5917 |
| ✅ Knowledge | 4 | 17 | 5226 |
| ✅ LLM | 3 | 15 | 5395 |
| ✅ Loops | 4 | 5 | 1475 |
| ✅ MCP | 1 | 62 | 14489 |
| ✅ Memory | 6 | 19 | 7588 |
| ✅ MCP | 1 | 62 | 14418 |
| ✅ Memory | 6 | 18 | 7237 |
| ✅ OCR | 2 | 1 | 237 |
| ✅ Observability | 2 | 23 | 2815 |
| ✅ Observability | 2 | 23 | 2811 |
| ✅ Optimizer | 1 | 2 | 792 |
| ✅ Output | 3 | 5 | 1273 |
| ✅ Parallel Execution | 3 | 2 | 497 |
| ✅ Planning | 6 | 7 | 1825 |
| ✅ Plugins | 8 | 5 | 3124 |
| ✅ Prompts | 2 | 9 | 2018 |
| ✅ Providers | 1 | 57 | 8636 |
| ✅ Plugins | 8 | 5 | 3120 |
| ✅ Prompts | 2 | 9 | 1971 |
| ✅ Providers | 1 | 56 | 7793 |
| ✅ Query | 1 | 2 | 736 |
| ✅ RAG | 5 | 15 | 3122 |
| ✅ RAG | 5 | 15 | 3118 |
| ✅ Realtime | 2 | 5 | 668 |
| ✅ Reflection | 3 | 3 | 732 |
| ✅ Retrieval | 2 | 5 | 1042 |
| ✅ Routing | 1 | 2 | 430 |
| ✅ Sandbox | 5 | 7 | 3670 |
| ✅ Security | 1 | 3 | 3558 |
| ✅ Sessions | 4 | 20 | 7898 |
| ✅ Skills | 6 | 16 | 5984 |
| ✅ Tasks | 2 | 8 | 3231 |
| ✅ Telemetry | 1 | 2 | 660 |
| ✅ Templates | 1 | 8 | 1667 |
| ✅ Tools | 12 | 145 | 44258 |
| ✅ Sandbox | 5 | 7 | 3556 |
| ✅ Security | 1 | 3 | 3529 |
| ✅ Sessions | 4 | 19 | 7644 |
| ✅ Skills | 6 | 16 | 5903 |
| ✅ Tasks | 2 | 6 | 2897 |
| ✅ Telemetry | 1 | 2 | 648 |
| ✅ Templates | 1 | 8 | 1663 |
| ✅ Tools | 12 | 144 | 43629 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Vector Store | 1 | 12 | 1219 |
| ✅ Video | 2 | 6 | 728 |
| ✅ Video | 2 | 6 | 649 |
| ✅ Vision | 2 | 1 | 329 |
| ✅ Web | 3 | 10 | 2791 |
| ✅ Workflows | 5 | 18 | 7446 |
| ✅ Web | 3 | 9 | 2487 |
| ✅ Workflows | 5 | 18 | 7406 |
## Documentation Without Features
These docs exist but don't match any implemented feature category:
- ℹ️ Database (43 docs, 7573 lines)
- ℹ️ Documents (1 docs, 810 lines)
- ℹ️ Database (42 docs, 6973 lines)
- ℹ️ Documents (1 docs, 798 lines)
---
@@ -1,331 +0,0 @@
# Ground truth
Every claim here was measured, not estimated. Each carries **how** it was obtained so it can
be re-checked cheaply. Measured 2026-09-03 against `origin/main` at `2591aa405`, with a live
Ollama 0.33.2 (`b79067b0`) on `127.0.0.1:11434`.
If a work order contradicts something here, re-measure before acting. Numbers drift.
> **Capabilities are per-model, not per-server.** An early draft of this file attributed the
> 9B model's capability list to `qwen3:0.6b`. Re-verified 2026-09-03: `qwen3:0.6b` is
> `["completion","tools","thinking"]` — **no vision**. Always name the model when quoting a
> capability set.
---
## 1. Runtime facts, verified live
| Fact | Value | How to re-verify |
|---|---|---|
| Ollama liveness probe | `GET /` returns `200` with body `Ollama is running` | `curl -sI 127.0.0.1:11434/` |
| Ollama version probe | `{"version":"0.33.2"}` | `curl -s 127.0.0.1:11434/api/version` |
| Capability probe works | `qwen3:0.6b` → `["completion","tools","thinking"]` (the 9B tamil model, separately, → `[...,"vision"]`) | `curl -s :11434/api/show -d '{"model":"qwen3:0.6b"}' \| jq .capabilities` |
| Unknown `options` keys are silently dropped | `{"totally_bogus_key":123}` → HTTP 200, no warning | send it to `/api/chat`; response is a normal completion |
| Tool-param schema keywords are stripped | `minLength`, `format`, `default`, `additionalProperties` all absent from the rendered prompt | `/api/chat` with `_debug_render_only: true` and those keys set |
| `parameters.type` must be a bare string | `"type": ["object","null"]` → **HTTP 400** `cannot unmarshal array into Go struct field ToolFunctionParameters...type of type string` | send that tool definition to `/api/chat` |
| **`format` + `tools` are mutually destructive** | Tool call silently suppressed; model fabricates. Same request without `format` calls the tool correctly. | see §2 below |
### Installed on this machine
`ollama` (0.33.2), `llama-server` (b7620, Homebrew), `mlx_lm.server`, `hf` CLI.
**Not** installed: `lms` (LM Studio), `vllm`.
Ports open at time of measurement: `11434` only.
---
## 2. The reproduction that matters most
`format` (JSON schema) plus `tools`, sent to Ollama, produces a **confident fabrication** with
an HTTP 200 and no warning. The JSON grammar makes the tool-call tag unemittable, so the
model cannot call the tool and answers from nothing instead.
```
POST /api/chat model=qwen3:0.6b think=false
prompt: "What is the weather in Paris? Use the tool."
tools: [get_weather(city: string)]
WITH format + tools -> HTTP 200
message.tool_calls : null
message.content : {"answer": "The weather in Paris is sunny with a temperature of 15C."}
TOOLS ONLY (format removed) -> HTTP 200
message.tool_calls : [{"function": {"name": "get_weather",
"arguments": {"city": "Paris"}}}]
message.content : ""
```
Consequence for design: this combination must **raise**, not be silently repaired. A
fabricated answer that looks correct is worse than an error.
---
## 3. Codebase measurements
All obtained by script over `praisonaiagents/` at `2591aa405`.
### Scale
| Metric | Value |
|---|---|
| Python files / lines | 595 / 238,936 |
| Files over 2,000 lines | 13 |
| `agent/agent.py` | 7,701 |
| `llm/llm.py` | 7,160 |
| `agent/chat_mixin.py` | 6,151 |
| `llm/openai_client.py` | 2,691 |
| `llm/adapters/__init__.py` | 340 |
### `LLM` class cohesion
95 methods, 6,779 lines. Three largest methods total 3,087 lines — 43% of the file.
| Lines | Location | Method | Branch/loop/try nodes |
|---|---|---|---|
| 1,675 | `llm.py:2557` | `get_response` | **194** |
| 969 | `llm.py:4706` | `get_response_async` | 115 |
| 443 | `llm.py:4233` | `get_response_stream` | 56 |
| 249 | `llm.py:5966` | `_build_completion_params` | 52 |
| 199 | `llm.py:413` | `__init__` | 10 |
### Adapter call sites — the A1 finding
`DefaultAdapter` declares **17 methods** (an earlier draft said 18; the 18th item,
`add_provider_adapter`, is a module-level function). Eleven methods have **zero** call sites
anywhere, and with the three module functions that is 12 dead items. Note also that
`LLMProviderAdapterProtocol` declares only **16** — it omits `get_streaming_adapter`, so that
method is not part of the protocol it claims to implement.
```
supports_streaming 2
format_tool_result_message 1 (llm.py:1712)
get_default_settings 1 (llm.py:690)
should_summarize_tools 1
supports_prompt_caching 1
supports_streaming_with_tools 1
--- zero call sites ---
recover_tool_calls_from_text 0 (inline copy at llm.py:3468-3500)
parse_tool_calls 0
handle_empty_response_with_tools 0
post_tool_iteration 0
format_tools 0
get_streaming_adapter 0
get_max_iteration_threshold 0
should_skip_streaming_with_tools 0
supports_structured_output 0
inject_cache_control 0
extract_reasoning_tokens 0
add_provider_adapter 0 (public registration hook)
```
Ollama references (case-insensitive): `llm/llm.py` **145**, `llm/adapters/__init__.py` 19,
`llm/streaming_protocol.py` 11, `tools/call_executor.py` 6.
### Compensation coverage across the three response paths — the A2 finding
```
get_response _async _stream
1675 L 969 L 443 L
tool_result_mapping (value fix-up) 8 0 0
max_tool_repairs (repair loop) 2 0 0
force_tool_usage (prompt inject) 1 0 0
_validate_and_filter_ollama_arguments 1 1 0
_generate_ollama_tool_summary 1 1 0
_handle_ollama_sequential_logic 1 1 0
_format_ollama_tool_result_message 1 1 1* (indirect)
```
`get_response_stream` has none of the seven — except `_format_ollama_tool_result_message`,
which it reaches indirectly via `_create_tool_message` (`llm.py:6791`), so that row overstated
the gap. A fuller re-measurement found **16** divergent compensations, not 7; three of them run
*against* the sync path. The full matrix is in `06-adapter-revival.md` §2.6. `difflib` similarity of `get_response` against
`get_response_async` is **22.3%**, while the file's other four sync/async pairs are 76–91% —
so those two have diverged, not merely duplicated.
### Import graph — the I6 constraint
65 subpackages, 212 directed edges, **26 mutually-importing pairs**. Heaviest:
```
agent <-> tools 53 + 1 llm/ imports: tools 10, streaming 7,
agent <-> approval 28 + 1 agent 4, telemetry 2,
agent <-> config 25 + 1 auth 2, compaction 2,
agent <-> llm 19 + 4 context 1, thinking 1
llm <-> tools 10 + 1
context <-> llm 2 + 1 15 subpackages import llm/
```
### Reachability — the A3 finding
21 modules call a provider SDK directly, bypassing both clients: 23 `litellm.completion/acompletion`,
7 `litellm.embedding/aembedding`, 16 `chat.completions.create`, 3 bare `OpenAI()`.
These nine contain **zero** occurrences of `api_base` or `base_url`, so there is no parameter
to set — "run everything locally" is currently unachievable, not merely unconfigured:
```
memory/memory.py workflows/workflows.py eval/judge.py
eval/grader.py eval/accuracy.py context/compressor.py
context/optimizer.py lite/__init__.py telemetry/performance_monitor.py
```
Worst single case, `memory/memory.py:2186-2192`:
```python
from openai import OpenAI
client = OpenAI() # no base_url, no api_key
response = client.chat.completions.create(
model=llm or "gpt-4o-mini", ...
```
Hardcoded defaults in code (excluding docstrings): **105** `"gpt-4o-mini"`, **17**
`"text-embedding-3-small"`. Sixteen of the 105 are the identical line
`self.llm if hasattr(self,'llm') else "gpt-4o-mini"` in `agent/context_agent.py`.
### Stringly-typed provider identity — the A6 finding
**77 occurrences of 23 distinct string predicates** across `src/`. The same provider is
tested inconsistently, which is precisely how D3 and D4 arose:
```
9 startswith("anthropic/") gemini tested 3 ways: gpt tested 4 ways:
8 startswith("ollama/") startswith("gemini") startswith('gpt-')
7 "ollama" in startswith("gemini/") startswith("gpt-")
6 startswith("gemini") startswith('gemini-') startswith("gpt")
5 startswith("openai/") "gpt" in
5 startswith("claude")
3 ":11434" in
```
### Accretion history — why boundaries matter here
| File | First | Now | Commits | Ever shrank |
|---|---|---|---|---|
| `llm/llm.py` | 1,212 (Jan 2025) | 7,160 | 251 | never, in 18 sampled months |
| `agent/agent.py` | 1,251 (Jan 2025) | 7,701 | 472 | once — the split below |
| `agent/chat_mixin.py` | created Apr 2026 | 6,151 | 150 | — |
On **2026-04-01** `agent.py` was split 8,915 → 5,030 by extracting `chat_mixin.py`. Five
months later the two halves total **13,852 lines — 55% above the pre-split file**, growing
~33 lines/day. Moving code did not create a boundary. This is the argument for I6 being a
*test* rather than a convention.
---
## 4. Dependency facts
| Package | Version present | Status |
|---|---|---|
| `httpx` | 0.28.1 | **hard transitive dep** of required `openai>=2.0.0` (`httpx<1,>=0.23.0`) |
| `aiohttp` | 3.14.1 | direct required core dep |
| `openai` | 2.44.0 | direct required core dep |
| `litellm` | 1.90.2 | optional (`llm`, `memory` extras) |
`pyproject.toml` declares **17 optional extras** — `a2ui, api, all, auth, autonomy, crawl,
dakera, graph, knowledge, llm, mcp, memory, mongodb, os, sandbox, search, telemetry` — and
**none of them is local**. `import praisonaiagents` measures 0.04 s, so imports are lazy and
cheap; discovery latency is the only startup cost to manage.
**Conclusion (superseded).** This section originally concluded `local/` should use `httpx`.
That was reversed by the normative decision in `07-local-package-spec.md` §3: `httpx` is present
only as an **undeclared transitive** edge (`openai -> httpx`), so building the dependency sink on
it means a future `openai` release dropping `httpx` would silently break `local/`. The client is
therefore stdlib `urllib.request`, and the boundary test forbids `httpx`/`requests`/`aiohttp`.
The measurement above stands; only the recommendation drawn from it was wrong.
---
## 5. litellm prefix resolution vs ours
litellm 1.90.2 resolves all of these; PraisonAI's `_detect_provider` recognises only `ollama/`:
```
ollama/llama3.2 -> ollama OllamaAdapter repairs=2
ollama_chat/llama3.2 -> openai DefaultAdapter repairs=0 <- litellm's RECOMMENDED prefix
lm_studio/qwen -> openai DefaultAdapter repairs=0
vllm/x -> openai DefaultAdapter repairs=0
hosted_vllm/x -> openai DefaultAdapter repairs=0
huggingface/x -> openai DefaultAdapter repairs=0
```
---
## 6. Known-bad environment note
`mervinpraison/praisonai-qwen3.5-9b-tamil-en2ta:latest` does **not load** on Ollama 0.33.2:
`Failed to load CLIP model from ...sha256-11540ddc21b3...`, HTTP 500. The vision projector
blob is the failing piece. Use `qwen3:0.6b` for local verification instead. Unrelated to this
work, but it will waste an agent's time if not known.
---
## 7. The three response paths disagree — measured, not inferred
Live Ollama, `temperature=0`, identical prompt and tool, two trials, reproducible
(script in `06-adapter-revival.md` §2.7):
```
sync calls=2 'Paris: 21C sunny. Paris: 21C sunny.'
async calls=1 'Paris: 21C sunny'
stream calls=10 ''
```
> **Corrected 2026-09-03.** An earlier draft recorded the sync path returning
> `'{\n\n\n}'` with one tool call. That did not reproduce; the figures above did,
> across two trials. The finding is unchanged — the paths disagree and the stream
> path is broken — but the `'{\n\n\n}'` sample should not be cited.
Three facts follow, and they reframe the work:
1. **The best-compensated path produces the worst answer.** `get_response` carries 16 of the 18
compensations and calls the tool twice, returning a doubled answer;
`get_response_async` carries 8 and returns the correct answer once. More compensation is
not the goal — *the same* compensation is.
2. **The stream path is not under-compensated, it is broken.** It burns all 10 iterations of
`max_iter`, invokes the tool **ten times** for a one-tool question, and yields nothing. That
is a cost and side-effect amplifier, not a formatting defect.
3. Leading hypothesis for the sync/async split: `OLLAMA_FINAL_ANSWER_PROMPT` (`llm.py:5302`,
async only). **Still a hypothesis, not a measurement** — it was not settled during order 06,
because the corrected baseline changed what needs explaining.
## 8. Two test-infrastructure traps
- **`src/praisonai-agents/tests/` is almost entirely outside CI.** The only tests any workflow
runs from it are `tests/smoke/test_subscription_auth.py` and
`tests/unit/auth/test_agent_auth_wiring.py`. `tests/unit/test_adapter_registry.py` (38 tests)
and all 13 files in `tests/unit/llm/` run **nowhere**. Put new tests in
`src/praisonai/tests/unit/llm/`, which is run.
- **`test_ollama_tool_reliability.py` tests a copy, not the product.** Its `MockLLM`
re-implements `_is_ollama_provider`, `_apply_ollama_defaults`, `_validate_tool_call`,
`_should_force_tool_usage` and both prompt templates. It cannot fail in response to any change
in `llm.py`. Do not treat it as a safety net.
## 9. Test-gating facts (order 04)
- **`not network`, not `provider_ollama`, is what deselects the mocked base_url suite.** The gating
plugin implies `network` from *any* provider marker (`test_gating.py:235-238`) and every CI
expression contains `not network`. Editing the workflows' `-m` strings accomplishes nothing.
- `tests/unit/**` is **exempt** from provider auto-detection (`test_gating.py:215`,
`test_type != 'unit'`). That is why `tests/unit/llm/test_ollama_tool_reliability.py` runs in CI
while the integration file does not — and why live tests placed under `tests/unit/` need an
**explicit** marker.
- `-m provider_ollama` currently selects **32 tests across 5 files**; only 7 are Ollama tests.
- **4 of the 7 tests in `test_base_url_api_base_fix.py` fail when actually executed** —
`LLMResponseError: 'str' object has no attribute 'choices'`. The mock returns a dict; the
tool-calling loop needs an object with `.choices`.
- **`tests/test_double_api_fix.py` makes a real network call when forced to run.** `llm.py` routes
`gpt-4o-mini` through `litellm.responses` (`llm.py:6434`), not `litellm.completion`, so its patch
no longer intercepts (verified: 401 from `api.openai.com/v1/responses`). It is currently masked
by an unconditional auto-skip — **anyone who "fixes" the skip without fixing the mock gets live
billed calls.**
- **`network_guard.py` disables itself** for anything under `/integration/`, `/e2e/`, `/live/`
(lines 103-105) and for any `provider_*`-marked test (96-100). The suite's network blocker does
not cover the tests most likely to make network calls.
- **A live Ollama suite already exists** and has never run:
`tests/integration/test_ollama_tool_calling_live.py`, 230 lines, 7 tests, gated on
`PRAISONAI_TEST_OLLAMA`. It is broken against `main` — all 7 pass `verbose=True` to `Agent()`,
and two pass `force_tool_usage=` / `max_tool_repairs=`; all three now raise `TypeError`.
- **`qwen3:0.6b` is 522 MB** (not ~400 MB) and reports `completion, tools, thinking` — **no
vision**. Reliability through `Agent`: default 3/4; `force_tool_usage="always"` + `temperature=0`
**6/6**; multi-step arithmetic 0/2 (one hung past 90 s).
@@ -1,372 +0,0 @@
# 04 — D7: provider markers are assigned per file, so mocked tests are gated as if live
**Branch:** `fix/gating-per-test-provider-markers`
**Measured:** clean worktree at `origin/main` (`2591aa405`), working tree clean.
**Owns:** `src/praisonai/tests/_pytest_plugins/test_gating.py`, both `pytest.ini` files,
`src/praisonai/tests/conftest.py`, `tests/integration/test_base_url_api_base_fix.py`,
`tests/unit/cli/test_test_command.py`.
**Must not edit:** any `praisonaiagents/` source; `.github/workflows/**` (order 05).
> **The audit's stated root cause was wrong, and the correction changes the fix.**
> `provider_ollama` is a red herring. **`not network` deselects all 7 tests on its own**, because
> the plugin implies `network` from *any* provider marker and the file also matches
> `provider_openai`. **Removing `not provider_ollama` from the workflows would change nothing.**
> The fix must be marker-side, not workflow-side. This is why order 04 touches no YAML.
---
## 1. Corrections to the audit
| # | Claim | Verdict |
|---|---|---|
| 1 | 4 CI marker expressions carry `not provider_ollama` | **True but incomplete** — there is a 5th, non-CI location: `src/praisonai-code/praisonai_code/cli/commands/test.py:67` (`praisonai test --tier main`). |
| 2 | 7 tests, all deselected | **Exactly true** (`collected 7 items / 7 deselected / 0 selected`). **Stated cause is wrong** — see the callout above. |
| 3 | The file never inspects `litellm.completion` kwargs | **True** (only `mock_completion.assert_called()`). **And worse: 4 of the 7 tests fail outright when executed.** They are not merely toothless, they are broken. |
| 4 | `test_double_api_fix.py`: 0 asserts, 34 prints | **True. And worse:** all 3 tests are auto-`skip`ped in every configuration, and when forced to run its `patch('litellm.completion')` no longer intercepts — `llm.py` routes `gpt-4o-mini` through `litellm.responses`, so it makes a **real network call** (verified: 401 from `api.openai.com/v1/responses`). |
| 5 | `test_ollama_tool_reliability.py` asserts against MockLLM | **True.** 541 lines, MockLLM at 23–197, 28 tests, all pass. **Quantified:** 2 of the 8 re-implemented methods (`_apply_ollama_defaults`, `_try_parse_tool_call_json`) **do not exist on the real `LLM` at all.** |
| 6 | agents-side ollama tests gated only by `OLLAMA_HOST` | **True**, and `test_ollama_fix_openai` is gated only by `OPENAI_API_KEY` — a live **billed** call on a plain `pytest`. Mitigating: no CI workflow runs these two files. |
| 7 | No ollama live job | **True for CI**, but the audit **missed that the suite already exists**: `tests/integration/test_ollama_tool_calling_live.py`, 230 lines, 7 tests, gated on `PRAISONAI_TEST_OLLAMA=1`. It is **broken against current `main`**. See order 05. |
---
## 2. Reproduce before the fix
```bash
cd src/praisonai && export PYTHONPATH=$(cd ../praisonai-agents && pwd)
CI_M="not slow and not network and not local_service and not provider_anthropic and not provider_google and not provider_ollama and not provider_grok_xai and not provider_groq and not provider_cohere"
```
**R1 — 100% deselected:**
```bash
python -m pytest tests/integration/test_base_url_api_base_fix.py -m "$CI_M" --collect-only -q --disable-warnings
# collected 7 items / 7 deselected / 0 selected
```
**R2 — `provider_ollama` is a red herring.** Rerun R1 with `and not provider_ollama` **removed**:
still `7 deselected / 0 selected`. `not network` does it alone.
**R3 — the whole file is tagged.** With a marker-dump plugin, all seven items carry the identical
set `['integration', 'network', 'provider_ollama', 'provider_openai', 'skip']` — including the six
that never mention Ollama.
**R4 — 4 of 7 fail when actually executed:**
```bash
PRAISONAI_ALLOW_NETWORK=1 PRAISONAI_TEST_PROVIDERS=all OPENAI_API_KEY=sk-fake-offline \
python -m pytest tests/integration/test_base_url_api_base_fix.py -o addopts="" -q --tb=no -rA
# 4 failed, 3 passed
```
All four fail with `LLMResponseError: ... 'str' object has no attribute 'choices'`. The three that
pass are the three that never call `get_response()`.
**R5 — `-m provider_ollama` selects 4× more than it should:**
```
7 tests/integration/test_base_url_api_base_fix.py
7 tests/integration/test_ollama_tool_calling_live.py
11 tests/integration/test_rag_integration.py
4 tests/integration/test_setup_integration.py
3 tests/test_double_api_fix.py
```
**32 selected; only 7 are Ollama tests.** This is why order 04 is a hard prerequisite for order 05 —
`-m provider_ollama` is currently not a usable selector.
---
## 3. Root cause
`test_gating.py:153-166` scans the **entire file**:
```python
153 def _detect_providers_in_file(filepath: Path) -> Set[str]:
154 """Detect which providers are referenced in a test file."""
...
161 content = _get_file_content(filepath)
162 detected = set()
163 for marker, pattern in PROVIDER_PATTERNS.items():
164 if pattern.search(content):
165 detected.add(marker)
166 return detected
```
Line 28 is `'provider_ollama': re.compile(r'\b(ollama)\b', re.IGNORECASE)`. One occurrence inside
`test_ollama_environment_variable_compatibility` tags all seven items (applied at lines 213–230).
Note line 215: `if item.fspath and test_type != 'unit'` — **`tests/unit/**` is exempt**, which is
why `tests/unit/llm/test_ollama_tool_reliability.py` runs in CI while the integration file does not.
**The second mechanism, which the audit missed**, is lines 235–238:
```python
235 # 3. Add network marker if any provider marker is present
236 provider_markers = {m for m in existing_markers if m.startswith('provider_')}
237 if provider_markers and 'network' not in existing_markers:
238 item.add_marker(pytest.mark.network)
```
A mocked test that merely *mentions* `openai` gets `network`, and every CI expression contains
`not network`. Lines 262–267 then convert it into a runtime skip.
**Sub-defect 3.** Lines 59, 145, 177, 234 are all bare `mock_completion.assert_called()`. Nothing
reads `call_args`. Measured: `LLM(model='openai/mistral', base_url='http://localhost:4000',
api_key='sk-test').get_response("test", stream=False)` calls `litellm.completion` with
`base_url='http://localhost:4000'` and `api_base=None`. **So the file's title is stale** — the real
invariant is *`base_url` survives into the call*, not "maps to `api_base`".
---
## 4. The change
**Design decision: AST per-test marking, not file splitting.**
- *Split the file* — cost cheap, **benefit zero** (measured, R2): the six non-Ollama tests still
match `provider_openai`, still get `network`, still get deselected. It also leaves `-m
provider_ollama` selecting 32 tests and fixes nothing for the other 14 mixed-provider files.
**Rejected.**
- *AST per-test marking* — one change in one plugin, fixes the class of problem, and is the
prerequisite for order 05's selector to mean anything. **Recommended.**
### Sub-change 1 — per-test provider detection via AST
Keep `_detect_providers_in_file` (it now serves only the `network` marker) and add:
```python
def _detect_providers_in_text(text: str) -> Set[str]:
"""Return every provider marker whose pattern appears in ``text``."""
detected = set()
for marker, pattern in PROVIDER_PATTERNS.items():
if pattern.search(text):
detected.add(marker)
return detected
def _build_per_test_provider_map(filepath: Path) -> Optional[Dict[tuple, Set[str]]]:
"""Map ``(class_name, func_name)`` -> provider markers for one test file.
The text considered for a test is that test's own source segment plus its
decorators -- not the whole file. Returns ``None`` if the file cannot be
parsed, so callers fall back to whole-file rather than under-marking.
"""
key = str(filepath)
if key in _file_provider_cache:
return _file_provider_cache[key]
result: Optional[Dict[tuple, Set[str]]] = None
try:
source = _get_file_content(filepath)
tree = ast.parse(source)
result = {}
def _visit(node, class_name=None):
for child in getattr(node, 'body', ()):
if isinstance(child, ast.ClassDef):
_visit(child, child.name)
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
segment = ast.get_source_segment(source, child) or ""
for decorator in child.decorator_list:
segment += "\n" + (ast.get_source_segment(source, decorator) or "")
result[(class_name, child.name)] = _detect_providers_in_text(segment)
_visit(tree)
except (SyntaxError, ValueError, RecursionError):
result = None
_file_provider_cache[key] = result
return result
def _detect_providers_for_item(item) -> Set[str]:
"""Provider markers for a single collected item (per test, not per file)."""
filepath = Path(item.fspath)
if _is_excluded_path(str(filepath)):
return set()
per_test = _build_per_test_provider_map(filepath)
if per_test is None:
return _detect_providers_in_file(filepath)
parts = item.nodeid.split("::")[1:]
if not parts:
return _detect_providers_in_file(filepath)
func = parts[-1].split("[")[0]
cls = parts[-2] if len(parts) >= 2 else None
if (cls, func) in per_test:
return set(per_test[(cls, func)])
if (None, func) in per_test:
return set(per_test[(None, func)])
# Dynamically generated item we cannot locate in the AST: stay conservative.
return _detect_providers_in_file(filepath)
```
Add `import ast`, a `_file_provider_cache: Dict[str, Optional[Dict[tuple, Set[str]]]] = {}` next to
`_file_content_cache` (line 46), and clear it in `pytest_configure` (186) and
`pytest_sessionfinish` (294).
### Sub-change 2 — `offline` opt-out, and pin `network` to the file
Replace lines 213–230 with a version that (a) honours an explicit `offline` marker, (b) calls
`_detect_providers_for_item`, and (c) **keeps `network` whole-file derived**:
```python
offline = 'offline' in existing_markers
if not offline:
detected_providers = _detect_providers_for_item(item)
for marker, pattern in PROVIDER_PATTERNS.items():
if pattern.search(item.nodeid):
detected_providers.add(marker)
for provider in detected_providers:
if provider not in existing_markers:
item.add_marker(getattr(pytest.mark, provider))
# The `network` marker stays WHOLE-FILE derived. Narrowing
# the provider markers must not, by itself, un-deselect a
# test that is really live -- only `offline` does that.
if _detect_providers_in_file(filepath) and 'network' not in existing_markers:
item.add_marker(pytest.mark.network)
```
**Why `network` stays file-derived — this is the whole backward-compatibility story and is not
optional.** Measured: if per-test narrowing were also allowed to narrow `network`, **48 currently-
deselected integration tests would newly select, 25 of them with no `skipif` — they would actually
execute**, across nine files including `test_rag_live.py` and `test_model_routing_real.py`, in a
suite where the network guard is disabled for everything under `/integration/`
(`network_guard.py:103-105`). Pinning `network` to the file makes sub-change 1 provably
**selection-neutral** under every existing `-m` expression, while still making `-m provider_ollama`
mean what it says.
Register the marker in both `pytest.ini` files and `tests/conftest.py`:
```
offline: Fully mocked - opt out of provider/network auto-marking
```
### Sub-change 3 — opt the file in, and make it guard its bug
Add `pytestmark = pytest.mark.offline` plus a response factory:
```python
def make_completion_response(text="Test response"):
"""A litellm.completion stand-in the LLM tool-calling loop can consume.
A bare dict is NOT sufficient: the loop reads ``.choices[0].message`` and a
dict raises ``'str' object has no attribute 'choices'``.
"""
resp = MagicMock()
resp.choices = [MagicMock()]
resp.choices[0].message.content = text
resp.choices[0].message.tool_calls = None
resp.choices[0].finish_reason = "stop"
return resp
```
For each of the four broken tests: replace the dict `return_value` with
`make_completion_response(...)`, pass `stream=False` to `get_response`, and replace
`mock_completion.assert_called()` with a real kwarg assertion:
```python
kwargs = mock_completion.call_args.kwargs
assert kwargs['base_url'] == 'http://localhost:4000'
assert kwargs['api_key'] == 'sk-test'
assert kwargs['model'] == 'openai/mistral'
```
- `test_koboldcpp_specific_scenario` → `kwargs['base_url'] == "http://127.0.0.1:5001/v1"`
- `test_litellm_documentation_example_compatibility` → `kwargs['base_url'] == "http://0.0.0.0:4000"`
- `test_ollama_environment_variable_compatibility` → `kwargs['model'] == 'ollama/llama2'` **and**
`'base_url' not in kwargs` (none was configured; the env path must not inject one)
All four were run standalone against `main` and pass. Leave the three currently-passing tests alone.
Update the stale module docstring ("maps base_url to api_base" → "base_url must reach litellm") but
**do not rename the test functions** — the nodeids are referenced in issue discussion, and renaming
would change what the nodeid scan at plugin lines 224–226 sees.
---
## 5. Backward-compatibility contract
Baseline, measured (`tests/integration/`, CI ignores applied): **290 collected, 187 selected,
103 deselected** under the CI marker expression.
> Caveat for the executing agent: a cold first invocation on a fresh interpreter has been observed
> to report `157/260`, apparently an optional-dependency import warming effect. **Run it twice**;
> `187/290/103` is stable from the second run on. Take your own baseline before touching anything.
**Tests whose selection status changes: exactly 7**, all in `test_base_url_api_base_fix.py`, all
deselected → selected. Post-fix expectation: **194/290 (96 deselected)**. Nothing else moves in
either direction — that is what the file-derived `network` guarantees.
Intended change under `-m provider_ollama` (32 → 9):
| file | before | after |
|---|---|---|
| `test_ollama_tool_calling_live.py` | 7 | 7 |
| `test_base_url_api_base_fix.py` | 7 | 0 (`offline`) |
| `test_rag_integration.py` | 11 | 1 |
| `test_setup_integration.py` | 4 | 0 |
| `test_double_api_fix.py` | 3 | 1 |
**The risk, stated plainly.** Un-deselecting tests turns CI red if any fail. **They do: 4 of the 7
fail today** (R4). This is not hypothetical.
**Mitigation — mandatory, not optional: land the gating fix and the four repairs in the same PR.**
Do not merge sub-changes 1+2 with the `pytestmark` but without the repairs. **Quarantining with
`xfail` is explicitly rejected** — the repairs are ~10 lines each, verified working, and an `xfail`
here would recreate the exact defect being fixed (a test that names a bug and does not guard it).
**Escape hatch.** If the 7 prove flaky in CI for a reason not reproducible locally, revert
**sub-change 3 only**. Selection returns to 187/290/103 and the plugin improvements stay.
---
## 6. Tests to add
In `tests/unit/cli/test_test_command.py` (the existing home of gating-plugin tests, and already in
`EXCLUDED_PATHS`, so these cannot be auto-marked by the thing they test), add
`class TestProviderMarkerGranularity` with:
- `test_ollama_does_not_leak_to_sibling_tests` — **THE meta-test.** If this fails, D7 has regressed.
- `test_openai_does_not_leak_to_sibling_tests`
- `test_plain_test_gets_no_provider_markers`
- `test_decorators_are_scanned` — a `@pytest.mark.parametrize("m", ["ollama/llama2"])` must mark it
- `test_unparseable_file_falls_back_to_whole_file` — conservative over-marking on a parse failure
- `test_network_marker_remains_whole_file_derived` — pins the decision that keeps this change
selection-neutral for the 96 still-deselected tests
- `test_offline_marker_is_registered` — both `pytest.ini` files, or every use warns
No new dependency: `ast`, `tmp_path` and `configparser` are stdlib/pytest builtins.
---
## 7. Verification and acceptance
```bash
# 1. The 7 are now selected
python -m pytest tests/integration/test_base_url_api_base_fix.py -m "$CI_M" --collect-only -q
# expect: 7 selected, 0 deselected
# 2. They pass offline, with no keys and no services
env -u OPENAI_API_KEY -u ANTHROPIC_API_KEY -u OLLAMA_HOST \
python -m pytest tests/integration/test_base_url_api_base_fix.py -m "$CI_M" -o addopts="" -q
# expect: 7 passed
# 3. Selection neutral everywhere else
python -m pytest tests/integration/ $IG -o addopts="" -q --collect-only -m "$CI_M" | tail -1
# expect: 194/290 tests collected (96 deselected)
# 4. `-m provider_ollama` now means Ollama -> 7 + 1 + 1, not 32
# 5. Meta-tests pass
# 6. The three CI shards reproduced verbatim do not regress
# 7. The two pytest.ini marker tables stay in sync
```
**Acceptance gate:** `tests/integration/` collects **194/290 (96 deselected)** — exactly seven more
than baseline, all seven from `test_base_url_api_base_fix.py` — **and passes with
`OPENAI_API_KEY`, `ANTHROPIC_API_KEY` and `OLLAMA_HOST` all unset and no Ollama server running.**
**PR title:** `fix(tests): mark provider tests per test function, not per file`
---
## 8. Filed for separate PRs
- **`tests/test_double_api_fix.py`'s mock no longer intercepts.** `llm.py` routes `gpt-4o-mini`
through `litellm.responses` (`llm.py:6434`), not `litellm.completion`. Forced to run, it makes a
**real OpenAI request** (verified: 401 for `https://api.openai.com/v1/responses`). Today it is
masked by an unconditional auto-`skip` — **anyone who "fixes" the skip without fixing the mock
gets live billed calls.**
- **`network_guard.py` disables itself for `/integration/`, `/e2e/`, `/live/`** (lines 103–105) and
for any `provider_*`-marked test (96–100). The suite's network blocker does not cover the tests
most likely to make network calls.
@@ -1,298 +0,0 @@
# 05 — Live local-model CI job
**Branch:** `feat/ci-ollama-live-job`
**Depends on:** order `04` must merge first. Before it, `-m provider_ollama` selects 32 tests
across 5 files (25 of which are not Ollama tests) and this job is unbuildable.
**Owns:** `.github/workflows/test-optimized.yml`,
`src/praisonai/tests/integration/test_ollama_tool_calling_live.py`.
**Must not edit:** `test_gating.py` (order 04); the `smoke` / `main` / `openai-live` jobs; anything
in `test-core.yml`; `src/praisonai-agents/tests/test_ollama_{fix,async_fix}.py`.
---
## 1. The suite already exists — and is broken
The audit concluded "no ollama live job, and no live suite". The first half is right; **the second
is wrong**. `src/praisonai/tests/integration/test_ollama_tool_calling_live.py` is 230 lines,
7 tests, gated at lines 19–22 on `PRAISONAI_TEST_OLLAMA`. It has never run anywhere.
Measured against `main`, **it cannot run** — three `Agent()` kwargs are now rejected:
```
$ python -c "from praisonaiagents import Agent; Agent(name='C', llm='ollama/qwen3:0.6b', verbose=True)"
TypeError: Agent.__init__() got unexpected keyword argument(s): verbose
verbose: verbosity moved into output=; use output='verbose' (or output=OutputConfig(verbose=True)).
$ ... Agent(..., force_tool_usage="always") -> TypeError: unexpected keyword argument(s): force_tool_usage
$ ... Agent(..., max_tool_repairs=3) -> TypeError: unexpected keyword argument(s): max_tool_repairs
```
All 7 tests use `verbose=True`; two also use the other two. **Every test errors on construction.**
The modern form, verified working:
```python
Agent(name="C",
llm={"model": "ollama/qwen3:0.6b", "force_tool_usage": "always", "max_tool_repairs": 3},
tools=[calculator])
# -> llm_instance.force_tool_usage == 'always', llm_instance.max_tool_repairs == 3
```
So this order is **"fix a dead test file and wire it up"**, not "write a live suite".
---
## 2. Model selection
Measured on a real Ollama 0.33.2 host:
```
$ ollama list
qwen3:0.6b 7df6b6e09427 522 MB
$ ollama show qwen3:0.6b
parameters 751.63M quantization Q4_K_M context length 40960
Capabilities: completion, tools, thinking
```
**Two corrections:** the blob is **522 MB**, not ~400 MB; and it is **not vision capable** —
`completion, tools, thinking` only. Tools + thinking is all this job needs.
**Reliability, measured end-to-end through `praisonaiagents.Agent`:**
| configuration | trials | tool invoked + correct answer | latency |
|---|---|---|---|
| default | 4 | **3/4** — one run returned `{}` with no tool call | 0.5–5.4 s |
| `force_tool_usage="always"`, `temperature=0` | 6 | **6/6** | 0.4–4.0 s |
| indirect prompt ("I know it's easy, but use the tool") | 1 | 0/1 — emitted `{"type": "calculator", ...}`, wrong key | 0.6 s |
| multi-step `(17+25)+(8+9)` | 2 | 1 hung past 90 s; 1 answered 42 instead of 59 | — |
**Conclusion: `qwen3:0.6b` is the right model, but only the forced, zero-temperature, single-call
prompt is CI-deterministic.**
Rejected alternatives: `qwen2.5:0.5b` (~400 MB, older tool calling, unmeasured — no reason to
prefer it over a verified model); `llama3.2:1b` (~1.3 GB, 2.5× the cache for no measured gain);
`smollm2:135m` (**no `tools` capability — disqualifying**); `olmo-3` (the file's current default,
~7B, far too large per run). Keep `olmo-3` as the *local* default so developer workflow is
unchanged; let CI override via env var.
**Cache:** `~/.ollama/models`, keyed `${{ runner.os }}-ollama-models-${{ env.OLLAMA_TEST_MODEL }}`.
**No `restore-keys`** — a partial restore of a *different* model's blobs is worse than a clean
pull, and a stale key would silently test the wrong model.
**Install:** pinned release tarball, not `curl https://ollama.com/install.sh | sh`. The install
script tracks latest, so the job would silently change what it tests, and it pulls GPU runtimes a
CPU-only runner never uses. Expect ~20–60 s tarball + ~30–90 s cold model pull, near-zero on a
cache hit — **the executing agent must confirm real timings on the first green run and write them
into a YAML comment.**
**Gating:** `if: github.event_name == 'schedule' || github.event.inputs.tier == 'extended' ||
github.event.inputs.tier == 'nightly'` — byte-identical to `extended-anthropic` (line 467),
`extended-google` (525), `extended-groq` (585), `extended-xai` (644), `extended-cohere` (703).
**It never runs on a pull request.**
---
## 3. One deliberate departure from the sibling jobs
Every existing live job ends `|| echo "... tests completed"`, so **it can never fail**. That is
defensible for key-dependent jobs — a rotated secret should not page anyone — but it is exactly
the "test that cannot fail" pattern this whole effort exists to remove, and **Ollama needs no
secret**: availability is fully under the job's control.
So the job has **two run steps**:
1. a **required** minimum-contract step with **no `|| echo`**;
2. an **informational** step for the measured-flaky remainder, keeping the sibling style.
**Do not merge them.**
---
## 4. Test-file changes
1. After `pytestmark`, add the model indirection:
```python
# CI overrides this with a small, fast model; local runs keep olmo-3.
OLLAMA_MODEL = "ollama/" + os.getenv("PRAISONAI_OLLAMA_TEST_MODEL", "olmo-3")
```
2. Replace all 7 hard-coded `llm="ollama/olmo-3"` with `llm=OLLAMA_MODEL`.
3. Remove `verbose=True` from all 7 `Agent(...)` calls. Where verbosity is wanted: `output="verbose"`.
4. Move `force_tool_usage="always"` and `max_tool_repairs=3` into an `llm` dict.
5. Add the CI-minimum test as a new first class — **the only assertion in the file with a measured
6/6 pass rate**:
```python
class TestOllamaCIMinimum:
"""The minimum contract a local model must satisfy: a tool call round-trips.
Deliberately narrow. Measured on qwen3:0.6b: this exact configuration
(forced tool usage, temperature 0, single arithmetic step) passed 6/6.
The default configuration passed 3/4 and multi-step prompts were worse --
those live in TestOllamaToolCallingLive, which CI runs informationally.
"""
def test_tool_call_round_trips(self, ollama_available):
from praisonaiagents import Agent
invocations = []
def add(a: int, b: int) -> int:
"""Add two integers together.
Args:
a: First number to add
b: Second number to add
"""
invocations.append((a, b))
return a + b
agent = Agent(
name="CI Calculator",
llm={"model": OLLAMA_MODEL, "force_tool_usage": "always", "temperature": 0},
tools=[add],
)
result = agent.chat("Compute 17 + 25. You MUST use the calculator tool.")
# 1. the model actually called our function, with the right arguments
assert invocations == [(17, 25)], f"tool was not invoked: {invocations!r}"
# 2. the tool's return value made it back into the model's answer
assert "42" in str(result), f"tool result did not round-trip: {result!r}"
```
Reuse the existing `ollama_available` fixture (lines 48–53) — it already probes
`http://localhost:11434/api/tags` and skips cleanly. Do not add a second probe.
### Why both assertions are needed
`qwen3:0.6b` answers `17 + 25 = 42` **correctly with no tools at all** (measured, 1.2 s,
`tools=[]`). So a bare `assert "42" in result` is satisfied by a model that ignores tools entirely.
The `invocations == [(17, 25)]` half is what proves the tool call was emitted, parsed and
dispatched with the right arguments; the `"42"` half proves the return value was fed back and
reached the final answer. Anything beyond this — multi-step chains, repair loops, resisting
distraction — is measured-flaky on a 0.6B model and belongs in the informational step.
---
## 5. The YAML
Insert after `extended-cohere`'s `Skip notice` step (ends line 760), before the `legacy-unit`
banner. Key fragments:
```yaml
extended-ollama:
if: github.event_name == 'schedule' || github.event.inputs.tier == 'extended' || github.event.inputs.tier == 'nightly'
runs-on: ubuntu-latest
timeout-minutes: 15
env:
# Bump deliberately; the cache key and what we test both follow this.
OLLAMA_VERSION: v0.33.2
OLLAMA_TEST_MODEL: qwen3:0.6b
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Cache Ollama model blobs
uses: actions/cache@v4
with:
path: ~/.ollama/models
# No restore-keys: a partial hit from a different model would
# silently test something other than OLLAMA_TEST_MODEL.
key: ${{ runner.os }}-ollama-models-${{ env.OLLAMA_TEST_MODEL }}
- name: Install Ollama
shell: bash
run: |
set -euo pipefail
curl -fsSL -o /tmp/ollama.tgz \
"https://github.com/ollama/ollama/releases/download/${OLLAMA_VERSION}/ollama-linux-amd64.tgz"
sudo tar -C /usr -xzf /tmp/ollama.tgz
ollama --version
- name: Start Ollama and pull model
shell: bash
run: |
set -euo pipefail
ollama serve > /tmp/ollama-serve.log 2>&1 &
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:11434/api/tags > /dev/null; then
echo "Ollama is up after ${i}s"; break
fi
sleep 1
done
curl -sf http://127.0.0.1:11434/api/tags > /dev/null
ollama pull "$OLLAMA_TEST_MODEL"
ollama list
- name: Install dependencies
run: |
pip install --upgrade pip
pip install pytest pytest-asyncio pytest-timeout
- uses: ./.github/actions/install-monorepo-packages
with:
agents-extras: ".[all]"
- name: Run Ollama minimum contract (required)
env:
PRAISONAI_TEST_TIER: extended
PRAISONAI_ALLOW_NETWORK: '1'
PRAISONAI_LIVE_TESTS: '1'
PRAISONAI_TEST_PROVIDERS: 'ollama'
PRAISONAI_TEST_OLLAMA: '1'
PRAISONAI_OLLAMA_TEST_MODEL: ${{ env.OLLAMA_TEST_MODEL }}
shell: bash
run: |
set -euo pipefail
cd src/praisonai
# No "|| echo" here on purpose: no secret is involved, so a failure
# here is a real regression in local-model tool calling.
python -m pytest tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
-m "provider_ollama" -v --tb=short --timeout=120
- name: Run remaining Ollama live tests (informational)
if: always()
env: { ...same... }
run: |
cd src/praisonai
python -m pytest tests/integration/test_ollama_tool_calling_live.py \
-m "provider_ollama" \
--deselect tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
-v --tb=short --timeout=180 \
|| echo "Ollama exploratory tests completed (small models are non-deterministic)"
- name: Ollama server log
if: failure()
run: tail -100 /tmp/ollama-serve.log
```
And in `test-summary` (line 838): extend `needs: [smoke, main, extended-ollama]` and add the
summary rows. `test-summary` is already `if: always()`, so a skipped `extended-ollama` on PRs
reports `skipped` rather than blocking.
---
## 6. Verification and acceptance
```bash
ollama pull qwen3:0.6b
cd src/praisonai && export PYTHONPATH=$(cd ../praisonai-agents && pwd)
# Must be 6/6 before this ships.
for i in $(seq 1 6); do
PRAISONAI_TEST_OLLAMA=1 PRAISONAI_ALLOW_NETWORK=1 PRAISONAI_LIVE_TESTS=1 \
PRAISONAI_TEST_PROVIDERS=ollama PRAISONAI_OLLAMA_TEST_MODEL=qwen3:0.6b \
python -m pytest tests/integration/test_ollama_tool_calling_live.py::TestOllamaCIMinimum \
-o addopts="" -q --timeout=120 2>&1 | tail -1
done
# The gate still holds with the env var absent:
python -m pytest tests/integration/test_ollama_tool_calling_live.py -o addopts="" -q --collect-only
# expect: collected, all skipped by the module pytestmark
# Nothing leaked onto the PR path:
grep -n "extended-ollama" -A3 .github/workflows/test-optimized.yml | grep "if:"
python -c "import yaml; yaml.safe_load(open('.github/workflows/test-optimized.yml')); print('yaml ok')"
# Then: gh workflow run "Optimized Test Suite" -f tier=extended
```
**Acceptance gate:** a manual `workflow_dispatch` with `tier=extended` shows `extended-ollama`
**green**, with "Run Ollama minimum contract (required)" reporting `1 passed` — **and** the same
workflow triggered by a pull request shows `extended-ollama` **skipped**.
**PR title:** `ci: add nightly live-Ollama job exercising a real local model`
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,196 +0,0 @@
# Local Model Layer — implementation ledger
**Status:** every order below has an open PR. See §4.
**Owner workstream:** local model runtimes (Ollama, LM Studio, llama.cpp, vLLM, MLX, `transformers serve`)
**Created:** 2026-09-03
---
## 0. How to use this document
This directory is a **work-order ledger**. It exists so that any agent — including one
with no memory of the conversation that produced it — can pick up exactly one unit of
work and execute it correctly, in isolation, without re-deriving the analysis.
**If you are an agent starting work here, do this in order:**
1. Read [`00-ground-truth.md`](00-ground-truth.md). It records what was measured, how, and
when. Do not trust a claim in any work order that contradicts it; re-measure instead.
2. Read §2 (Invariants) and §3 (File ownership) of this file. §3 is what stops two agents
corrupting each other's work.
3. Open the single work order you were assigned. Execute only that one.
4. Update the status row in §4 of this file in the same commit as your change. That row is
the only shared file content; keep the edit to one line to avoid conflicts.
**Do not** expand scope, batch two work orders into one PR, or "fix while you're in there".
Each order is independently revertible by design and that property is worth more than
convenience.
> **Two corrections this ledger has already had to make.** Both were caught by measuring rather
> than reasoning, and both would have sent an agent down the wrong path.
>
> **The D7 root cause is not `provider_ollama`.** The audit blamed the per-file `provider_ollama`
> tag. Measurement (`04-test-gating.md` §2) shows `not network` deselects all seven tests **on its
> own**, because the plugin implies `network` from *any* provider marker and the file also matches
> `provider_openai`. **Removing `not provider_ollama` from the workflows would change nothing.**
> That is why order `04` touches no YAML at all — the fix is marker-side.
> Its first draft told an agent to "wire
> the twelve dead adapter methods". Measurement (`06-adapter-revival.md` §1) says the opposite:
> **12 of 20 items should be deleted, only 3 wired.** Six of the dead methods have no inline
> equivalent anywhere — they are speculative API, and an agent told to "wire them up" would
> invent an implementation that no test could ever have failed against. Read the decision table
> before touching `llm/adapters/`; do not act on the summary in §1 of this file.
---
## 1. Why this work exists
Every local runtime worth supporting already speaks `POST /v1/chat/completions`, and
`litellm` already handles that transport. So the problem is not calling local models — the
SDK can already do that. The problems are:
- **Discovery.** A user with a running daemon still hand-types a URL, a port and an exact
model tag; getting any one wrong produces `OPENAI_API_KEY environment variable is required`.
- **Capability.** "Does this model support tools?" is answerable by asking the server
(`/api/show`, `/props`, `/server_info`). The SDK asks nothing and guesses from a name prefix.
- **Quirks.** Local stacks fail silently with HTTP 200. The sharpest: sending `format` and
`tools` together to Ollama suppresses the tool call and the model fabricates an answer.
The layer being built is therefore a **resolver, not a transport**: it returns data about
what is running and what it will get wrong. Compensating behaviour stays in
`llm/adapters/`, which already declares methods for exactly this purpose.
---
## 2. Invariants — never break these
These come from `src/praisonai-agents/AGENTS.md` §4.6 plus the structural review in
`00-ground-truth.md`. Every work order inherits them; none may waive them.
| # | Invariant | Why it binds this work specifically |
|---|-----------|--------------------------------------|
| I1 | **Backward compatible.** Public API changes need a deprecation cycle. | Several fixes change which code path a given input takes. Each order carries an explicit list of inputs whose behaviour must not change. |
| I2 | **Lazy imports.** Optional deps never imported at module level. | Discovery must not import `litellm` or any runtime SDK at import time. |
| I3 | **Safe defaults.** New features are opt-in. | Discovery must not fire unless the model spec is `"local"` or unset. Never probe the network on a fully-specified config. |
| I4 | **Deterministic tests.** No dependence on timing or external state. | Every test must pass with **no server running**. Live-server tests go behind an explicit marker and never run in the default suite. |
| I5 | **Protocol-driven core.** No heavy implementations in core. | `local/` returns dataclasses. It performs no chat calls, no retry, no request mutation. |
| I6 | **`local/` is a dependency sink.** | It may import the **standard library only** — nothing from `praisonaiagents`, and no third-party distribution (`httpx`/`requests`/`aiohttp` are forbidden; use `urllib.request`). Enforced by a test. See §5 and `07` §3. |
| I7 | **No second abstraction.** | `llm/adapters/` is the extension point. Do not add a parallel quirk/adapter registry beside it. |
| I8 | **No new public knobs without a live consumer.** | Root `AGENTS.md`: do not add params, modules or exports that merely duplicate existing behaviour. |
### I6 in detail — the import rule
An AST pass over `praisonaiagents/` (595 files, 238,936 lines) found **65 subpackages, 212
directed edges and 26 mutually-importing subpackage pairs**. `llm/` already imports upward
into `agent/`, and 15 subpackages import `llm/` back. Consequently a shared helper placed in
`llm/` **cannot** be adopted by `memory/` or `eval/` without deepening the existing cycles.
`local/` is therefore specified as a leaf. That single property is what allows the 21
modules which currently bypass the LLM layer to adopt it later. Treat any import of
`praisonaiagents.*` from inside `local/` as a build break, not a style issue.
The HTTP client is stdlib `urllib.request`, **not** `httpx`. An earlier draft of this ledger
permitted `httpx` on the grounds that it is a transitive dependency of `openai>=2.0.0`. That
was reversed in `07` §3: the edge is undeclared in `pyproject.toml`, so a future `openai`
release that drops `httpx` would silently break the sink — precisely the class of failure this
package exists to catalogue. The boundary test in §5 hard-forbids `httpx`/`requests`/`aiohttp`
so the decision cannot drift.
---
## 3. File ownership — collision avoidance
Two work orders that touch the same file **must not run concurrently**. This table is the
authority. Before starting, check that no in-flight PR owns your files.
| Work order | Owns (may edit) | Must not edit |
|---|---|---|
| `01-d1-default-model` | `agent/agent.py` (branch chain only) | `llm/llm.py`, `llm/adapters/` |
| `02-d3-provider-detection` | `llm/llm.py` (`_detect_provider`, `_is_ollama_provider` only) | `agent/agent.py`, `llm/adapters/` |
| `03-d2-base-url-routing` | `agent/agent.py` (base_url branch), root `README.md` | `llm/llm.py`, `llm/adapters/` |
| `04-test-gating` | `tests/_pytest_plugins/test_gating.py`, both `pytest.ini`, `tests/conftest.py`, `tests/integration/test_base_url_api_base_fix.py`, `tests/unit/cli/test_test_command.py` | any `praisonaiagents/` source; **`.github/workflows/**` — the `-m` expressions are not the bug** |
| `05-live-ci-job` | `.github/workflows/test-optimized.yml`, `tests/integration/test_ollama_tool_calling_live.py` | any `praisonaiagents/` source; `test_gating.py` |
| `06-adapter-revival` | `llm/adapters/__init__.py`, `llm/llm.py` (3 response paths) | `agent/agent.py` |
| `07-local-package` | `praisonaiagents/local/**` (new), `src/praisonai/tests/unit/llm/local/**` (new), `pyproject.toml` extras | everything else |
| `08-reachability` | `memory/`, `eval/`, `context/`, `lite/`, `telemetry/` | `llm/`, `agent/` |
### Enforced serialisation
- **`01` then `03`** — both edit `agent/agent.py`. Land `01` first.
- **`02` then `06`** — both edit `llm/llm.py`. Land `02` first; it is far smaller.
- **`04` then `05`** — before `04`, `-m provider_ollama` selects 32 tests across 5 files (only 7
are Ollama tests), so `05` has no usable selector and is unbuildable.
- **`04` and `07` are independent** of orders 01/02/03/06 and may run in parallel with them.
- **`08` requires `07`** to have landed, because it consumes `local/`.
---
## 4. Status
Update your row in the same commit as your change. One line only.
Additional PRs not in the original order list, found while executing it:
D4 (local route prefixes) #4798, D5 (local embedding dimensions) #4802,
D6 (registry accepts local providers) #4800.
**Three claims in this ledger were corrected by executing it.** Each is marked
inline where it appears, but they are collected here because a reader who trusts
the summary would otherwise carry the wrong version:
1. *"Wire the twelve dead adapter methods."* Measurement said the opposite:
**12 of 20 items should be deleted, only 3 wired** (`06` §1). Six had no
inline equivalent at all — an agent told to wire them would have invented
behaviour no test could fail against.
2. *"`provider_ollama` deselects the mocked base_url suite."* It does not.
**`not network` deselects it on its own**, because the plugin implies
`network` from any provider marker (`04` §1). Editing the workflow `-m`
strings would have changed nothing.
3. *"Running fully locally is unachievable, not merely unconfigured."* Not so:
litellm and the openai SDK both honour `OPENAI_BASE_URL`, so the bypass sites
**do** reach a local server. The blocker is the hardcoded model name they then
ask for (`00` §5, `08`).
| Order | Title | Branch | PR | Status |
|---|---|---|---|---|
| 01 | D1 — provider-prefixed default model never reaches litellm | `fix/d1-default-provider-model` | #4795 | PR open |
| 02 | D3 — provider over-detection from base_url | `fix/d3-provider-detection` | #4797 | PR open |
| 03 | D2 — README recipe + local-endpoint diagnostic | `fix/d2-base-url-routing` | #4799 | PR open |
| 04 | D7 — per-file provider marker deselects unrelated tests | `fix/d7-per-test-provider-markers` | #4801 | PR open |
| 05 | Live local-model CI job | `feat/ci-ollama-live-job` | #4803 | PR open |
| 06 | A1/A2 — make the provider adapter load-bearing (6 sub-steps) | 6 branches, stacked | #4804 #4806 #4808 #4809 #4810 #4811 | **complete — KNOWN_DEAD empty** |
| 07 | `praisonaiagents/local/` — discovery and capability resolver | `feat/local-model-resolver` | #4807 | PR open |
| 08 | A3 — auxiliary-model fallback configurable everywhere | `fix/reachability-local-endpoint` | #4812 | PR open (model half; parameter half deferred) |
---
## 5. The two tests that keep this work honest
Both are cheap, both are anti-regression, and both should land with their respective orders.
**Import boundary (`07`).** Asserts `local/` imports nothing from `praisonaiagents`. Without
it, I6 decays silently and the leaf property — the whole reason the package can be adopted
by `memory/` and `eval/` — is lost.
**Adapter reachability (`06`).** Asserts every method on `DefaultAdapter` has at least one
call site. This is the test whose absence allowed 11 of 17 adapter methods to become dead code
while `llm.py` grew 145 hand-written Ollama references. Two limits, both real: it cannot detect
an *unreachable* call site (proven by `should_skip_streaming_with_tools`, which a caller would
have satisfied while the branch stayed dead), and **one call site satisfies it — so it does not
detect the three-path divergence at all.** A floor, not a ceiling.
---
## 6. Deliberately out of scope
Named here so that deferring them is a decision rather than an oversight. Do not let a work
order quietly grow into one of these.
| Deferred | Measured size | Why not now |
|---|---|---|
| Consolidating `LLM` and `OpenAIClient` behind one protocol | 7,160 + 2,691 lines, selected by one boolean at 6 sites | Neither is a superset of the other; the merge is larger than every order in this ledger combined. |
| Decomposing `LLM.get_response` | 1,675 lines, 194 branch/loop/try nodes | Highest-risk file in the package. Order `06` must edit it surgically without reflowing it. |
| Removing the sync/async divergence | ~1,628 duplicated lines across 10 pairs; `get_response` vs `_async` only 22.3% similar | Requires the consolidation above to be worth doing. |
The one obligation this ledger accepts toward them: **do not make them worse.** No order may
add a fifth copy of any compensation, and no order may add new logic inside `get_response`
that is not a delegation to an adapter.
@@ -133,13 +133,6 @@ _LAZY_IMPORTS = {
'resolve_harness': ('praisonaiagents.model_harness.profiles', 'resolve_harness'),
'register_profile': ('praisonaiagents.model_harness.profiles', 'register_profile'),
# Test doubles - drive an Agent offline and keep real requests out of a suite
'ScriptedModel': ('praisonaiagents.model_harness.scripted', 'ScriptedModel'),
'ScriptExhausted': ('praisonaiagents.model_harness.scripted', 'ScriptExhausted'),
'allow_model_requests': ('praisonaiagents.model_harness.guard', 'allow_model_requests'),
'no_model_requests': ('praisonaiagents.model_harness.guard', 'no_model_requests'),
'ModelRequestBlocked': ('praisonaiagents.model_harness.guard', 'ModelRequestBlocked'),
# Run outcomes - typed validation and agent execution results
'AgentRunOutcome': ('praisonaiagents.run_outcome', 'AgentRunOutcome'),
'RunStatus': ('praisonaiagents.run_outcome', 'RunStatus'),
@@ -227,7 +220,6 @@ _LAZY_IMPORTS = {
# Guardrails
'GuardrailResult': ('praisonaiagents.guardrails', 'GuardrailResult'),
'LLMGuardrail': ('praisonaiagents.guardrails', 'LLMGuardrail'),
'GuardrailRetry': ('praisonaiagents.guardrails', 'GuardrailRetry'),
# Approval (agent-centric approval backends)
'AutoApproveBackend': ('praisonaiagents.approval.backends', 'AutoApproveBackend'),
@@ -215,14 +215,6 @@ def __getattr__(name):
from .memory_mixin import MemoryMixin
_lazy_cache[name] = MemoryMixin
return MemoryMixin
elif name == 'AttachmentError':
from .attachments import AttachmentError
_lazy_cache[name] = AttachmentError
return AttachmentError
elif name == 'build_attachment_parts':
from .attachments import build_attachment_parts
_lazy_cache[name] = build_attachment_parts
return build_attachment_parts
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -305,7 +297,4 @@ __all__ = [
'ChatMixin',
'ExecutionMixin',
'MemoryMixin',
# Attachments
'AttachmentError',
'build_attachment_parts',
]
@@ -1,5 +1,4 @@
import os
import re
import time
import json
import logging
@@ -9,7 +8,6 @@ import contextlib
import threading
import concurrent.futures
import random
import re
from typing import List, Optional, Any, Dict, Union, Literal, TYPE_CHECKING, Callable, Generator
from collections import OrderedDict
import inspect
@@ -222,28 +220,6 @@ from ..errors import BudgetExceededError
# Import retry configuration
from .retry_utils import RetryBackoffConfig
# Completion-signal detection patterns (compiled once at import — see
# Agent._is_completion_signal). Negation patterns that should NOT be treated
# as completion.
_COMPLETION_NEGATION_RE = re.compile(
r'\b(?:not|never|no longer|hardly|barely|isn\'t|aren\'t|wasn\'t|weren\'t|hasn\'t|haven\'t|hadn\'t|won\'t|wouldn\'t|can\'t|couldn\'t|shouldn\'t|don\'t|doesn\'t|didn\'t)\b'
r'.{0,20}' # up to 20 chars between negation and keyword
)
# Word-boundary patterns to avoid substring false positives
_COMPLETION_PATTERNS = (
(re.compile(r'\btask\s+completed?\b'), False), # no negation check needed
(re.compile(r'\bcompleted\s+successfully\b'), False),
(re.compile(r'\ball\s+done\b'), False),
(re.compile(r'\bdone\b'), True), # 'done' needs negation check
(re.compile(r'\bfinished\b'), True), # 'finished' needs negation check
)
# Emitted at most once per process: pointing at a non-OpenAI endpoint without
# naming a model silently falls back to OpenAI's default, which that endpoint
# almost certainly does not serve.
_LOCAL_ENDPOINT_DEFAULT_MODEL_WARNED = False
class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
# Class-level counter for generating unique display names for nameless agents
_agent_counter = 0
@@ -308,15 +284,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
cls._env_output_checked = True
return cls._env_output_mode
@staticmethod
def _local_discovery(target):
"""Re-read the discovery for a resolved local target (cached upstream)."""
try:
from ..local import probe_endpoint
return probe_endpoint(target.base_url, expect=target.engine)
except Exception:
return None
# Ordered (credential env-var, provider-appropriate default model). The
# first provider whose credential is present wins. OpenAI is first so
# existing OpenAI users keep their default; any other single configured
@@ -394,26 +361,9 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
self._llm_init_params = params
self._using_custom_llm = True
def _wire_deferred_history_callback(self):
"""Point the LLM instance's deferred re-injection at the Agent's own history.
The LLM's own ``chat_history`` list has no readers when driven by an
Agent (Agent passes its separately-maintained history as a parameter),
so a resolved ``defer(...)`` result appended there would be lost. Wiring
the Agent's thread-safe append here lets background-completed deferred
tool results reach the next turn's context.
"""
inst = self._llm_instance
if inst is not None:
try:
inst._agent_history_append = self._append_to_chat_history
except Exception:
pass
def _ensure_llm_instance(self):
"""Lazy-create LLM instance from deferred init params (avoids import at Agent())."""
if self._llm_instance is not None:
self._wire_deferred_history_callback()
return self._llm_instance
if self._panel_descriptor is not None:
try:
@@ -430,7 +380,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
"LLM features requested but dependencies not installed. "
"Please install with: pip install \"praisonaiagents[llm]\""
) from e
self._wire_deferred_history_callback()
return self._llm_instance
if self._llm_init_params:
try:
@@ -441,7 +390,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
"LLM features requested but dependencies not installed. "
"Please install with: pip install \"praisonaiagents[llm]\""
) from e
self._wire_deferred_history_callback()
return self._llm_instance
@property
@@ -453,7 +401,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
self._llm_instance = value
if value is not None:
self._llm_init_params = None
self._wire_deferred_history_callback()
def _ensure_loop_guard(self):
"""Lazy-create loop guard on first tool execution."""
@@ -741,12 +688,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
- bool: True enables with defaults
- Callable: Validation function
- GuardrailConfig: Custom configuration
A validator rejects an answer by returning ``(False, "why")`` or
by raising ``GuardrailRetry("why")``. Either way the reason is
appended to the same conversation and the model gets another
attempt in the same run, up to
``GuardrailConfig(max_retries=...)`` (default 3); see
``guardrail_retry_count``.
web: Web search/fetch. Accepts:
- bool: True enables with defaults
- WebConfig: Custom configuration
@@ -1465,16 +1406,7 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
elif memory_backend == "file":
memory = True
elif _memory_config.config:
# Carry the backend into the dict. _init_memory reads the
# provider as memory.get("provider", memory.get("backend",
# "file")), so handing it the bare config lost the backend
# entirely and silently fell back to FileMemory --
# MemoryConfig(backend="sqlite", config={...}) built a
# FileMemory while to_dict() went on reporting "sqlite".
# An explicit provider/backend inside config still wins.
memory = dict(_memory_config.config)
if "provider" not in memory and "backend" not in memory:
memory["provider"] = memory_backend
memory = _memory_config.config
else:
memory = memory_backend
elif hasattr(_memory_config, 'search') and hasattr(_memory_config, 'add'):
@@ -1497,18 +1429,9 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
if learn is False:
_learn_config = None
elif learn is True:
# learn=True is a coordinated posture (Issue #4864): enable
# AGENTIC extraction AND a conversational-aware nudge cadence in
# one switch, so an always-on companion bot actually learns.
# nudge_min_tool_iters=0 lets a chat-only turn (no tool calls)
# still nudge; auto_memory is turned on below so durable facts
# are captured on every turn, not just tool-using ones.
# learn=True shorthand enables AGENTIC mode (auto-learning)
from ..memory.learn.protocols import LearnMode
_learn_config = LearnConfig(
mode=LearnMode.AGENTIC,
nudge_interval=10,
nudge_min_tool_iters=0,
)
_learn_config = LearnConfig(mode=LearnMode.AGENTIC)
elif isinstance(learn, LearnConfig):
_learn_config = learn
elif isinstance(learn, dict):
@@ -1555,14 +1478,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
elif _learn_config is not None and isinstance(memory, dict):
# Merge learn config into memory dict
memory["learn"] = _learn_config.to_dict() if hasattr(_learn_config, 'to_dict') else _learn_config
# Coordinated learn posture (Issue #4864): the ``learn=True`` shorthand
# turns on conversational fact extraction too, so an always-on companion
# accrues durable persona/preferences from plain chat — not just from
# tool-using turns. Only defaults it on when the caller did not set
# auto_memory explicitly (via MemoryConfig), so an explicit choice wins.
if learn is True and auto_memory is None:
auto_memory = True
# ─────────────────────────────────────────────────────────────────────
# Resolve HISTORY from MemoryConfig - FAST PATH
@@ -2192,58 +2107,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
if reasoning_effort is not None:
self._llm_option_kwargs['reasoning_effort'] = reasoning_effort
# Local-runtime descriptor: llm="local" (or "local:<engine>/<model>").
# Opt-in only -- discovery never fires for any other spec, so a fully
# configured agent pays nothing. Resolved here into a concrete
# provider/model so the normal LLM path handles it unchanged.
if isinstance(llm, str) and (llm == "local" or llm.startswith("local:")):
from ..local import LocalEngine as _LocalEngine
from ..local import resolve as _resolve_local
_spec = llm[len("local:"):] if llm.startswith("local:") else None
_target = _resolve_local(_spec or None)
llm = _target.litellm_model
if base_url is None:
# Ollama's litellm provider builds its own /api paths from the
# server root, so it takes base_url. Every other local engine
# speaks OpenAI and serves under /v1 -- handing it the bare root
# sent chat to /chat/completions, which a real LM Studio, vLLM
# or llama-server answers with 404.
base_url = (_target.base_url
if _target.engine is _LocalEngine.OLLAMA
else _target.openai_base_url)
if api_key is None:
api_key = _target.api_key
self._local_target = _target
# Keep a local agent local end to end. Without this, chat went to
# the local server while knowledge/memory embedded against OpenAI --
# sending the user's documents AND their queries off the machine
# with no warning, which defeats the entire point of asking for a
# local model. Only fills a gap: an explicit embedder always wins.
_wants_embeddings = (retrieval_config is not None
or embedder_config is not None
or memory not in (None, False))
if _wants_embeddings and not embedder_config and not (
retrieval_config or {}).get('embedder_config'):
from ..local import (local_embedder_config as _local_embedder,
select_embedding_model as _select_embed)
_embed_model = _select_embed(
getattr(_d, 'models', ()) if (_d := Agent._local_discovery(_target)) else (),
getattr(_d, 'model_meta', ()) if _d else ())
if _embed_model:
embedder_config = _local_embedder(
_target.engine, _target.base_url, _embed_model)
if retrieval_config is not None:
retrieval_config.setdefault('embedder_config', embedder_config)
else:
logging.warning(
"llm=%r resolved a local model, but %s serves no embedding "
"model, so knowledge/memory would embed against a remote "
"provider -- sending your documents off this machine. "
"Pull one (e.g. `ollama pull nomic-embed-text`) or set an "
"explicit embedder to silence this.",
llm, _target.base_url)
# Panel (multi-model) descriptor: "panel:<name>" or {"provider": "panel"}.
# Resolved lazily into a PanelLLM; composes with the normal tool loop.
# Detected inline (no heavy import) to keep Agent() construction lazy.
@@ -2251,33 +2114,7 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
(isinstance(llm, str) and llm.startswith("panel:"))
or (isinstance(llm, dict) and llm.get("provider") == "panel")
)
# An already-built model object (an ``LLM``, a subclass, or a test double
# such as ``model_harness.ScriptedModel``) is adopted as this agent's
# backend. Without this branch such an object fell through to the plain
# OpenAI path, where ``self.llm`` became the object itself and every turn
# was sent to OpenAI under a model name that was really a repr -- the
# opposite of what passing your own model means. Duck-typed on
# ``get_response`` so any conforming backend works, not just ``LLM``.
# Duck-typed across every backend surface this package documents, not
# just `get_response`: llm/protocols.py defines LLMProviderProtocol via
# `chat`/`achat` and UnifiedLLMProtocol via
# `chat_completion`/`achat_completion`. Checking only `get_response`
# left those backends falling through to the plain OpenAI branch, where
# the object became the model identifier -- the same defect this branch
# exists to fix, just for a different protocol.
_MODEL_BACKEND_METHODS = (
"get_response", "chat", "achat", "chat_completion", "achat_completion",
)
_is_model_instance = (
llm is not None
and not isinstance(llm, (str, dict))
and any(callable(getattr(llm, m, None)) for m in _MODEL_BACKEND_METHODS)
)
if _is_model_instance:
self._llm_instance = llm
self._using_custom_llm = True
self.llm = getattr(llm, "model", None) or type(llm).__name__
elif _is_panel:
if _is_panel:
self._panel_descriptor = llm
self._using_custom_llm = True
self.llm = llm if isinstance(llm, str) else "panel"
@@ -2371,57 +2208,14 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
# Otherwise, fall back to OpenAI environment/name (cached for performance)
else:
model_name = llm or Agent._get_default_model()
# Pointing at a non-OpenAI endpoint without naming a model leaves the
# OpenAI default in place, so the endpoint is asked for a model it
# has never heard of -- Ollama answers 404 "model 'gpt-4o-mini' not
# found", which reads like an SDK bug rather than a missing line of
# config. Warn, don't raise: an OpenAI-compatible proxy may legitimately
# serve that exact name.
#
# Only fire when the resolved default really is the OpenAI default:
# a provider credential (e.g. OLLAMA_HOST) resolves an
# already-correct provider-prefixed model, so there is nothing to
# warn about. Read the endpoint in the order the OpenAI client uses
# (OPENAI_API_BASE before OPENAI_BASE_URL, see openai_client.py) so
# the message names the endpoint the request actually goes to.
if (
llm is None
and model_name == 'gpt-4o-mini'
and not os.getenv('OPENAI_MODEL_NAME')
):
_endpoint = os.getenv('OPENAI_API_BASE') or os.getenv('OPENAI_BASE_URL')
if _endpoint and 'api.openai.com' not in _endpoint:
global _LOCAL_ENDPOINT_DEFAULT_MODEL_WARNED
if not _LOCAL_ENDPOINT_DEFAULT_MODEL_WARNED:
with Agent._env_cache_lock:
if not _LOCAL_ENDPOINT_DEFAULT_MODEL_WARNED:
_LOCAL_ENDPOINT_DEFAULT_MODEL_WARNED = True
logging.warning(
"No model specified, so the OpenAI default %r will be sent to %s. "
"That endpoint probably does not serve it. Set OPENAI_MODEL_NAME "
"to a model it does serve (e.g. OPENAI_MODEL_NAME=ollama/llama3.2), "
"or pass llm= explicitly.",
model_name, _endpoint,
)
# A provider-prefixed model must be built as an LLM instance, which
# owns provider routing and the per-provider adapters. The
# `"/" in llm` branch above only inspects the explicit `llm=`
# argument, so a prefixed model that arrived from a credential env
# var (_PROVIDER_DEFAULT_MODELS) or from OPENAI_MODEL_NAME lands
# here instead and would otherwise fall through to the plain OpenAI
# client holding a model name OpenAI has never heard of. Re-check
# the *resolved* name so `llm="ollama/x"` and OLLAMA_HOST agree.
_has_provider_prefix = isinstance(model_name, str) and "/" in model_name
if auth or _has_provider_prefix:
if auth:
# A subscription auth provider only takes effect inside LLM
# (which injects the resolved OAuth credentials), so a bare
# model name plus auth= must still build an LLM instance rather
# than falling through to the plain OpenAI client.
llm_params = {'model': model_name}
llm_params = {'model': model_name, 'auth': auth}
if api_key:
llm_params['api_key'] = api_key
if auth:
llm_params['auth'] = auth
llm_params['metrics'] = metrics
llm_params['web_search'] = web_search
llm_params['web_fetch'] = web_fetch
@@ -2644,10 +2438,6 @@ Your Goal: {self.goal}
# Initialize guardrail settings
self.guardrail = guardrail
self.max_guardrail_retries = max_guardrail_retries
# Observability for in-run output-validation retries (see
# _apply_guardrail_with_retry / the guardrail_retry_count property).
self._guardrail_retry_count = 0
self._last_guardrail_error = None
self._guardrail_fn = None
self._setup_guardrail()
@@ -2978,10 +2768,6 @@ Your Goal: {self.goal}
# Learning configuration (top-level, peer to memory)
# Stored for access via agent._learn_config
self._learn_config = _learn_config
# Raw learn= intent, so surfaces that default-enable learning (e.g. the
# gateway/bot) can tell "unset" (None) apart from an explicit opt-out
# (learn=False) — both of which leave _learn_config None (Issue #4864).
self._learn_enabled = learn
# Agent-centric feature instances (lazy loaded for zero performance impact)
self._auto_memory = auto_memory
@@ -3400,21 +3186,9 @@ Your Goal: {self.goal}
return self.__chat_history_state
def _append_to_chat_history(self, message: dict):
"""Thread-safe append to chat history using proper locking.
Also records the appended message into the current turn's per-turn
ownership tracker (when one is active) so a failing turn's rollback
removes only its own messages by identity and never a concurrent
turn's. This is the append path every real chat()/achat() turn uses,
so without recording here _rollback_chat_history_to would always take
the unsafe positional-delete branch.
"""
from .memory_mixin import _active_turn_owned
"""Thread-safe append to chat history using proper locking."""
with self._history_lock.lock():
self._history_lock.value.append(message)
owned = _active_turn_owned.get()
if owned is not None:
owned.append(message)
def _replace_chat_history(self, new_history: List[Dict[str, Any]]):
"""Thread-safe replacement of entire chat history using proper locking."""
@@ -4405,7 +4179,21 @@ Summary:"""
Returns:
True if a completion signal is detected
"""
import re
response_lower = response_text.lower()
# Negation patterns that should NOT be treated as completion
_NEGATION_RE = re.compile(
r'\b(?:not|never|no longer|hardly|barely|isn\'t|aren\'t|wasn\'t|weren\'t|hasn\'t|haven\'t|hadn\'t|won\'t|wouldn\'t|can\'t|couldn\'t|shouldn\'t|don\'t|doesn\'t|didn\'t)\b'
r'.{0,20}' # up to 20 chars between negation and keyword
)
# Word-boundary patterns to avoid substring false positives
_COMPLETION_PATTERNS = [
(re.compile(r'\btask\s+completed?\b'), False), # no negation check needed
(re.compile(r'\bcompleted\s+successfully\b'), False),
(re.compile(r'\ball\s+done\b'), False),
(re.compile(r'\bdone\b'), True), # 'done' needs negation check
(re.compile(r'\bfinished\b'), True), # 'finished' needs negation check
]
for pattern, needs_negation_check in _COMPLETION_PATTERNS:
match = pattern.search(response_lower)
if match:
@@ -4413,7 +4201,7 @@ Summary:"""
# Check if a negation word precedes the match within 30 chars
start = max(0, match.start() - 30)
prefix = response_lower[start:match.start()]
if _COMPLETION_NEGATION_RE.search(prefix):
if _NEGATION_RE.search(prefix):
continue # Skip — negated completion
# Also check "not X yet" pattern
end = min(len(response_lower), match.end() + 10)
@@ -6756,9 +6544,6 @@ Answer:"""
# ``self._tool_call_guardrails`` but nothing ever assigned it, so the
# whole ``validate_tool_call`` surface was dead code. Always define it.
self._tool_call_guardrails = []
# ``tool_execution._apply_tool_result_guardrails`` reads this to gate a
# tool's raw output before it re-enters the LLM context. Always define it.
self._tool_result_guardrails = []
if self.guardrail is None:
self._guardrail_fn = None
return
@@ -6835,17 +6620,6 @@ Answer:"""
):
self._tool_call_guardrails = [_fn]
# Register the tool-result surface the same way. Output-only (string/LLM)
# guardrails are excluded on purpose: running an LLM validation after
# every tool result would be a hot-path regression, mirroring the
# tool-call exclusion above.
if (
_fn is not None
and callable(getattr(_fn, "validate_tool_result", None))
and not getattr(_fn, "_praison_output_only", False)
):
self._tool_result_guardrails = [_fn]
def _process_handoffs(self):
"""Process handoffs and convert them to tools that can be used by the agent."""
# Import here to avoid circular imports
@@ -6898,22 +6672,6 @@ Answer:"""
except Exception as e:
logging.error(f"Failed to process handoff item {handoff_item}: {e}")
@property
def guardrail_retry_count(self) -> int:
"""How many times output validation has sent the model back for a retry.
Cumulative over the agent's lifetime, incremented once per rejected
answer that was handed back to the model (see
``_apply_guardrail_with_retry``). Read it to confirm a run converged in
one correction rather than burning ``max_guardrail_retries``.
"""
return getattr(self, "_guardrail_retry_count", 0)
@property
def last_guardrail_error(self):
"""The most recent rejection message a guardrail sent back to the model."""
return getattr(self, "_last_guardrail_error", None)
def _process_guardrail(self, task_output):
"""Process the guardrail validation for a task output.
@@ -6923,26 +6681,18 @@ Answer:"""
Returns:
GuardrailResult: The result of the guardrail validation
"""
from ..guardrails import GuardrailResult, GuardrailRetry
from ..guardrails import GuardrailResult
if not self._guardrail_fn:
return GuardrailResult(success=True, result=task_output)
try:
# Call the guardrail function
result = self._guardrail_fn(task_output)
# Convert the result to a GuardrailResult
return GuardrailResult.from_tuple(result)
except GuardrailRetry as e:
# Deliberate rejection with a message meant for the model. Keep the
# author's wording verbatim - it is fed back into the conversation
# by _apply_guardrail_with_retry - rather than wrapping it as an
# internal validation *error*.
logging.warning(f"Agent {self.name}: Guardrail asked for a retry: {e.feedback}")
return GuardrailResult(success=False, result=None, error=e.feedback)
except Exception as e:
logging.error(f"Agent {self.name}: Error in guardrail validation: {e}")
# On error, return failure
@@ -7005,133 +6755,48 @@ Answer:"""
else:
return False, None, guardrail_result.error
def _guardrail_retry_feedback(self, error):
"""The correction turn handed back to the model after a rejection.
Shared by the in-conversation retry and the legacy single-prompt
fallback so the instruction reads the same whichever transport the
configured provider uses.
"""
return (
f"Previous response failed validation due to: {error}. "
"Please provide an improved response."
)
def _extend_guardrail_conversation(self, conversation, response_text, feedback):
"""Append the rejected answer and the validator's reason to ``conversation``.
This is what makes the correction happen *inside* the same run: the
model sees its own answer plus why it was refused, so it patches that
answer instead of re-deriving one from the bare prompt. Extends (and
returns) the list in place, so successive retries accumulate the way a
real conversation does.
The assistant turn is skipped when the caller's conversation already
ends with it, which is the case for callers that hand over live chat
history rather than the pre-call message list.
"""
last = conversation[-1] if conversation else None
already_present = (
isinstance(last, dict)
and last.get("role") == "assistant"
and last.get("content") == response_text
)
if response_text and not already_present:
conversation.append({"role": "assistant", "content": response_text})
conversation.append({"role": "user", "content": feedback})
return conversation
def _record_guardrail_retry(self, *, attempt, error, delay):
"""Count and announce one guardrail retry.
``guardrail_retry_count`` makes the retries observable to callers and
tests; the RETRY stream event puts them on the same channel as
transient-failure retries, so a CLI/UI shows "retrying" rather than
appearing to hang.
"""
self._guardrail_retry_count = getattr(self, "_guardrail_retry_count", 0) + 1
self._last_guardrail_error = error
emit = getattr(self, "_emit_retry_stream_event", None)
if emit is not None:
emit(
attempt=attempt,
max_attempts=self.max_guardrail_retries,
delay=delay,
reason=f"guardrail validation failed: {error}",
)
def _guardrail_retry_delay(self, retry_count):
"""Backoff before the next guardrail retry (agent execution policy)."""
execution_config = getattr(self, '_execution_config', None)
if execution_config is not None:
return BackoffPolicy.delay(
retry_count,
execution_config.retry_initial_delay,
execution_config.retry_backoff_factor,
execution_config.retry_jitter
)
# Fall back to simple backoff if no execution config
return 1.0 * (2.0 ** (retry_count - 1))
def _apply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None, cancel_token=None, messages=None):
"""Apply guardrail validation with retry logic (sync version).
Args:
response_text: The answer to validate.
prompt: The originating prompt, used only by the fallback below.
messages: The conversation that produced ``response_text`` (as sent
to the model, without its reply). When supplied, a rejection is
appended to *that* conversation - rejected answer plus the
validator's reason - so the model corrects itself in the same
run with its context intact. When omitted (providers whose
message list the caller does not hold), the retry falls back to
re-asking the original prompt with the reason appended.
Bounded by ``max_guardrail_retries``; a validator that never passes
raises rather than looping.
"""
def _apply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None, cancel_token=None):
"""Apply guardrail validation with retry logic (sync version)."""
retry_count = 0
current_response = response_text
# Own copy: _chat_completion rewrites its messages list in place.
conversation = list(messages) if messages else None
while retry_count <= self.max_guardrail_retries:
success, result, error = self._validate_with_guardrail(current_response)
if success:
logging.info(f"Agent {self.name}: Guardrail validation passed")
return result
# Guardrail failed
if retry_count >= self.max_guardrail_retries:
raise Exception(
f"Agent {self.name} response failed guardrail validation after {self.max_guardrail_retries} retries. "
f"Last error: {error}"
)
retry_count += 1
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {error}")
# Add exponential backoff delay to avoid hammering the LLM
total_delay = self._guardrail_retry_delay(retry_count)
self._record_guardrail_retry(attempt=retry_count, error=error, delay=total_delay)
execution_config = getattr(self, '_execution_config', None)
if execution_config is not None:
total_delay = BackoffPolicy.delay(
retry_count,
execution_config.retry_initial_delay,
execution_config.retry_backoff_factor,
execution_config.retry_jitter
)
else:
# Fall back to simple backoff if no execution config
total_delay = 1.0 * (2.0 ** (retry_count - 1))
logging.info(f"Agent {self.name}: Waiting {total_delay:.2f}s before guardrail retry")
time.sleep(total_delay)
# Regenerate response for retry
try:
feedback = self._guardrail_retry_feedback(error)
if conversation is not None:
retry_messages = self._extend_guardrail_conversation(
conversation, current_response, feedback
)
else:
retry_messages = [{"role": "user", "content": f"{prompt}\n\nNote: {feedback}"}]
# stream=False: the retry reads a complete message and nothing
# consumes deltas, so streaming here only costs a failed
# sync-adapter attempt and an ERROR log before falling back.
response = self._chat_completion(list(retry_messages), temperature, tools, stream=False, task_name=task_name, task_description=task_description, task_id=task_id, cancel_token=cancel_token)
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {error}. Please provide an improved response."
response = self._chat_completion([{"role": "user", "content": retry_prompt}], temperature, tools, task_name=task_name, task_description=task_description, task_id=task_id, cancel_token=cancel_token)
if response and response.choices:
content = response.choices[0].message.content
current_response = content.strip() if content else ""
@@ -7140,19 +6805,14 @@ Answer:"""
except Exception as e:
logging.error(f"Agent {self.name}: Error during guardrail retry: {e}")
raise Exception(f"Agent {self.name} guardrail retry failed: {e}")
return current_response
async def _aapply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None, messages=None):
"""Apply guardrail validation with retry logic (async version).
See ``_apply_guardrail_with_retry`` for the ``messages`` contract.
"""
async def _aapply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None):
"""Apply guardrail validation with retry logic (async version)."""
retry_count = 0
current_response = response_text
# Own copy: the completion path rewrites its messages list in place.
conversation = list(messages) if messages else None
while retry_count <= self.max_guardrail_retries:
# A string/LLMGuardrail guardrail fires a *blocking* LLM call inside
# _validate_with_guardrail. Offload it to a thread so it does not
@@ -7171,42 +6831,41 @@ Answer:"""
lambda: self._validate_with_guardrail(current_response)
),
)
if success:
logging.info(f"Agent {self.name}: Guardrail validation passed")
return result
# Guardrail failed
if retry_count >= self.max_guardrail_retries:
raise Exception(
f"Agent {self.name} response failed guardrail validation after {self.max_guardrail_retries} retries. "
f"Last error: {error}"
)
retry_count += 1
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {error}")
# Add exponential backoff delay to avoid hammering the LLM
total_delay = self._guardrail_retry_delay(retry_count)
self._record_guardrail_retry(attempt=retry_count, error=error, delay=total_delay)
execution_config = getattr(self, '_execution_config', None)
if execution_config is not None:
total_delay = BackoffPolicy.delay(
retry_count,
execution_config.retry_initial_delay,
execution_config.retry_backoff_factor,
execution_config.retry_jitter
)
else:
# Fall back to simple backoff if no execution config
total_delay = 1.0 * (2.0 ** (retry_count - 1))
logging.info(f"Agent {self.name}: Waiting {total_delay:.2f}s before guardrail retry")
await asyncio.sleep(total_delay)
# Regenerate response for retry (async version)
try:
feedback = self._guardrail_retry_feedback(error)
if conversation is not None:
retry_messages = self._extend_guardrail_conversation(
conversation, current_response, feedback
)
else:
retry_messages = [{"role": "user", "content": f"{prompt}\n\nNote: {feedback}"}]
# stream=False: the retry consumes a complete message, and the
# helper's own default is stream=True regardless of the agent's
# setting, which would make the retry stream when nothing reads
# the deltas.
response = await self._execute_unified_achat_completion(list(retry_messages), temperature, tools, stream=False, task_name=task_name, task_description=task_description, task_id=task_id)
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {error}. Please provide an improved response."
response = await self._execute_unified_achat_completion([{"role": "user", "content": retry_prompt}], temperature, tools, task_name=task_name, task_description=task_description, task_id=task_id)
if response and hasattr(response, 'choices') and response.choices:
content = response.choices[0].message.content
current_response = content.strip() if content else ""
@@ -7217,9 +6876,9 @@ Answer:"""
except Exception as e:
logging.error(f"Agent {self.name}: Error during guardrail retry: {e}")
raise Exception(f"Agent {self.name} guardrail retry failed: {e}")
return current_response
def _get_tools_cache_key(self, tools):
"""Generate a cache key for tools list."""
if tools is None:
@@ -1,443 +0,0 @@
"""Attachment routing for multimodal prompts.
The one invariant this module exists to enforce: **an attachment is never
dropped silently**. Every input either becomes a model-visible content part, or
produces a loud, actionable signal that names the file and says why.
Two failure classes are treated differently on purpose:
``raise`` -- *addressing* errors: the path does not exist, is a directory, is
unreadable, or the attachment is not a type this API accepts. These are
always caller mistakes (a typo, a wrong working directory, a stale path).
They are fully actionable at the call site, there is no useful degraded
answer, and continuing would produce a confident answer about a file the
model never saw. Raising is the only way the caller can tell.
``warn + visible degrade`` -- *capability* limits: the file is there and
readable, but the target model cannot ingest that media type. The run can
still be useful, so we log a warning **and** put a marker part in the
prompt so the model itself knows a file was withheld or converted and can
say so. This is environmental, not a bug in the caller's code, and hard
failing would make a model swap a breaking change.
Escape hatches (both default to the behaviour described above):
- ``PRAISONAI_ATTACHMENTS_ON_MISSING=warn`` downgrades the missing/unreadable
raise to a warning + visible marker part.
- ``PRAISONAI_ATTACHMENTS_STRICT=1`` escalates capability degrades to raises,
for pipelines that would rather fail than get a partial answer.
Part shapes emitted (OpenAI/LiteLLM chat-completions style):
- image -> ``{"type": "image_url", "image_url": {"url": "data:...;base64,..."}}``
- pdf native -> ``{"type": "file", "file": {"filename": ..., "file_data": "data:application/pdf;base64,..."}}``
- audio -> ``{"type": "input_audio", "input_audio": {"data": "<b64>", "format": "mp3"}}``
- text/degraded/withheld -> ``{"type": "text", "text": "[Attachment: ...] ..."}``
"""
import base64
import logging
import os
from typing import Any, Dict, List, Optional
from ..tools.base import file_part, image_part, text_part
__all__ = [
"AttachmentError",
"build_attachment_parts",
"resolve_attachment_model_name",
"IMAGE_EXTENSIONS",
"PDF_EXTENSIONS",
"AUDIO_EXTENSIONS",
"VIDEO_EXTENSIONS",
"TEXT_EXTENSIONS",
]
class AttachmentError(ValueError):
"""Raised when an attachment cannot be turned into a model content part.
Subclasses ``ValueError`` so existing ``except ValueError`` handlers around
``chat()`` keep working.
"""
# --- Type tables -----------------------------------------------------------
# Unchanged from the original image-only implementation; images must keep
# producing byte-identical parts.
IMAGE_MEDIA_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
}
IMAGE_EXTENSIONS = frozenset(IMAGE_MEDIA_TYPES)
PDF_EXTENSIONS = frozenset({".pdf"})
# Extension -> the ``format`` string sent in an ``input_audio`` part.
AUDIO_FORMATS = {
".mp3": "mp3",
".wav": "wav",
".m4a": "m4a",
".ogg": "ogg",
".oga": "ogg",
".opus": "opus",
".flac": "flac",
".aac": "aac",
}
AUDIO_EXTENSIONS = frozenset(AUDIO_FORMATS)
VIDEO_EXTENSIONS = frozenset({".mp4", ".mov", ".avi", ".mkv", ".webm", ".mpeg", ".mpg", ".wmv"})
# Files we can safely inline as UTF-8 text. No model capability needed.
TEXT_EXTENSIONS = frozenset({
".txt", ".md", ".markdown", ".rst", ".log", ".csv", ".tsv", ".json",
".jsonl", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".xml",
".html", ".htm", ".sql", ".py", ".js", ".ts", ".tsx", ".jsx", ".java",
".c", ".h", ".cpp", ".hpp", ".cs", ".go", ".rs", ".rb", ".php", ".swift",
".kt", ".sh", ".bash", ".zsh", ".env", ".tex", ".srt", ".vtt",
})
# Inlined text is truncated so one large file cannot blow the context window.
# Truncation is announced in the part itself, never silent.
MAX_INLINE_TEXT_CHARS = 100_000
# --- Helpers ---------------------------------------------------------------
def _on_missing_raises() -> bool:
return os.environ.get("PRAISONAI_ATTACHMENTS_ON_MISSING", "error").strip().lower() not in (
"warn", "warning", "ignore", "0", "false",
)
def _strict_unsupported() -> bool:
return os.environ.get("PRAISONAI_ATTACHMENTS_STRICT", "").strip().lower() in (
"1", "true", "yes", "on", "error", "raise",
)
def _fail(message: str) -> List[Dict[str, Any]]:
"""Addressing error: raise, unless explicitly downgraded to a warning."""
if _on_missing_raises():
raise AttachmentError(message)
logging.warning("Attachment problem: %s", message)
return [text_part(f"[Attachment could not be read: {message}]")]
def _degrade(message: str, marker: str) -> List[Dict[str, Any]]:
"""Capability limit: warn loudly and leave a marker the model can see."""
if _strict_unsupported():
raise AttachmentError(message + " (PRAISONAI_ATTACHMENTS_STRICT is set)")
logging.warning("Attachment degraded: %s", message)
return [text_part(marker)]
def _read_bytes(path: str) -> bytes:
with open(path, "rb") as fh:
return fh.read()
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode("utf-8")
def _extract_pdf_text(path: str) -> Optional[str]:
"""Return a PDF's text, or ``None`` if no extractor is installed/usable."""
try:
from pypdf import PdfReader # type: ignore
except ImportError:
try:
from PyPDF2 import PdfReader # type: ignore
except ImportError:
return None
try:
reader = PdfReader(path)
pages = []
for index, page in enumerate(reader.pages, start=1):
try:
pages.append(f"--- page {index} ---\n{page.extract_text() or ''}")
except Exception as exc: # noqa: BLE001 - one bad page must not kill the rest
logging.debug("PDF page %d of %s failed to extract: %s", index, path, exc)
text = "\n".join(pages).strip()
return text or None
except Exception as exc: # noqa: BLE001
logging.debug("PDF text extraction failed for %s: %s", path, exc)
return None
def _truncate(text: str, label: str) -> str:
if len(text) <= MAX_INLINE_TEXT_CHARS:
return text
logging.warning(
"Attachment %s truncated to %d of %d characters", label, MAX_INLINE_TEXT_CHARS, len(text)
)
return (
text[:MAX_INLINE_TEXT_CHARS]
+ f"\n\n[... truncated: {len(text) - MAX_INLINE_TEXT_CHARS} more characters not shown ...]"
)
def resolve_attachment_model_name(agent: Any) -> str:
"""Best-effort model id for capability routing.
Mirrors the resolution already used by the agent's other capability probes
(``_model_supports_web_search`` and friends).
"""
instance = getattr(agent, "llm_instance", None)
model = getattr(instance, "model", None) if instance else None
if isinstance(model, str) and model:
return model
llm = getattr(agent, "llm", None)
if isinstance(llm, str) and llm:
return llm
if isinstance(llm, dict):
model = llm.get("model")
if isinstance(model, str) and model:
return model
return ""
# --- Per-type builders -----------------------------------------------------
def _image_message_part(path: str, ext: str) -> Dict[str, Any]:
"""Encode an image exactly as the original implementation did."""
media_type = IMAGE_MEDIA_TYPES.get(ext, "image/jpeg")
encoded = _b64(_read_bytes(path))
# Canonical part first (shared vocabulary with tools/base.py), then the
# provider-facing shape, so there is one definition of "an image part".
canonical = image_part(encoded, mime=media_type, name=os.path.basename(path))
logging.debug(
"Successfully encoded image attachment: %s (%d bytes base64)", path, len(encoded)
)
return {
"type": "image_url",
"image_url": {"url": f"data:{canonical['mime']};base64,{canonical['data']}"},
}
def _pdf_parts(path: str, model_name: str) -> List[Dict[str, Any]]:
name = os.path.basename(path)
if _capability("pdf", model_name):
canonical = file_part(
_b64(_read_bytes(path)), mime="application/pdf", name=name
)
return [{
"type": "file",
"file": {
"filename": canonical["name"],
"file_data": f"data:{canonical['mime']};base64,{canonical['data']}",
},
}]
text = _extract_pdf_text(path)
if text:
logging.warning(
"Model %r cannot accept PDF attachments natively; sending locally "
"extracted text of %s instead. Use a PDF-capable model (e.g. an "
"Anthropic Claude or OpenAI gpt-4o/4.1 model) to send the document itself.",
model_name or "<unknown>", path,
)
body = _truncate(text, name)
return [text_part(
f"[Attachment: {name} - PDF text extracted locally because model "
f"{model_name or '<unknown>'!r} cannot accept PDF files directly; "
f"layout, images and tables are lost]\n{body}"
)]
return _degrade(
f"{path}: model {model_name or '<unknown>'!r} cannot accept PDFs natively and the "
f"PDF's text could not be extracted (install 'pypdf', or use a PDF-capable model)",
f"[Attachment omitted: {name} - the PDF could not be sent to model "
f"{model_name or '<unknown>'!r} and its text could not be extracted locally. "
f"Answer without it and say so.]",
)
def _audio_parts(path: str, ext: str, model_name: str) -> List[Dict[str, Any]]:
name = os.path.basename(path)
if _capability("audio", model_name):
canonical = file_part(
_b64(_read_bytes(path)),
mime=f"audio/{AUDIO_FORMATS.get(ext, 'mpeg')}",
name=name,
)
return [{
"type": "input_audio",
"input_audio": {
"data": canonical["data"],
"format": AUDIO_FORMATS.get(ext, "mp3"),
},
}]
return _degrade(
f"{path}: model {model_name or '<unknown>'!r} cannot accept audio input "
f"(use an audio-capable model such as gpt-4o-audio-preview or a Gemini model, "
f"or transcribe the file first)",
f"[Attachment omitted: {name} - audio was not sent because model "
f"{model_name or '<unknown>'!r} cannot accept audio input. Answer without "
f"it and say so.]",
)
def _text_parts(path: str) -> List[Dict[str, Any]]:
name = os.path.basename(path)
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read()
except OSError as exc:
return _fail(f"{path}: could not be read ({exc})")
return [text_part(f"[Attachment: {name}]\n{_truncate(content, name)}")]
def _capability(kind: str, model_name: str) -> bool:
"""Capability probe, isolated so a broken/absent litellm never crashes a run."""
try:
from ..llm.model_capabilities import supports_audio_input, supports_pdf_input
except Exception: # noqa: BLE001
return False
try:
if kind == "pdf":
return bool(supports_pdf_input(model_name))
if kind == "audio":
return bool(supports_audio_input(model_name))
except Exception as exc: # noqa: BLE001
logging.debug("Capability probe %s/%s failed: %s", kind, model_name, exc)
return False
# --- Entry point -----------------------------------------------------------
def build_attachment_parts(attachment: Any, model_name: str = "") -> List[Dict[str, Any]]:
"""Turn one attachment into zero-or-more model content parts.
Never returns an empty list for an input it could not handle: it either
raises :class:`AttachmentError` or returns a visible marker part.
"""
# Already-structured content parts pass through untouched.
if isinstance(attachment, dict):
if "type" not in attachment:
logging.warning(
"Attachment dict has no 'type' key and may be ignored by the provider: %r",
attachment,
)
return [attachment]
if isinstance(attachment, os.PathLike):
attachment = os.fspath(attachment)
if not isinstance(attachment, str):
raise AttachmentError(
f"Unsupported attachment type {type(attachment).__name__!r}: expected a file "
f"path, an http(s)/data URL, or a content-part dict, got {attachment!r}"
)
if not attachment.strip():
raise AttachmentError("Empty attachment path")
if attachment.startswith(("http://", "https://", "data:")):
return _remote_parts(attachment, model_name)
if os.path.isdir(attachment):
return _fail(f"{attachment}: is a directory, not a file")
if not os.path.isfile(attachment):
return _fail(
f"{attachment}: no such file (attachment paths are resolved relative to the "
f"current working directory {os.getcwd()!r})"
)
ext = os.path.splitext(attachment)[1].lower()
name = os.path.basename(attachment)
try:
if ext in IMAGE_EXTENSIONS:
return [_image_message_part(attachment, ext)]
if ext in PDF_EXTENSIONS:
return _pdf_parts(attachment, model_name)
if ext in AUDIO_EXTENSIONS:
return _audio_parts(attachment, ext, model_name)
if ext in TEXT_EXTENSIONS:
return _text_parts(attachment)
except AttachmentError:
raise
except OSError as exc:
return _fail(f"{attachment}: could not be read ({exc})")
if ext in VIDEO_EXTENSIONS:
return _degrade(
f"{attachment}: video attachments are not supported on the chat-completions "
f"path (extract frames as images, or a transcript as text, first)",
f"[Attachment omitted: {name} - video attachments are not supported. "
f"Answer without it and say so.]",
)
# Unknown extension. Rather than guess at a binary, try it as text when it
# decodes cleanly as UTF-8; otherwise say plainly that it was not sent.
sample_text = _maybe_text_file(attachment)
if sample_text is not None:
logging.warning(
"Attachment %s has an unrecognised extension %r; inlining it as UTF-8 text.",
attachment, ext or "(none)",
)
return [text_part(f"[Attachment: {name} (unrecognised type, sent as text)]\n"
f"{_truncate(sample_text, name)}")]
return _degrade(
f"{attachment}: unsupported attachment type {ext or '(no extension)'!r}. Supported: "
f"images {sorted(IMAGE_EXTENSIONS)}, PDF, audio {sorted(AUDIO_EXTENSIONS)}, and "
f"text-like files. Convert the file, or pass its text instead.",
f"[Attachment omitted: {name} - unsupported file type "
f"{ext or '(no extension)'!r}. Answer without it and say so.]",
)
def _maybe_text_file(path: str) -> Optional[str]:
"""Return the file's text if it is valid UTF-8 and not obviously binary."""
try:
raw = _read_bytes(path)
except OSError:
return None
if b"\x00" in raw[:8192]:
return None
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
return None
def _remote_parts(url: str, model_name: str) -> List[Dict[str, Any]]:
"""Route an http(s) URL or data URI.
Images (and anything without a recognisable non-image extension) keep the
original behaviour: a straight ``image_url`` part. A URL that plainly names
a non-image document is called out instead of being passed off as an image.
"""
if url.startswith("data:"):
mime = url[5:].split(";", 1)[0].split(",", 1)[0].lower()
if mime and not mime.startswith("image/"):
return _degrade(
f"data URI with media type {mime!r} is not supported as an attachment; "
f"only image data URIs are passed through",
f"[Attachment omitted: a {mime} data URI was not sent to the model. "
f"Answer without it and say so.]",
)
return [{"type": "image_url", "image_url": {"url": url}}]
path = url.split("?", 1)[0].split("#", 1)[0]
ext = os.path.splitext(path)[1].lower()
if ext and ext not in IMAGE_EXTENSIONS and (
ext in PDF_EXTENSIONS or ext in AUDIO_EXTENSIONS
or ext in VIDEO_EXTENSIONS or ext in TEXT_EXTENSIONS
):
return _degrade(
f"{url}: remote {ext} attachments are not fetched (this function performs no "
f"network I/O); download the file and pass the local path instead",
f"[Attachment omitted: {url} - remote {ext} files are not downloaded "
f"automatically. Answer without it and say so.]",
)
return [{"type": "image_url", "image_url": {"url": url}}]
@@ -95,51 +95,6 @@ class ChatMixin:
return _TurnCancelToken(source)
return _TurnCancelToken(source, turn_id=begin_turn())
@staticmethod
def _notify_tool_call(tool_name, tool_input, tool_result, elapsed_time=None, success=True):
"""Fire the ``tool_call`` display callback for one tool execution.
The non-streaming path fires this from the OpenAI client after every
tool (``display_tool_call`` -> ``execute_sync_callback('tool_call')``),
which is how streaming UIs such as the desktop learn that a tool ran.
The streaming path executes tools inline here, so it has to fire the
same callback itself with the same kwargs (Issue #4716).
Best-effort by design: a callback failure is logged and swallowed so
it can never break the stream it is reporting on.
A durable run converts a raising tool into an ``{"error": ...}`` result
instead of re-raising (see ``DurableRunContext.wrap_sync``), so the
raising-tool ``except`` branch never fires for it. Detect that shape
here and report ``success=False`` so streaming UIs do not label a failed
tool as successful (Issue #4735 review).
"""
if (
success
and isinstance(tool_result, dict)
and tool_result.get("error") is not None
):
success = False
try:
if tool_result is None:
result_str = None
else:
try:
result_str = json.dumps(tool_result)
except (TypeError, ValueError):
result_str = str(tool_result)
_get_display_functions()['execute_sync_callback'](
'tool_call',
message=f"Calling function: {tool_name}",
tool_name=tool_name,
tool_input=tool_input,
tool_output=result_str[:200] if result_str else None,
elapsed_time=elapsed_time,
success=success,
)
except Exception as callback_error:
logging.debug(f"tool_call callback failed for '{tool_name}': {callback_error}")
def _durable_sync_tool_executor(self, execute_tool_fn):
"""Wrap tool execution only while an opt-in durable run is active."""
context = self._get_durable_run_context()
@@ -1203,37 +1158,56 @@ Your Goal: {self.goal}"""
This is a DRY helper used by chat/achat/run/arun/start/astart.
Attachments are ephemeral - only text is stored in history.
Routing lives in ``praisonaiagents.agent.attachments``. The contract is
that an attachment is **never dropped silently**: a bad path raises
:class:`~praisonaiagents.agent.attachments.AttachmentError`, and a file
the target model cannot ingest is logged as a warning *and* replaced
with a visible marker part (or a locally extracted text form) so both
the caller and the model know what happened.
Args:
prompt: Text query (ALWAYS stored in chat_history)
attachments: Image/document/audio paths, URLs, or content-part
dicts for THIS turn only (NEVER stored)
attachments: Image/file paths for THIS turn only (NEVER stored)
Returns:
Either a string (no attachments) or multimodal message list
Raises:
AttachmentError: for a path that does not exist or cannot be read,
or an attachment that is not a path/URL/content-part dict.
"""
if not attachments:
return prompt
from .attachments import build_attachment_parts, resolve_attachment_model_name
model_name = resolve_attachment_model_name(self)
# Build multimodal content list
content = [{"type": "text", "text": prompt}]
for attachment in attachments:
content.extend(build_attachment_parts(attachment, model_name))
# Handle image files
if isinstance(attachment, str):
import os
import base64
if os.path.isfile(attachment):
# File path - read and encode
ext = os.path.splitext(attachment)[1].lower()
if ext in ('.jpg', '.jpeg', '.png', '.gif', '.webp'):
try:
with open(attachment, 'rb') as f:
data = base64.b64encode(f.read()).decode('utf-8')
media_type = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
}.get(ext, 'image/jpeg')
content.append({
"type": "image_url",
"image_url": {"url": f"data:{media_type};base64,{data}"}
})
logging.debug(f"Successfully encoded image attachment: {attachment} ({len(data)} bytes base64)")
except Exception as e:
logging.warning(f"Failed to load attachment {attachment}: {e}")
elif attachment.startswith(('http://', 'https://', 'data:')):
# URL or data URI
content.append({
"type": "image_url",
"image_url": {"url": attachment}
})
elif isinstance(attachment, dict):
# Already structured content
content.append(attachment)
return content
def _extract_llm_response_content(self, response) -> Optional[str]:
@@ -1664,47 +1638,6 @@ Your Goal: {self.goal}"""
return None
return self.fallback_models[fallback_index]
def _build_fallback_dispatcher(self, model):
"""Build a throwaway dispatcher bound to a fallback ``model``.
The async fallback path passes the result to
``_execute_unified_achat_completion(..., _dispatcher=...)`` for a single
retry so it NEVER mutates the shared ``self.llm`` /
``self._unified_dispatcher``. Concurrent async turns on the same Agent
therefore keep dispatching on the original model with correct cost /
observability attribution — closing the ordinary-turn fallback race.
Returns ``None`` for custom-LLM agents (the custom client owns its own
model), which keeps that path on the shared dispatcher as before.
"""
from ..llm import create_llm_dispatcher
if self._using_custom_llm and hasattr(self, 'llm_instance'):
return None
if not hasattr(self, '_openai_client') or self._openai_client is None:
from ..llm import get_openai_client
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
return create_llm_dispatcher(openai_client=self._openai_client, model=model)
def _get_model_fallback_lock(self):
"""Lazily create the per-instance lock guarding model-fallback swaps.
Async fallback now runs against a per-call throwaway dispatcher
(``_build_fallback_dispatcher``), so it does not mutate shared state and
ordinary async turns can never observe the fallback model. The sync
fallback path still recurses through the full ``_chat_completion``
wrapper (which reads ``self.llm`` for BEFORE_LLM hook attribution and
budget), so it temporarily overrides ``self.llm`` /
``self._unified_dispatcher`` for a single retry. This dual (sync/async)
lock serialises that mutate/restore window; normal, non-fallback turns
never acquire it and stay fully concurrent. Created on demand so no
extra state is added for agents without ``fallback_models``.
"""
lock = getattr(self, '_model_fallback_lock', None)
if lock is None:
from .async_safety import DualLock
lock = DualLock()
self._model_fallback_lock = lock
return lock
def _emit_model_fallback(self, from_model, to_model, reason_category, fallback_index,
stream_callback=None):
"""Surface a mid-turn model fallback as an observable state transition.
@@ -2161,26 +2094,22 @@ Your Goal: {self.goal}"""
if classification.backoff_seconds and classification.backoff_seconds > 0:
time.sleep(classification.backoff_seconds)
# Temporarily override the model and clear dispatcher cache
# for this call. Serialise the mutate/restore window so a
# concurrent turn on a shared Agent can't observe the wrong
# model; only fallback turns contend on this lock.
with self._get_model_fallback_lock().sync():
original_llm = self.llm
original_dispatcher = getattr(self, '_unified_dispatcher', None)
try:
self.llm = next_model
self._unified_dispatcher = None # Force recreation with new model
return self._chat_completion(
messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
_retry_depth=_retry_depth + 1,
_fallback_index=_fallback_index + 1,
cancel_token=cancel_token
)
finally:
self.llm = original_llm
self._unified_dispatcher = original_dispatcher # Restore original dispatcher
# Temporarily override the model and clear dispatcher cache for this call
original_llm = self.llm
original_dispatcher = getattr(self, '_unified_dispatcher', None)
try:
self.llm = next_model
self._unified_dispatcher = None # Force recreation with new model
return self._chat_completion(
messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
_retry_depth=_retry_depth + 1,
_fallback_index=_fallback_index + 1,
cancel_token=cancel_token
)
finally:
self.llm = original_llm
self._unified_dispatcher = original_dispatcher # Restore original dispatcher
else:
logging.warning(f"[{self.name}] {classification.user_message} (no more fallback models available)")
# Continue to error handling without retry
@@ -2343,21 +2272,16 @@ Your Goal: {self.goal}"""
if classification.backoff_seconds and classification.backoff_seconds > 0:
await asyncio.sleep(classification.backoff_seconds)
# Run this single retry against a throwaway dispatcher bound to
# the fallback model, passed per-call. This never mutates the
# shared self.llm / self._unified_dispatcher, so a concurrent
# async turn on this shared Agent keeps dispatching on the
# original model (correct cost / observability attribution) even
# while this turn is suspended on the await — no lock needed on
# the async path. Custom-LLM agents get None here and fall back
# to the shared dispatcher, matching prior behaviour.
_fallback_dispatcher = self._build_fallback_dispatcher(next_model)
# Temporarily override the model and clear dispatcher cache for this call
original_llm = self.llm
original_dispatcher = getattr(self, '_unified_dispatcher', None)
try:
self.llm = next_model
self._unified_dispatcher = None # Force recreation with new model
return await self._execute_unified_achat_completion(
messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
stream_callback, emit_events,
_dispatcher=_fallback_dispatcher,
stream_callback, emit_events
)
except Exception as retry_error:
return await self._handle_async_llm_error(
@@ -2365,6 +2289,9 @@ Your Goal: {self.goal}"""
reasoning_steps, task_name, task_description, task_id, response_format,
stream_callback, emit_events, _retry_depth + 1, _fallback_index + 1
)
finally:
self.llm = original_llm
self._unified_dispatcher = original_dispatcher # Restore original dispatcher
else:
logging.warning(f"[{self.name}] {classification.user_message} (no more fallback models available)")
# Continue to error handling without retry
@@ -2572,43 +2499,29 @@ Your Goal: {self.goal}"""
response_format=None,
stream_callback=None,
emit_events=True,
_dispatcher=None,
):
"""
Execute unified async chat completion using composition instead of runtime class mutation.
This method provides a unified async dispatch for all LLM providers using
composition for safety and maintainability.
``_dispatcher`` lets the model-fallback path run a single retry against a
caller-supplied throwaway dispatcher WITHOUT mutating the shared
``self.llm`` / ``self._unified_dispatcher``, so concurrent async turns on
the same Agent never observe another turn's fallback model.
"""
from ..llm import create_llm_dispatcher
# Resolve the dispatcher for THIS call. A caller-supplied dispatcher
# (async model fallback) is used as-is and never cached onto the
# instance, so the shared cached dispatcher is left intact for
# concurrent turns.
dispatcher = _dispatcher
if dispatcher is None:
# Get or create the shared cached dispatcher
if not hasattr(self, '_unified_dispatcher') or self._unified_dispatcher is None:
# Provider selection based on existing agent configuration
if self._using_custom_llm and hasattr(self, 'llm_instance'):
dispatcher = create_llm_dispatcher(llm_instance=self.llm_instance)
else:
# Initialize OpenAI client if not present
if not hasattr(self, '_openai_client') or self._openai_client is None:
from ..llm import get_openai_client
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
dispatcher = create_llm_dispatcher(openai_client=self._openai_client, model=self.llm)
# Cache the dispatcher
self._unified_dispatcher = dispatcher
# Get or create unified dispatcher
if not hasattr(self, '_unified_dispatcher') or self._unified_dispatcher is None:
# Provider selection based on existing agent configuration
if self._using_custom_llm and hasattr(self, 'llm_instance'):
dispatcher = create_llm_dispatcher(llm_instance=self.llm_instance)
else:
dispatcher = self._unified_dispatcher
# Initialize OpenAI client if not present
if not hasattr(self, '_openai_client') or self._openai_client is None:
from ..llm import get_openai_client
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
dispatcher = create_llm_dispatcher(openai_client=self._openai_client, model=self.llm)
# Cache the dispatcher
self._unified_dispatcher = dispatcher
# Pre-call budget guard (parity with sync _chat_completion). Bots and
# other async callers route through here, not _chat_completion.
@@ -2664,7 +2577,7 @@ Your Goal: {self.goal}"""
try:
if stream_callback is None and hasattr(self, 'stream_emitter'):
stream_callback = getattr(self.stream_emitter, 'emit', None)
final_response = await dispatcher.achat_completion(
final_response = await self._unified_dispatcher.achat_completion(
messages=messages,
tools=tools,
tool_choice=getattr(self, 'tool_choice', None),
@@ -3274,10 +3187,6 @@ Your Goal: {self.goal}"""
prompt = res.output.modified_input["prompt"]
llm_prompt = self._build_multimodal_prompt(prompt, attachments) if attachments else prompt
# Track execution via telemetry
if hasattr(self, '_telemetry') and self._telemetry:
self._telemetry.track_agent_execution(self.name, success=True)
# Reset the final display flag for each new conversation
self._final_display_shown = False
@@ -3514,7 +3423,7 @@ Your Goal: {self.goal}"""
# Apply guardrail validation for custom LLM response
try:
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=processed_history)
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
# Execute callback and display after validation
self._execute_callback_and_display(prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return self._trigger_after_agent_hook(prompt, validated_response, start_time)
@@ -3638,7 +3547,7 @@ Your Goal: {self.goal}"""
self._persist_message("assistant", response_text)
# Apply guardrail validation even for JSON output
try:
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=messages)
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
@@ -3659,7 +3568,7 @@ Your Goal: {self.goal}"""
if reasoning_steps and hasattr(response.choices[0].message, 'reasoning_content') and response.choices[0].message.reasoning_content:
# Apply guardrail to reasoning content
try:
validated_reasoning = self._apply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=messages)
validated_reasoning = self._apply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_reasoning, time.time() - start_time, task_name, task_description, task_id)
return self._trigger_after_agent_hook(original_prompt, validated_reasoning, start_time)
@@ -3670,7 +3579,7 @@ Your Goal: {self.goal}"""
return None
# Apply guardrail to regular response
try:
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=messages)
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
@@ -3736,7 +3645,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._append_to_chat_history({"role": "assistant", "content": response_text})
# Apply guardrail validation after satisfactory reflection
try:
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=messages)
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
self._end_run(validated_response, "completed", {"duration_ms": (time.time() - start_time) * 1000})
@@ -3756,7 +3665,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._append_to_chat_history({"role": "assistant", "content": response_text})
# Apply guardrail validation after max reflections
try:
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=messages)
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
@@ -3792,7 +3701,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
_get_display_functions()['display_self_reflection']("Maximum reflection count reached after repeated parse errors, returning current response", console=self.console)
self._append_to_chat_history({"role": "assistant", "content": response_text})
try:
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=messages)
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as guard_e:
@@ -4037,18 +3946,18 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._ensure_knowledge_processed()
if not skip_retrieval and self.knowledge:
# Use the unified, already-correct normalization helper (handles
# the SearchResult dataclass, dict, and list shapes). It is
# synchronous (Mem0 embedding + vector-store I/O), so offload it
# to a thread to avoid blocking the event loop for coroutines
# sharing the loop.
knowledge_context, _ = await asyncio.to_thread(
self._get_knowledge_context,
prompt if isinstance(prompt, str) else str(prompt),
True
# Knowledge.search is synchronous (Mem0 embedding + vector-store
# I/O). Offload to a thread so it never blocks the event loop for
# other coroutines sharing the loop.
search_results = await asyncio.to_thread(
self.knowledge.search, prompt, agent_id=self.agent_id
)
if knowledge_context:
prompt = f"{prompt}\n\nKnowledge: {knowledge_context}"
if search_results:
if isinstance(search_results, dict) and 'results' in search_results:
knowledge_content = "\n".join([result['memory'] for result in search_results['results']])
else:
knowledge_content = "\n".join(search_results)
prompt = f"{prompt}\n\nKnowledge: {knowledge_content}"
if self._using_custom_llm:
# Store chat history length for potential rollback
@@ -4166,7 +4075,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Apply guardrail validation for custom LLM response
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id, messages=effective_history)
validated_response = await self._aapply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(normalized_content, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(prompt, validated_response, start_time)
@@ -4302,7 +4211,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
await self._apersist_message("assistant", response_text)
# Apply guardrail validation even for JSON output
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, messages=messages)
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
@@ -4325,7 +4234,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if reasoning_steps and hasattr(response, 'choices') and response.choices and hasattr(response.choices[0].message, 'reasoning_content') and response.choices[0].message.reasoning_content:
# Apply guardrail to reasoning content
try:
validated_reasoning = await self._aapply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id, messages=messages)
validated_reasoning = await self._aapply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_reasoning, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_reasoning, start_time)
@@ -4337,7 +4246,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
else:
# Apply guardrail to regular response content
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, messages=messages)
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
@@ -4377,7 +4286,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Persist assistant message to DB (offloaded to a worker thread)
await self._apersist_message("assistant", response_text)
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, messages=messages)
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
@@ -4411,7 +4320,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Persist assistant message to DB (offloaded to a worker thread)
await self._apersist_message("assistant", response_text)
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, messages=messages)
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
@@ -4430,7 +4339,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Persist assistant message to DB (offloaded to a worker thread)
await self._apersist_message("assistant", response_text)
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, messages=messages)
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
@@ -4480,7 +4389,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Persist assistant message to DB (offloaded to a worker thread)
await self._apersist_message("assistant", response_text)
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, messages=messages)
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
@@ -4644,7 +4553,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Apply guardrail validation for OpenAI client response
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, messages=messages)
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
@@ -4660,10 +4569,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if get_logger().getEffectiveLevel() == logging.DEBUG:
total_time = time.time() - start_time
logging.debug(f"Agent.achat failed in {total_time:.2f} seconds: {str(e)}")
# Rollback chat history so a failed async turn does not leave
# a dangling, unanswered user message (mirrors the sync path
# and achat()'s own guardrail handler).
self._rollback_chat_history_to(chat_history_length)
return None
except ToolExecutionError:
raise
@@ -4672,9 +4577,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if get_logger().getEffectiveLevel() == logging.DEBUG:
total_time = time.time() - start_time
logging.debug(f"Agent.achat failed in {total_time:.2f} seconds: {str(e)}")
# Rollback chat history so a failed async turn does not leave a
# dangling, unanswered user message (mirrors the sync path).
self._rollback_chat_history_to(chat_history_length)
return None
async def _achat_completion(self, response, tools, reasoning_steps=False):
@@ -4898,15 +4800,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
original_verbose = self.verbose
self.verbose = False
memory_prefetch_context = self._prefetch_memory(prompt)
# Ephemeral attachments (images / data URIs) are folded into a
# multimodal prompt so streaming turns reach vision models too,
# matching chat()/achat(). The text prompt is kept intact through
# knowledge retrieval below and the multimodal content is built
# afterwards, so knowledge augmentation never flattens the image.
# Only the text is stored in history.
attachments = kwargs.get('attachments')
# For custom LLM path, use the new get_response_stream generator
if self._using_custom_llm:
# Handle knowledge search
@@ -4915,15 +4809,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._ensure_knowledge_processed()
if self.knowledge:
knowledge_context, _ = self._get_knowledge_context(
prompt if isinstance(prompt, str) else str(prompt),
use_rag=True
)
if knowledge_context:
actual_prompt = f"{prompt}\n\nKnowledge: {knowledge_context}"
if attachments:
actual_prompt = self._build_multimodal_prompt(actual_prompt, attachments)
search_results = self.knowledge.search(prompt, agent_id=self.agent_id)
if search_results:
if isinstance(search_results, dict) and 'results' in search_results:
knowledge_content = "\n".join([result['memory'] for result in search_results['results']])
else:
knowledge_content = "\n".join(search_results)
actual_prompt = f"{prompt}\n\nKnowledge: {knowledge_content}"
# Handle tools properly
tools = kwargs.get('tools', self.tools)
@@ -4964,47 +4856,18 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
try:
# Use the new streaming generator from LLM class
response_content = ""
# Compact before sending, exactly as the OpenAI-client
# branch below does. #4729 added compaction to that branch
# only, so a configured ContextManager -- auto-enabled
# whenever tools are present -- was still skipped for every
# agent routed through the custom-LLM path. The trigger is
# the routing flag, not the vendor: llm="openai/gpt-4o-mini"
# takes this branch too. Zero overhead when context=False.
stream_system_prompt = self._build_system_prompt(
tool_param,
memory_prefetch_context=memory_prefetch_context,
)
if self.context_manager:
stream_history, _ = self._apply_context_management(
messages=self.chat_history,
system_prompt=stream_system_prompt or "",
tools=tool_param,
)
stream_history = list(stream_history)
else:
stream_history = self.chat_history
stream_history = self.chat_history
durable_context = self._get_durable_run_context()
if durable_context is not None:
stream_history = durable_context.restore_messages(
list(stream_history)
)
stream_sampling_kwargs = {}
if kwargs.get('max_tokens') is not None:
stream_sampling_kwargs['max_tokens'] = kwargs['max_tokens']
if kwargs.get('top_p') is not None:
stream_sampling_kwargs['top_p'] = kwargs['top_p']
# reasoning_effort travelled the same route as max_tokens
# and top_p -- collected by the caller, forwarded into
# start(), and then dropped, because this enumeration never
# listed it. llm.py already consumes it from here.
_stream_effort = kwargs.get(
'reasoning_effort', getattr(self, 'reasoning_effort', None))
if _stream_effort is not None:
stream_sampling_kwargs['reasoning_effort'] = _stream_effort
for chunk in self.llm_instance.get_response_stream(
prompt=actual_prompt,
system_prompt=stream_system_prompt,
system_prompt=self._build_system_prompt(
tool_param,
memory_prefetch_context=memory_prefetch_context,
),
chat_history=stream_history,
temperature=kwargs.get('temperature', 1.0),
tools=tool_param,
@@ -5022,8 +4885,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self.execute_tool
),
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
max_tool_calls_per_turn=self._resolve_max_tool_calls(),
**stream_sampling_kwargs
max_tool_calls_per_turn=self._resolve_max_tool_calls()
):
response_content += chunk
yield chunk
@@ -5049,15 +4911,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._ensure_knowledge_processed()
if self.knowledge:
knowledge_context, _ = self._get_knowledge_context(
prompt if isinstance(prompt, str) else str(prompt),
use_rag=True
)
if knowledge_context:
actual_prompt = f"{prompt}\n\nKnowledge: {knowledge_context}"
if attachments:
actual_prompt = self._build_multimodal_prompt(actual_prompt, attachments)
search_results = self.knowledge.search(prompt, agent_id=self.agent_id)
if search_results:
if isinstance(search_results, dict) and 'results' in search_results:
knowledge_content = "\n".join([result['memory'] for result in search_results['results']])
else:
knowledge_content = "\n".join(search_results)
actual_prompt = f"{prompt}\n\nKnowledge: {knowledge_content}"
# Handle tools properly
tools = kwargs.get('tools', self.tools)
@@ -5071,27 +4931,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
kwargs.get('output_json'), kwargs.get('output_pydantic'),
memory_prefetch_context=memory_prefetch_context)
# Apply context management so the streaming path compacts long
# histories the same way the non-streaming path does (Issue #4714).
# The system prompt is embedded as messages[0]; split it out so the
# token ledger accounts for it, then reattach after optimization.
if self.context_manager and messages:
if messages[0].get("role") == "system":
stream_system_prompt = messages[0].get("content", "")
optimized_history, _ = self._apply_context_management(
messages=messages[1:],
system_prompt=stream_system_prompt,
tools=tool_param,
)
messages = [messages[0]] + list(optimized_history)
else:
optimized_history, _ = self._apply_context_management(
messages=messages,
system_prompt="",
tools=tool_param,
)
messages = list(optimized_history)
# Store chat history length for potential rollback
chat_history_length = len(self.chat_history)
@@ -5121,37 +4960,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
"temperature": kwargs.get('temperature', 1.0),
"stream": True
}
if kwargs.get('max_tokens') is not None:
completion_args["max_tokens"] = kwargs['max_tokens']
if kwargs.get('top_p') is not None:
completion_args["top_p"] = kwargs['top_p']
# Native reasoning control. resolve_reasoning_params returns
# {} for "off", for an unset level, and for a model with no
# reasoning knob, so the default stays a no-op; a reasoning
# model gets reasoning_effort and Anthropic/Gemini get their
# thinking budget. completion_args is copied into
# followup_args below, so tool follow-ups inherit it.
_effort = kwargs.get(
'reasoning_effort', getattr(self, 'reasoning_effort', None))
if _effort is not None:
try:
from ..thinking.effort import resolve_reasoning_params
_reasoning = resolve_reasoning_params(_effort, self.llm)
for _key, _value in _reasoning.items():
# ``reasoning_effort`` is a native OpenAI SDK
# keyword; anything else (e.g. an extended-thinking
# ``thinking`` budget for an Anthropic/Gemini-named
# model reached over an OpenAI-compatible endpoint)
# is a provider-specific body field the OpenAI SDK
# rejects as a top-level kwarg, so route it through
# ``extra_body`` instead of raising TypeError.
if _key == "reasoning_effort":
completion_args[_key] = _value
else:
completion_args.setdefault(
"extra_body", {})[_key] = _value
except ImportError:
pass
if formatted_tools:
completion_args["tools"] = formatted_tools
@@ -5287,34 +5095,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
)
else {}
)
_tool_started = time_module.perf_counter()
try:
tool_result = executor(
tool_call['function']['name'],
parsed_args,
tool_call_id=tool_call.get('id'),
**iteration_kwargs,
)
except Exception as _exec_error:
# Streaming UIs only learn about tool
# activity through this callback; report
# the failure before re-raising.
self._notify_tool_call(
tool_call['function']['name'],
parsed_args,
{"error": str(_exec_error)},
elapsed_time=time_module.perf_counter() - _tool_started,
success=False,
)
raise
# Parity with the non-streaming path, which
# fires 'tool_call' after every tool run.
self._notify_tool_call(
tool_result = executor(
tool_call['function']['name'],
parsed_args,
tool_result,
elapsed_time=time_module.perf_counter() - _tool_started,
success=True,
tool_call_id=tool_call.get('id'),
**iteration_kwargs,
)
# Add tool result to chat history (multimodal-aware)
from .tool_execution import build_tool_result_message_pair
@@ -5450,7 +5235,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._rollback_chat_history_to(chat_history_length)
logging.error(f"OpenAI streaming error: {e}")
# Fall back to simulated streaming
response = self._stream_fallback_chat(prompt, kwargs, e)
response = self.chat(prompt, **kwargs)
if response:
words = str(response).split()
chunk_size = max(1, len(words) // 20)
@@ -5470,77 +5255,12 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
except Exception as e:
# Restore verbose mode on any error
self.verbose = original_verbose
if getattr(e, '_praisonai_stream_fallback_exhausted', False):
# The inner fallback already ran chat() and it failed too.
# Running it a second time would only bury the streaming
# error one level deeper; surface the chained failure as-is.
raise
# Graceful fallback to non-streaming if streaming fails
logging.warning(f"Streaming failed, falling back to regular response: {e}")
response = self._stream_fallback_chat(prompt, kwargs, e)
response = self.chat(prompt, **kwargs)
if response:
yield response
def _stream_fallback_chat(self, prompt: str, kwargs: Dict[str, Any], streaming_error: BaseException) -> Optional[str]:
"""Run the non-streaming ``chat()`` after a streaming attempt failed.
``start(stream=True)`` hands ``_start_stream_impl`` the caller's raw
kwargs with ``stream=True`` already set. Forwarding them to ``chat()``
unchanged broke the fallback in two ways (#4719): ``stream=True``
reached the sync adapter, which refuses to stream; and any sampling
knob ``chat()`` does not declare (``max_tokens``, ``top_p``...) raised
``TypeError`` before a request was made. The desktop passes
``max_tokens`` on every turn, so its fallback never worked.
This builds the call from the subset of kwargs ``chat()`` actually
accepts (introspected, so subclass overrides are honoured), forces
``stream=False``, and -- if the fallback fails as well -- raises that
failure chained ``from`` the original streaming exception so the real
cause is not lost.
"""
import inspect
try:
parameters = inspect.signature(self.chat).parameters
except (TypeError, ValueError):
parameters = {}
accepts_var_kwargs = any(
p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values()
)
accepted = {
name for name, p in parameters.items()
if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
}
accepted.discard('prompt')
fallback_kwargs = {
key: value for key, value in kwargs.items()
if accepts_var_kwargs or key in accepted
}
dropped = sorted(set(kwargs) - set(fallback_kwargs))
if dropped:
logging.debug(
f"Streaming fallback: dropping kwargs chat() does not accept: {dropped}"
)
# The fallback is, by definition, the non-streaming path.
if accepts_var_kwargs or 'stream' in accepted:
fallback_kwargs['stream'] = False
else:
fallback_kwargs.pop('stream', None)
try:
return self.chat(prompt, **fallback_kwargs)
except Exception as fallback_error:
logging.error(
f"Streaming failed ({streaming_error!r}) and the non-streaming "
f"fallback also failed ({fallback_error!r})"
)
try:
fallback_error._praisonai_stream_fallback_exhausted = True
except Exception:
pass
raise fallback_error from streaming_error
def _create_llm_summarize_function(self):
"""
Create an async LLM summarization function for context compaction.
@@ -802,32 +802,21 @@ Write the complete compiled report:"""
Args:
new_model: The new model name to switch to (e.g., "gpt-4o", "claude-3-sonnet")
"""
# Serialise against an in-flight sync model fallback (which also swaps
# self.llm / self._unified_dispatcher) so the two can't interleave.
with self._get_model_fallback_lock().sync():
# Store the new model name
self.llm = new_model
# Drop any cached dispatcher bound to the old model so the next
# chat()/achat() rebuilds against new_model. Without this, the
# OpenAI path keeps dispatching on the old model even though
# self.llm now reads the new name (mirrors _apply_default_llm).
if getattr(self, '_unified_dispatcher', None) is not None:
self._unified_dispatcher = None
# Recreate the LLM instance with the new model
try:
from ..llm.llm import LLM
self._llm_instance = LLM(
model=new_model,
base_url=self._openai_base_url,
api_key=self._openai_api_key,
)
self._using_custom_llm = True
self._wire_deferred_history_callback()
except ImportError:
# If LLM class not available, just update the model string
pass
# Store the new model name
self.llm = new_model
# Recreate the LLM instance with the new model
try:
from ..llm.llm import LLM
self._llm_instance = LLM(
model=new_model,
base_url=self._openai_base_url,
api_key=self._openai_api_key,
)
self._using_custom_llm = True
except ImportError:
# If LLM class not available, just update the model string
pass
def start(self, prompt: Optional[str] = None, **kwargs: Any) -> Union[str, Generator[str, None, None], None]:
"""Start the agent interactively with verbose output.
@@ -1421,27 +1410,10 @@ Write the complete compiled report:"""
)
if _verdict.get("stuck"):
if _verdict.get("level") == "critical":
# Async parity with the sync path in tool_execution.py:
# route the critical verdict through the unified approval
# pipeline (human override / per-project policy) instead
# of an unconditional hard stop. Use the native async
# helper so an async-only / event-loop-bound approval
# backend runs on *this* loop via ``approve_async``,
# preserving loop-bound resources — the sync bridge would
# otherwise run the backend on a throwaway worker-thread
# loop and turn its failure into a spurious denial.
_approved = await self._doom_loop_approved_async(
function_name, arguments, _verdict,
)
if _approved:
_loop_detection.reset_matching_history(
_ld_history, function_name, arguments
)
else:
return {
"error": _verdict.get("message", "loop detected"),
"loop_blocked": True,
}
return {
"error": _verdict.get("message", "loop detected"),
"loop_blocked": True,
}
elif not getattr(self, '_loop_warned_this_turn', False):
self._loop_warned_this_turn = True
self._pending_self_correction = (
@@ -1725,40 +1697,11 @@ Write the complete compiled report:"""
# deny or a tool-call guardrail. The check is pure/sync (no awaits).
check = getattr(self, "_check_tool_policy_and_guardrails", None)
if check is not None:
# ``tools_override`` is threaded through so a run-scoped tool
# list cannot bypass the per-tool guardrails its tools declare.
policy_result = check(function_name, arguments, tools_override)
policy_result = check(function_name, arguments)
if isinstance(policy_result, dict):
return policy_result # Error dict
_, arguments = policy_result
# MCP tools first — parity with the sync path (_execute_tool_impl).
# Without this an MCP-backed tool call hard-fails via achat()/astart().
# Only consult MCP when searching the agent's own tools (no override),
# matching the sync path which resolves MCP against self.tools.
if tools_override is None:
resolve_mcp = getattr(self, "_resolve_mcp_tool_result", None)
if resolve_mcp is not None:
found, mcp_result = resolve_mcp(function_name, arguments)
if found:
if inspect.isawaitable(mcp_result):
mcp_result = await mcp_result
# Normalize MCP transport/timeout failures into a native
# error dict — parity with the sync path so an async MCP
# timeout classifies as a retryable failure instead of
# being handed to the model as a bare "Error: ..." string
# that looks like a successful result.
normalize_mcp = getattr(self, "_normalize_mcp_result", None)
if normalize_mcp is not None:
mcp_result = normalize_mcp(mcp_result)
# Tool-result guardrail gate — the native path applies this
# below, but the MCP branch returns early, so gate here too
# or successful MCP output would bypass validation entirely.
apply_result_guardrails = getattr(self, "_apply_tool_result_guardrails", None)
if apply_result_guardrails is not None:
mcp_result = apply_result_guardrails(function_name, mcp_result)
return mcp_result
# Try to find the function in the override tools list first, then agent's tools list.
# Resolve by BaseTool/FunctionTool ``.name`` (instances like BrowserBaseTool or
# aliased decorated tools), plain callable ``__name__``, or class name so async
@@ -1777,36 +1720,8 @@ Write the complete compiled report:"""
(inspect.isclass(tool) and tool.__name__ == function_name):
func = tool
break
# Name self-repair — parity with the sync path. A slightly hallucinated
# tool name (e.g. 'WebSearch' for 'web_search') is re-matched instead of
# hard-failing. Only over the agent's own active tools (no override).
if func is None and tools_override is None:
repair = getattr(self, "_self_repair_tool_name", None)
if repair is not None:
func = repair(function_name)
if func is None:
# Corrective, model-readable message with the available tools and a
# closest-match hint, mirroring the sync path so the model can retry.
available = None
if hasattr(self, "_available_active_tool_names"):
try:
available = self._available_active_tool_names()
except Exception:
available = None
if available is not None:
suggestion = None
try:
import difflib
near = difflib.get_close_matches(function_name, available, n=1, cutoff=0.5)
suggestion = near[0] if near else None
except Exception:
pass
hint = f" Did you mean '{suggestion}'?" if suggestion else ""
error_msg = f"Tool '{function_name}' not found.{hint} Available tools: {available}"
logging.error(error_msg)
return {"error": error_msg, "available_tools": available}
logging.error(f"Function {function_name} not found in tools")
return {"error": f"Function {function_name} not found in tools"}
@@ -1885,42 +1800,17 @@ Write the complete compiled report:"""
call_arguments = dict(arguments)
call_arguments["idempotency_key"] = durable_key
# Establish the injection context (Injected[T] params, including
# the cooperative cancel_event) around dispatch so the async path
# matches the sync execute_tool path. Without this, a long-running
# tool (e.g. shell) executed via achat()/astart() never observes an
# interrupt and runs until its own timeout. Built lazily and reused
# for both the coroutine and executor branches.
from ..tools.injected import with_injection_context
_inject_state = None
if hasattr(self, "_build_agent_state"):
try:
_inject_state = self._build_agent_state()
except Exception:
_inject_state = None
async def _invoke():
# Set the tool-progress channel BEFORE dispatch so the sink
# propagates into the executor thread via contextvars, mirroring
# the sync execute_tool path.
if inspect.iscoroutinefunction(call_target):
logging.debug(f"Executing async function: {function_name}")
if _inject_state is not None:
with with_injection_context(_inject_state), tool_progress_channel(_progress_sink):
return await call_target(**call_arguments)
with tool_progress_channel(_progress_sink):
return await call_target(**call_arguments)
logging.debug(f"Executing sync function in executor: {function_name}")
loop = asyncio.get_running_loop()
from ..trace.context_events import copy_context_to_callable
# copy_context_to_callable snapshots the CURRENT context (incl.
# the injection contextvar) into the executor thread, so setting
# it here propagates cancel_event to the sync tool body too.
if _inject_state is not None:
with with_injection_context(_inject_state), tool_progress_channel(_progress_sink):
return await loop.run_in_executor(
None, copy_context_to_callable(lambda: call_target(**call_arguments))
)
with tool_progress_channel(_progress_sink):
return await loop.run_in_executor(
None, copy_context_to_callable(lambda: call_target(**call_arguments))
@@ -2060,14 +1950,6 @@ Write the complete compiled report:"""
)
breaker_record(function_name, not is_breaker_failure)
# Tool-result guardrail gate (protocol-driven) — parity with the
# sync path in tool_execution.py. Runs on the raw result before it
# re-enters the LLM context so a guardrail can inspect or redact
# unsafe tool output. Fail-closed. Zero overhead when unset.
apply_result_guardrails = getattr(self, "_apply_tool_result_guardrails", None)
if apply_result_guardrails is not None:
result = apply_result_guardrails(function_name, result, tools_override)
# Loop guard (post-execution) — record the outcome and surface a
# block/halt decision back to the model on this same turn, mirroring
# the sync path in tool_execution.py so repeated failures accumulate
@@ -6,26 +6,14 @@ operations. Follows protocol-driven design with zero overhead when not used.
"""
import threading
from typing import Optional, Protocol, Set, runtime_checkable
from typing import Optional, Protocol, Set
from dataclasses import dataclass, field
__all__ = [
"InterruptControllerProtocol",
"CancellableInterruptControllerProtocol",
"InterruptController",
]
__all__ = ["InterruptControllerProtocol", "InterruptController"]
class InterruptControllerProtocol(Protocol):
"""Protocol for interrupt controller extension point.
``event`` is intentionally NOT part of this base contract: exposing the
underlying cancellation Event is an OPTIONAL capability. Existing third-party
controllers that only implement request/clear/is_set/reason/check remain
valid; the runtime probes for ``event`` via ``getattr(..., 'event', None)``.
Controllers that want in-tool cooperative abort can implement
:class:`CancellableInterruptControllerProtocol`.
"""
"""Protocol for interrupt controller extension point."""
def request(self, reason: str = "user") -> None:
"""Request cancellation with optional reason."""
@@ -43,27 +31,12 @@ class InterruptControllerProtocol(Protocol):
def reason(self) -> Optional[str]:
"""Get interrupt reason if set."""
...
def check(self) -> None:
"""Check for interrupt and raise if set."""
...
@runtime_checkable
class CancellableInterruptControllerProtocol(InterruptControllerProtocol, Protocol):
"""Optional extension: controllers that expose the cancellation Event.
A running tool body can wait on / poll this Event to abort promptly instead
of running until its own timeout. Implementing it is entirely optional and
fully backward compatible with :class:`InterruptControllerProtocol`.
"""
@property
def event(self) -> "threading.Event":
"""Get the underlying cancellation Event for in-tool observation."""
...
@dataclass
class InterruptController:
"""Thread-safe cooperative cancellation for agent runs.
@@ -122,19 +95,10 @@ class InterruptController:
return turn_id in self._cancelled_turns
def _end_turn(self, turn_id: int) -> None:
"""Unregister a turn so its request cannot affect a future turn.
Also clears the underlying Event once no turn still needs it (no active
or still-cancelled turns and no pending request). The Event is exposed to
running tool bodies via :attr:`event`; leaving it set after an interrupted
turn would make a reused controller abort tools launched by the NEXT turn.
"""
"""Unregister a turn so its request cannot affect a future turn."""
with self._lock:
self._active_turns.discard(turn_id)
self._cancelled_turns.discard(turn_id)
if not self._active_turns and not self._cancelled_turns and not self._pending_turn_cancel:
self._flag.clear()
self._reason = None
def clear(self) -> None:
"""Clear the cancellation request."""
@@ -162,16 +126,6 @@ class InterruptController:
with self._lock:
return self._reason
@property
def event(self) -> threading.Event:
"""Expose the underlying cancellation Event.
Lets a running tool body observe the interrupt cooperatively (e.g. wait
on it with a timeout, or poll ``is_set()``) so an in-flight subprocess/
request can be aborted rather than left running until its own timeout.
"""
return self._flag
def check(self) -> None:
"""Check for cancellation and raise if requested.
@@ -9,9 +9,8 @@ for maintainability.
import os
import inspect
import logging
import threading
import contextvars
from typing import Optional, Dict
from typing import Optional
# Per-turn ownership of appended chat-history messages. Each chat()/achat() turn
# runs in its own thread (sync) or task (async); both get an isolated copy of
@@ -22,38 +21,6 @@ _active_turn_owned: "contextvars.ContextVar[Optional[list]]" = contextvars.Conte
"praisonai_active_turn_owned", default=None
)
# Registry of the ownership lists of every currently in-flight chat()/achat()
# turn, keyed by id() of the list. A turn registers its list in
# _begin_turn_tracking and removes it in _end_turn_tracking. ephemeral() reads
# this at exit so a message a *concurrent live turn* appended is preserved even
# when that append did not flow through the ephemeral tracker (e.g. the turn
# ran on another thread that never entered this block's context). Guarded by
# _live_turns_lock because turns on different threads mutate it concurrently.
_live_turn_owners: "Dict[int, list]" = {}
_live_turns_lock = threading.Lock()
def _register_live_turn(owned: list) -> None:
with _live_turns_lock:
_live_turn_owners[id(owned)] = owned
def _unregister_live_turn(owned: Optional[list]) -> None:
if owned is None:
return
with _live_turns_lock:
_live_turn_owners.pop(id(owned), None)
def _live_turn_message_ids() -> set:
"""ids of every message any currently in-flight turn has appended so far."""
with _live_turns_lock:
owners = list(_live_turn_owners.values())
ids = set()
for owned in owners:
ids.update(id(m) for m in owned)
return ids
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
from ._lazy_display import _get_console, _get_live, _get_display_functions
@@ -154,18 +121,11 @@ class MemoryMixin:
runs in its own thread (sync ``chat``) or task (async ``achat``), so the
context var copy is isolated per turn. Safe to call unconditionally; when
a turn opts in, its rollback removes only its own messages by identity.
The list is also registered in the process-wide live-turn registry so a
concurrent ``ephemeral()`` block on another thread can preserve messages
this in-flight turn appends (see ``_live_turn_message_ids``).
"""
owned: list = []
_register_live_turn(owned)
return _active_turn_owned.set(owned)
return _active_turn_owned.set([])
def _end_turn_tracking(self, token):
"""Stop recording for the current turn and drop its ownership list."""
_unregister_live_turn(_active_turn_owned.get())
try:
_active_turn_owned.reset(token)
except (ValueError, LookupError):
@@ -176,10 +136,8 @@ class MemoryMixin:
Used at the start of an async turn (whose control flow has no single
wrapping ``finally``) so a stale list from a completed turn on the same
task cannot accumulate messages from unrelated turns. Also unregisters
that stale list from the live-turn registry so it cannot linger there.
task cannot accumulate messages from unrelated turns.
"""
_unregister_live_turn(_active_turn_owned.get())
_active_turn_owned.set(None)
def _rollback_chat_history_to(self, rollback_length):
@@ -204,7 +162,7 @@ class MemoryMixin:
"""
owned = _active_turn_owned.get()
with self._history_lock:
if owned is not None:
if owned:
owned_ids = {id(m) for m in owned}
self.chat_history[:] = [
m for m in self.chat_history if id(m) not in owned_ids
@@ -242,12 +200,9 @@ class MemoryMixin:
Also resets _auto_save_last_index to prevent silent message loss
when auto_save is enabled.
"""
# Hold the same lock every other history mutator takes so a concurrent
# _add_to_chat_history append can't land on the list object we discard.
with self._history_lock:
self.chat_history = []
# Reset auto-save index to prevent stale index causing message loss
self._auto_save_last_index = 0
self.chat_history = []
# Reset auto-save index to prevent stale index causing message loss
self._auto_save_last_index = 0
def prune_history(self, keep_last: int = 5) -> int:
"""
@@ -341,32 +296,16 @@ class MemoryMixin:
response = agent.chat("[IMAGE] Analyze this")
# After block, history is restored - image NOT persisted
"""
# Restore by identity, not by a blind ``chat_history = saved_history``.
# A message a concurrent chat()/achat() turn on the same shared Agent
# commits during the block must survive; only the ephemeral additions go.
#
# A message is KEPT on exit when it is either:
# * present in the pre-block snapshot (it predates the block), or
# * owned by a chat()/achat() turn that is still in flight (a live
# concurrent turn appended it - preserving it is the fix for the
# history-loss race).
# Everything else that appeared during the block is an ephemeral addition
# and is removed. This also drops direct ``chat_history.append`` writes
# made inside the block, which carry no turn ownership. Nested blocks
# compose: the inner block removes its own additions first, and the outer
# block's snapshot still holds everything that predates it.
# Save current history state
with self._history_lock:
snapshot_ids = {id(m) for m in self.chat_history}
saved_history = self.chat_history.copy()
try:
yield
finally:
live_ids = _live_turn_message_ids()
keep_ids = snapshot_ids | live_ids
# Restore history to pre-block state
with self._history_lock:
self.chat_history[:] = [
m for m in self.chat_history if id(m) in keep_ids
]
self.chat_history = saved_history
def _init_db_session(self):
"""Initialize DB session if db adapter is provided (lazy, first chat only)."""
@@ -791,16 +730,11 @@ class MemoryMixin:
# Reset counter and emit nudge
self._turns_since_nudge = 0
# Optional tool-work floor (Issue #4864): ``nudge_min_tool_iters`` is a
# tunable override, not a hard floor. When it is <= 0 the cadence fires
# on conversational (tool-less) turns too, so a chat-only companion bot
# still learns; a positive value keeps the "only nudge after real tool
# work" behaviour for task-oriented agents.
# Optional: only nudge if agent has done meaningful work
min_iters = getattr(self._learn_config, 'nudge_min_tool_iters', 3)
if min_iters > 0:
tool_calls_count = getattr(self, '_recent_tool_calls_count', 0)
if tool_calls_count < min_iters:
return None
tool_calls_count = getattr(self, '_recent_tool_calls_count', 0)
if tool_calls_count < min_iters:
return None
return (
"\n\n[System nudge] Review the recent conversation. If you discovered a "
@@ -113,20 +113,11 @@ def resolve_placement(
TypeError: on any combination that names two different places for the
same thing, or a place that cannot do the job asked of it.
"""
managed = managed_runtimes()
places = tool_places()
# ── a place that cannot do the job asked of it ───────────────────────────
run_on_name = _name_of(run_on)
tools_name = _name_of(tools_run_on)
# The two lookups below feed only the branches guarded on run_on_name /
# tools_name being set; the entry-point scan in tool_places() is the
# dominant cost of Agent() construction, so skip both on the default
# (no-placement) path rather than compute results that get discarded.
if run_on_name is not None or tools_name is not None:
managed = managed_runtimes()
places = tool_places()
else:
managed = places = ()
if run_on_name is not None and run_on_name not in managed:
if run_on_name in places:
raise TypeError(
@@ -144,6 +135,7 @@ def resolve_placement(
f"(pass those as tools_run_on=)"
)
tools_name = _name_of(tools_run_on)
if tools_name is not None and tools_name in managed and tools_name not in places:
raise TypeError(
f"{owner}(tools_run_on={tools_run_on!r}) is not valid: {tools_name!r} "
@@ -436,50 +436,6 @@ class ToolExecutionMixin:
logging.debug(f"Type casting failed for {getattr(func, '__name__', 'unknown function')}: {e}")
return arguments
def _current_cancel_event(self):
"""Return a cancellation Event a running tool body can observe, or None.
Sourced from the agent's ``interrupt_controller`` when present. The event
is only meaningful while a cancellation is actually in effect for the
current turn — the controller clears its underlying flag once an
interrupted turn ends (see ``InterruptController._end_turn``), so a stale
request from a previous turn does not abort newly started tool bodies.
A controller without an ``event`` member yields ``None`` (fully optional).
"""
controller = getattr(self, 'interrupt_controller', None)
if controller is None:
return None
return getattr(controller, 'event', None)
def _build_agent_state(self):
"""Construct the AgentState injected into tools for the current call.
Shared by the sync (``execute_tool``) and async
(``_execute_tool_async_impl``) paths so both establish an identical
injection context — including the cooperative ``cancel_event`` — avoiding
drift where one path silently omits injected state.
"""
from ..tools.injected import AgentState
try:
from .durable import get_durable_idempotency_key
idempotency_key = get_durable_idempotency_key()
except ImportError:
idempotency_key = None
state_metadata = {'agent_name': self.name}
if idempotency_key:
state_metadata['idempotency_key'] = idempotency_key
return AgentState(
agent_id=self.name,
run_id=getattr(self, '_current_run_id', 'unknown'),
session_id=getattr(self, '_session_id', None) or 'default',
last_user_message=self.chat_history[-1].get('content') if self.chat_history else None,
memory=getattr(self, '_memory_instance', None),
learn_manager=getattr(getattr(self, '_memory_instance', None), 'learn', None),
metadata=state_metadata,
cancel_event=self._current_cancel_event(),
)
def execute_tool(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None) -> Any:
"""
Execute a tool dynamically based on the function name and arguments.
@@ -503,8 +459,25 @@ class ToolExecutionMixin:
# Do NOT call it here to avoid duplicate output
# Set up injection context for tools with Injected parameters
state = self._build_agent_state()
cancel_event = state.cancel_event
from ..tools.injected import AgentState
try:
from .durable import get_durable_idempotency_key
idempotency_key = get_durable_idempotency_key()
except ImportError:
idempotency_key = None
state_metadata = {'agent_name': self.name}
if idempotency_key:
state_metadata['idempotency_key'] = idempotency_key
state = AgentState(
agent_id=self.name,
run_id=getattr(self, '_current_run_id', 'unknown'),
session_id=getattr(self, '_session_id', None) or 'default',
last_user_message=self.chat_history[-1].get('content') if self.chat_history else None,
memory=getattr(self, '_memory_instance', None),
learn_manager=getattr(getattr(self, '_memory_instance', None), 'learn', None),
metadata=state_metadata,
)
# Route through user-supplied tool middleware (Agent(hooks=[...])) when
# present. Zero overhead when no hooks: the fast path calls straight
@@ -523,7 +496,6 @@ class ToolExecutionMixin:
session_id=getattr(self, '_session_id', None) or 'default',
tool_name=function_name,
metadata={"tool_call_id": tool_call_id},
cancel_event=cancel_event,
),
)
@@ -983,13 +955,6 @@ class ToolExecutionMixin:
)
time.sleep(delay)
# Tool-result guardrail gate (protocol-driven). Runs on the raw
# result before any middleware/trust-wrapping/hooks so a guardrail
# can inspect or redact unsafe tool output (a leaked secret, a
# prompt-injection payload) before it re-enters the LLM context.
# Fail-closed. Zero overhead when unset.
result = self._apply_tool_result_guardrails(function_name, result)
# Apply runtime-scoped middleware normalization BEFORE hooks fire
# Plugin harnesses can register middleware to normalize vendor-specific results
runtime_id = getattr(self, '_runtime_id', 'praisonai') # Default to native runtime
@@ -1682,50 +1647,6 @@ class ToolExecutionMixin:
or (trust_level == "external") # External tools need approval
)
if needs_approval:
# Consult the registry's standing grants before prompting.
# This branch used to go straight to the backend, skipping
# is_env_auto_approve / is_yaml_approved / is_auto_approved /
# is_already_approved entirely -- and it is the DEFAULT branch,
# since a bare Agent() on a TTY installs a ConsoleBackend. So
# PRAISONAI_AUTO_APPROVE=true still prompted (measured: 3 of 3
# identical calls), a YAML approval was ignored, and an
# "approve for this session" grant was never remembered.
agent_name_for_registry = getattr(self, 'name', None)
scope_id = getattr(self, '_approval_scope_id', None) or agent_name_for_registry
try:
standing_grant = (
approval_registry.is_env_auto_approve()
or approval_registry.is_yaml_approved(tool_name)
or approval_registry.is_auto_approved(tool_name, scope_id)
or approval_registry.is_already_approved(
tool_name, tool_args, scope_id
)
# A "[s] this session" grant is stored by reusable
# permission target, not by exact arguments, so a later
# call with different args (same target, e.g. the same
# ``bash:git status *`` prefix) is covered too. Without
# this check the attached backend re-prompts for a call
# the user already blessed for the session -- the exact
# re-prompting this PR set out to stop.
or approval_registry._is_session_scoped(
agent_name_for_registry, tool_name, tool_args, scope_id
)
)
except Exception: # noqa: BLE001 - a grant lookup must never
# block the call; fall through and ask, which is the safe
# direction to fail in.
standing_grant = False
if standing_grant:
decision = ApprovalDecision(
approved=True,
reason="Approved by a standing registry grant",
)
if is_async:
async def _granted():
return decision
return _granted()
return decision
# Attach a rendered unified diff for file-mutating tools so the
# reviewer approves the actual change rather than a truncated
# argument dump. Non-edit tools get no diff (``None``) and keep
@@ -1743,46 +1664,19 @@ class ToolExecutionMixin:
context={"diff": diff_preview} if diff_preview else {},
)
# Record a backend decision back into the registry so a
# "[s] this session" (or "always") grant the user gives at the
# prompt is remembered -- exactly as the no-backend
# registry.approve_* path does via _persist_scoped_decision.
# Without this, the fast-path _is_session_scoped check above can
# never match, and the user is re-prompted on every matching
# call for the rest of the run. Best-effort: a bookkeeping error
# must never change or block the returned decision.
def _remember(decision):
try:
if getattr(decision, "approved", False):
approval_registry.mark_approved(
tool_name, tool_args, agent_name_for_registry, scope_id
)
approval_registry._persist_scoped_decision(
agent_name_for_registry, tool_name, tool_args,
decision, scope_id,
)
except Exception: # noqa: BLE001 - bookkeeping only
pass
return decision
if is_async:
# Async path - return the coroutine for caller to await
cfg_timeout = getattr(self, '_approval_timeout', 0)
async def _ask_backend_async():
if cfg_timeout is None:
decision = await backend.request_approval(request)
elif cfg_timeout > 0:
import asyncio
decision = await asyncio.wait_for(
backend.request_approval(request),
timeout=cfg_timeout,
)
else:
decision = await backend.request_approval(request)
return _remember(decision)
return _ask_backend_async()
if cfg_timeout is None:
return backend.request_approval(request)
elif cfg_timeout > 0:
import asyncio
return asyncio.wait_for(
backend.request_approval(request),
timeout=cfg_timeout,
)
else:
return backend.request_approval(request)
else:
# Sync path - handle timeout and sync/async backend compatibility
cfg_timeout = getattr(self, '_approval_timeout', 0)
@@ -1798,7 +1692,7 @@ class ToolExecutionMixin:
try:
if hasattr(backend, 'request_approval_sync'):
return _remember(backend.request_approval_sync(request))
return backend.request_approval_sync(request)
else:
# Use the shared utility to avoid code duplication and handle timeout correctly
from ..approval.utils import run_coroutine_safely
@@ -1812,10 +1706,10 @@ class ToolExecutionMixin:
# cfg_timeout == 0: use backend default or fallback
effective_timeout = getattr(backend, '_timeout', 60)
return _remember(run_coroutine_safely(
return run_coroutine_safely(
backend.request_approval(request),
timeout=effective_timeout
))
)
finally:
if orig_timeout is not None and hasattr(backend, '_timeout'):
backend._timeout = orig_timeout
@@ -1936,61 +1830,6 @@ class ToolExecutionMixin:
)
return False
async def _doom_loop_approved_async(self, function_name, arguments, verdict) -> bool:
"""Async twin of :meth:`_doom_loop_approved`.
Identical decision semantics, but routes the backend prompt through the
registry's native ``approve_async`` so an async-only or event-loop-bound
approval backend runs on the *caller's* loop (preserving loop-bound
resources) instead of the sync bridge's throwaway worker-thread loop.
Every non-allow outcome — deny, timeout, no backend, or any error —
returns ``False`` so the caller falls back to the historical hard-stop.
"""
try:
from ..approval import get_approval_registry
from ..approval.registry import DOOM_LOOP_TARGET
manager = getattr(self, "_permission_manager", None)
if manager is not None:
try:
from ..permissions import PermissionAction
action = manager.resolve_tool_action(
"doom_loop", getattr(self, "name", None)
)
if action == PermissionAction.ALLOW:
return True
if action == PermissionAction.DENY:
return False
except Exception as e: # noqa: BLE001
logging.debug(
"doom_loop permission-manager resolve failed (%s); "
"falling through to backend", e,
)
registry = get_approval_registry()
request_args = {
"tool": function_name,
"detector": verdict.get("detector"),
"count": verdict.get("count"),
"args_fingerprint": self._doom_loop_args_fingerprint(
function_name, arguments
),
}
decision = await registry.approve_async(
getattr(self, "name", None),
DOOM_LOOP_TARGET,
request_args,
force=True,
scope_id=getattr(self, "_approval_scope_id", None),
)
return bool(getattr(decision, "approved", False))
except Exception as e: # noqa: BLE001 — fail closed to the block path
logging.debug(
"doom_loop async approval routing failed for %s (%s); blocking",
function_name, e,
)
return False
@staticmethod
def _doom_loop_args_fingerprint(function_name, arguments) -> str:
"""Stable, immutable fingerprint of the looping (tool, args) pair."""
@@ -2634,124 +2473,7 @@ class ToolExecutionMixin:
"remediation": "Wait for recovery_timeout (60s) or investigate recent tool failures.",
}
def _find_declared_tool(self, function_name, tools=None):
"""Return the tool OBJECT named ``function_name``, or ``None``.
Per-tool guardrails are declared on the tool the user wrote
(``@tool(input_guardrails=...)``), so gating them needs the object, not
just the name. Searched over the agent's own tools (or the caller's
``tools_override``, so a run-scoped tool list cannot silently bypass its
own guardrails). Deliberately does NOT fall back to the global tool
registry: a guardrail must come from the tool this agent was actually
given, never from a same-named tool registered elsewhere.
"""
candidates = tools if tools is not None else getattr(self, "tools", None)
if not isinstance(candidates, (list, tuple)):
return None
for tool_obj in candidates:
if getattr(tool_obj, "name", None) == function_name:
return tool_obj
if getattr(tool_obj, "__name__", None) == function_name:
return tool_obj
return None
def _check_per_tool_input_guardrails(self, function_name, arguments, tools=None):
"""Run the guardrails declared on THIS tool against its arguments.
ORDERING (deliberate, see also ``_apply_per_tool_output_guardrails``):
this runs LAST on the way in - after the human approval gate, after the
agent-wide PolicyEngine, and after any agent-wide tool-call guardrail -
so it sits immediately before dispatch. That is the safest order for two
reasons:
1. Nothing can rewrite the arguments between this verdict and the call.
A guardrail that approved ``send_email(to=alice@corp.com)`` and then
had the arguments rewritten underneath it would be a guardrail in
name only, and the rewrite is exactly the capability an attacker-
steered agent-wide component would reach for. "The tool never runs
with arguments its own guardrail did not see" is the whole promise of
this feature, so it is the invariant that gets protected.
2. Human approval stays *upstream* of every automated rewrite. The human
is auditing what the MODEL proposed; if a guardrail could sanitise
the arguments first, an approval prompt would show a call the model
never made, and a rewriting guardrail could launder a call past human
review.
Returns an error dict when blocked (never raises), or ``(None, arguments)``
with the possibly-rewritten arguments when allowed. Zero overhead for
tools that declared no guardrail: one attribute read, then out.
"""
tool_obj = self._find_declared_tool(function_name, tools)
if tool_obj is None:
return None, arguments
from ..guardrails.tool_guardrails import get_tool_guardrail_chain, INPUT
chain = get_tool_guardrail_chain(tool_obj, INPUT)
if chain is None:
return None, arguments
is_valid, processed = chain.validate_tool_call(function_name, arguments)
if not is_valid:
# Hand the reason back to the MODEL as this tool's result so it can
# react (pick different arguments, choose another tool, explain to
# the user). Raising here would abort the run and surface a stack
# trace to the user for what is a normal, expected policy outcome.
logging.warning(
f"Tool '{function_name}' blocked by its input guardrail: {processed}"
)
return {
"error": (
f"Tool '{function_name}' was blocked by an input guardrail: "
f"{processed}"
),
"guardrail_denied": True,
"tool_guardrail": "input",
}
if isinstance(processed, dict):
arguments = processed
return None, arguments
def _apply_per_tool_output_guardrails(self, function_name, result, tools=None):
"""Run the guardrails declared on THIS tool against its raw result.
ORDERING (deliberate): this runs FIRST on the way out - before any
agent-wide tool-result guardrail and before the ``trust_level``
external-content fence (``tools.trust.wrap_if_external``) and output
truncation. Mirroring the input side, the per-tool guardrail is the
layer closest to the tool in both directions:
* It sees the RAW result. If the trust fence ran first, every guardrail
would have to parse and strip ``<external_tool_result>`` markers to
find the content it cares about, and a guardrail that redacts by
rewriting could damage or forge the fence.
* The fence therefore stays outermost, wrapping whatever the guardrails
finally allow, so a substituted result is fenced exactly like an
original one and cannot escape the injection markers.
Returns the (possibly substituted) result, or an error dict tagged
``guardrail_denied`` when blocked. Never raises.
"""
tool_obj = self._find_declared_tool(function_name, tools)
if tool_obj is None:
return result
from ..guardrails.tool_guardrails import get_tool_guardrail_chain, OUTPUT
chain = get_tool_guardrail_chain(tool_obj, OUTPUT)
if chain is None:
return result
is_valid, processed = chain.validate_tool_result(function_name, result)
if not is_valid:
logging.warning(
f"Tool '{function_name}' result blocked by its output guardrail: {processed}"
)
return {
"error": (
f"Tool '{function_name}' result was blocked by an output "
f"guardrail: {processed}"
),
"guardrail_denied": True,
"tool_guardrail": "output",
}
return processed
def _check_tool_policy_and_guardrails(self, function_name, arguments, tools=None):
def _check_tool_policy_and_guardrails(self, function_name, arguments):
"""Gate a tool call through the attached PolicyEngine and tool guardrails.
Consults ``self._policy`` (a ``PolicyEngine``) via ``check_tool`` and any
@@ -2811,92 +2533,8 @@ class ToolExecutionMixin:
}
if isinstance(processed, dict):
arguments = processed
# Per-tool guardrails run LAST, immediately before dispatch, so nothing
# can rewrite the arguments between the tool's own verdict and the call.
# See _check_per_tool_input_guardrails for the full ordering rationale.
per_tool = self._check_per_tool_input_guardrails(function_name, arguments, tools)
if isinstance(per_tool, dict):
return per_tool # Error dict -> handed back to the model
_, arguments = per_tool
return None, arguments
def _apply_tool_result_guardrails(self, function_name, result, tools=None):
"""Gate a tool's raw result through any tool-result guardrails.
Runs after execution and before the result re-enters the LLM context so
a guardrail can inspect or redact unsafe tool output (a leaked secret, a
prompt-injection payload) that ``validate_output`` never sees. Returns
the (possibly rewritten) result when allowed, or an error dict tagged
``guardrail_denied`` when rejected. Fail-closed on guardrail error,
mirroring ``_check_tool_policy_and_guardrails``. Zero overhead when no
tool-result guardrail is set. Denial error dicts pass straight through
untouched so a gated/failed tool is not re-inspected.
Order: per-tool guardrails (``@tool(output_guardrails=...)``) run first,
then agent-wide ones, then - back in the caller - the ``trust_level``
external-content fence and truncation. The tool's own validator is
therefore closest to the tool and always sees raw, unfenced output.
"""
guardrails = getattr(self, "_tool_result_guardrails", None)
# NOTE: the early return here used to be ``if not guardrails: return``.
# Per-tool output guardrails are declared on the TOOL, not on the agent,
# so an agent with no agent-wide result guardrail must still run them -
# bailing out on the agent-wide list alone would make
# ``@tool(output_guardrails=...)`` silently dead for the common case.
if not guardrails and self._find_declared_tool(function_name, tools) is None:
return result
# Only skip results the framework itself already gated/denied — those
# carry an explicit control marker and were never real tool output. An
# ordinary tool-authored ``{"error": ...}`` (a crawl result with both
# ``error`` and ``content``, a shell tool's recoverable failure) is still
# untrusted content that can carry a secret or an injection payload, so
# it MUST be validated like any other result.
if isinstance(result, dict) and (
result.get("guardrail_denied")
or result.get("policy_denied")
or result.get("approval_denied")
or result.get("permission_denied")
or result.get("approval_error")
):
return result
# Per-tool guardrails run FIRST on the way out, so the tool's own
# validator sees the raw result before any agent-wide guardrail rewrites
# it and before the trust fence wraps it. See
# _apply_per_tool_output_guardrails for the full ordering rationale.
result = self._apply_per_tool_output_guardrails(function_name, result, tools)
if isinstance(result, dict) and result.get("guardrail_denied"):
return result
for guardrail in guardrails or []:
validate = getattr(guardrail, "validate_tool_result", None)
if validate is None:
continue
try:
is_valid, processed = validate(function_name, result)
except Exception as e: # noqa: BLE001
# Fail closed: a guardrail dependency/implementation error must
# block, not deliver, the unchecked result.
logging.warning(
f"Tool '{function_name}' result denied: guardrail validate_tool_result raised: {e}"
)
return {
"error": f"Tool '{function_name}' result denied: guardrail check failed ({e})",
"guardrail_denied": True,
}
if not is_valid:
logging.warning(
f"Tool '{function_name}' result rejected by tool-result guardrail"
)
return {
"error": f"Tool '{function_name}' result rejected by guardrail",
"guardrail_denied": True,
}
result = processed # Accept the (possibly redacted) result
return result
def _execute_tool_impl(self, function_name, arguments):
"""Internal tool execution implementation."""
@@ -2919,15 +2557,86 @@ class ToolExecutionMixin:
return policy_result # Error dict
_, arguments = policy_result
# Resolve MCP-backed tools via the shared helper (also used by the async
# path) so chat()/achat() behave identically for MCP tool calls. MCP
# transport/timeout failures are normalized (via the shared
# _normalize_mcp_result) into a native {"error": ..., "timeout": True}
# dict so they participate in retry/doom-loop/circuit handling instead of
# looking like a successful string result.
found, result = self._resolve_mcp_tool_result(function_name, arguments)
if found:
return self._normalize_mcp_result(result)
# Special handling for MCP tools
# Check if tools is an MCP instance with the requested function name
MCP = None
try:
from ..mcp.mcp import MCP
except ImportError:
pass # MCP not available
# Normalize MCP transport/timeout failures (returned as bare "Error: ..."
# strings by MCPClient.call_tool) into the same {"error": ...} dict shape
# used by native tools, so they participate in retry, doom-loop detection
# and circuit-breaking instead of looking like a successful string result.
#
# IMPORTANT: a successful MCP tool may legitimately return text that
# begins with "Error: " (e.g. a linter/grep/echo tool). To avoid
# misclassifying valid output as a failure, we only convert MCP's own
# unambiguous internally-generated failure signatures, not any string
# that merely starts with the "Error: " prefix.
def _normalize_mcp_result(res):
if not isinstance(res, str) or not res.startswith("Error: "):
return res
message = res[len("Error: "):]
# MCP-internal timeout messages are unambiguous:
# "MCP tool call timed out after Ns"
# "MCP initialization timed out after Ns"
is_mcp_timeout = (
message.startswith("MCP tool call timed out after")
or message.startswith("MCP initialization timed out after")
)
if is_mcp_timeout:
return {"error": message, "timeout": True}
return res
# Helper function to execute MCP tool
def _execute_mcp_tool(mcp_instance, func_name, args):
"""Execute a tool from an MCP instance."""
# Handle SSE MCP client
if hasattr(mcp_instance, 'is_sse') and mcp_instance.is_sse:
if hasattr(mcp_instance, 'sse_client'):
for tool in mcp_instance.sse_client.tools:
if tool.name == func_name:
logging.debug(f"Found matching SSE MCP tool: {func_name}")
return True, tool(**args)
# Handle HTTP Stream MCP client
if hasattr(mcp_instance, 'is_http_stream') and mcp_instance.is_http_stream:
if hasattr(mcp_instance, 'http_stream_client'):
for tool in mcp_instance.http_stream_client.tools:
if tool.name == func_name:
logging.debug(f"Found matching HTTP Stream MCP tool: {func_name}")
return True, tool(**args)
# Handle WebSocket MCP client
if hasattr(mcp_instance, 'is_websocket') and mcp_instance.is_websocket:
if hasattr(mcp_instance, 'websocket_client'):
for tool in mcp_instance.websocket_client.tools:
if tool.name == func_name:
logging.debug(f"Found matching WebSocket MCP tool: {func_name}")
return True, tool(**args)
# Handle stdio MCP client
if hasattr(mcp_instance, 'runner'):
for mcp_tool in mcp_instance.runner.tools:
if hasattr(mcp_tool, 'name') and mcp_tool.name == func_name:
logging.debug(f"Found matching MCP tool: {func_name}")
return True, mcp_instance.runner.call_tool(func_name, args)
return False, None
# Check if tools is a single MCP instance
if MCP is not None and isinstance(self.tools, MCP):
logging.debug(f"Looking for MCP tool {function_name}")
found, result = _execute_mcp_tool(self.tools, function_name, arguments)
if found:
return _normalize_mcp_result(result)
# Check if tools is a list that may contain MCP instances
if isinstance(self.tools, (list, tuple)):
for tool in self.tools:
if MCP is not None and isinstance(tool, MCP):
logging.debug(f"Looking for MCP tool {function_name} in MCP instance")
found, result = _execute_mcp_tool(tool, function_name, arguments)
if found:
return _normalize_mcp_result(result)
# Try to find the function in the agent's tools list first
func = None
@@ -2961,10 +2670,21 @@ class ToolExecutionMixin:
if func is None:
# Tool not found in declared tools or registry — do not fall back to
# globals() or __main__ as that allows undeclared callables to execute.
# Cheap, deterministic self-repair via the shared helper (also used by
# the async path): the model often emits a name that only differs by
# case/separator (e.g. 'WebSearch' -> 'web_search').
func = self._self_repair_tool_name(function_name)
# Cheap, deterministic self-repair: the model often emits a name that
# only differs by case/separator (e.g. 'WebSearch' -> 'web_search').
# Build a normalised index of the agent's active tools and re-match.
def _norm(n):
return str(n).lower().replace('_', '').replace('-', '').replace(' ', '')
normalised = {}
for name, tool in self._iter_active_named_tools():
normalised.setdefault(_norm(name), []).append((name, tool))
match = normalised.get(_norm(function_name))
if match and len(match) == 1:
matched_name, func = match[0]
logging.debug(
f"Self-repaired tool name {function_name!r} -> {matched_name!r}"
)
if func:
bind_target = func
@@ -2994,16 +2714,6 @@ class ToolExecutionMixin:
scoped["idempotency_key"] = durable_key
return scoped
# Any of the callables below (BaseTool.run, LangChain .run,
# CrewAI ._run, or a plain function) may be `async def`. On this
# sync tool-calling path we must await it, otherwise a bare
# un-awaited coroutine is handed to the model as the tool result
# and the tool body never runs (silent data loss).
def _resolve_result(value):
if inspect.iscoroutine(value):
return self._run_async_in_sync_context(value)
return value
# BaseTool instances (plugin system) - call run() method
from ..tools.base import BaseTool
if isinstance(func, BaseTool):
@@ -3011,7 +2721,7 @@ class ToolExecutionMixin:
keyed_arguments = _with_durable_key(func.run, arguments)
casted_arguments = self._cast_arguments(func.run, keyed_arguments)
bind_arguments = casted_arguments
return _resolve_result(func.run(**casted_arguments))
return func.run(**casted_arguments)
# Langchain: If it's a class with run but not _run, instantiate and call run
if inspect.isclass(func) and hasattr(func, 'run') and not hasattr(func, '_run'):
@@ -3023,7 +2733,7 @@ class ToolExecutionMixin:
and k != 'self'}
casted_params = self._cast_arguments(instance.run, run_params)
bind_arguments = casted_params
return _resolve_result(instance.run(**casted_params))
return instance.run(**casted_params)
# CrewAI: If it's a class with an _run method, instantiate and call _run
elif inspect.isclass(func) and hasattr(func, '_run'):
@@ -3035,7 +2745,7 @@ class ToolExecutionMixin:
and k != 'self'}
casted_params = self._cast_arguments(instance._run, run_params)
bind_arguments = casted_params
return _resolve_result(instance._run(**casted_params))
return instance._run(**casted_params)
# Otherwise treat as regular function
elif callable(func):
@@ -3043,7 +2753,7 @@ class ToolExecutionMixin:
keyed_arguments = _with_durable_key(func, arguments)
casted_arguments = self._cast_arguments(func, keyed_arguments)
bind_arguments = casted_arguments
return _resolve_result(func(**casted_arguments))
return func(**casted_arguments)
except Exception as e:
error_msg = str(e)
logging.error(f"Error executing tool {function_name}: {error_msg}")
@@ -3194,103 +2904,6 @@ class ToolExecutionMixin:
names = [name for name, _tool in self._iter_active_named_tools()]
return sorted(set(names))
@staticmethod
def _normalize_mcp_result(res):
"""Normalize MCP transport/timeout failures into a native error dict.
``MCPClient.call_tool`` surfaces transport/timeout failures as bare
``"Error: ..."`` strings. Converting MCP's own unambiguous
internally-generated timeout signatures into ``{"error": ..., "timeout":
True}`` lets them participate in retry, doom-loop detection and circuit
breaking instead of looking like a successful string result. Shared by
the sync (``_execute_tool_impl``) and async (``_execute_tool_async_impl``)
paths so achat()/chat() classify an MCP timeout identically.
A successful MCP tool may legitimately return text starting with
``"Error: "`` (e.g. a linter/grep/echo tool), so only MCP's own
unambiguous timeout messages are converted, never any string that merely
starts with the prefix.
"""
if not isinstance(res, str) or not res.startswith("Error: "):
return res
message = res[len("Error: "):]
is_mcp_timeout = (
message.startswith("MCP tool call timed out after")
or message.startswith("MCP initialization timed out after")
)
if is_mcp_timeout:
return {"error": message, "timeout": True}
return res
def _resolve_mcp_tool_result(self, function_name, arguments):
"""Resolve an MCP-backed tool and return ``(found, result)``.
Shared by the sync (``_execute_tool_impl``) and async
(``_execute_tool_async_impl``) paths so an MCP tool call behaves
identically regardless of chat()/achat(). Returns ``(False, None)``
when no MCP tool matches. The ``result`` may be awaitable (the async
caller awaits it); the sync caller normalizes/returns it directly.
"""
MCP = None
try:
from ..mcp.mcp import MCP
except ImportError:
return False, None
def _execute_mcp_tool(mcp_instance, func_name, args):
if hasattr(mcp_instance, 'is_sse') and mcp_instance.is_sse:
if hasattr(mcp_instance, 'sse_client'):
for tool in mcp_instance.sse_client.tools:
if tool.name == func_name:
return True, tool(**args)
if hasattr(mcp_instance, 'is_http_stream') and mcp_instance.is_http_stream:
if hasattr(mcp_instance, 'http_stream_client'):
for tool in mcp_instance.http_stream_client.tools:
if tool.name == func_name:
return True, tool(**args)
if hasattr(mcp_instance, 'is_websocket') and mcp_instance.is_websocket:
if hasattr(mcp_instance, 'websocket_client'):
for tool in mcp_instance.websocket_client.tools:
if tool.name == func_name:
return True, tool(**args)
if hasattr(mcp_instance, 'runner'):
for mcp_tool in mcp_instance.runner.tools:
if hasattr(mcp_tool, 'name') and mcp_tool.name == func_name:
return True, mcp_instance.runner.call_tool(func_name, args)
return False, None
if isinstance(self.tools, MCP):
found, result = _execute_mcp_tool(self.tools, function_name, arguments)
if found:
return True, result
if isinstance(self.tools, (list, tuple)):
for tool in self.tools:
if isinstance(tool, MCP):
found, result = _execute_mcp_tool(tool, function_name, arguments)
if found:
return True, result
return False, None
def _self_repair_tool_name(self, function_name):
"""Case/separator-insensitive re-match of a hallucinated tool name.
Returns the matched tool object when exactly one active tool normalizes
to the same key (e.g. 'WebSearch' -> 'web_search'), else ``None``.
Shared by the sync and async tool-resolution paths.
"""
def _norm(n):
return str(n).lower().replace('_', '').replace('-', '').replace(' ', '')
normalised = {}
for name, tool in self._iter_active_named_tools():
normalised.setdefault(_norm(name), []).append((name, tool))
match = normalised.get(_norm(function_name))
if match and len(match) == 1:
matched_name, tool = match[0]
logging.debug(f"Self-repaired tool name {function_name!r} -> {matched_name!r}")
return tool
return None
def _resolve_callable_signature_target(self, func):
"""Return the callable whose signature describes ``func``'s arguments."""
target = func
@@ -93,25 +93,10 @@ class VideoAgent:
Supported Providers:
- OpenAI: openai/sora-2, openai/sora-2-pro
- Azure: azure/sora-2, azure/sora-2-pro
- Gemini Veo 3.1 (paid API, uses GOOGLE_API_KEY / GEMINI_API_KEY):
- gemini/veo-3.1-generate-preview # Standard / cinematic quality
- gemini/veo-3.1-fast-generate-preview # Fast
- gemini/veo-3.1-lite-generate-preview # Lite (cheapest iteration)
- Vertex AI Veo 3.1 (uses GOOGLE_APPLICATION_CREDENTIALS or VERTEXAI_PROJECT/VERTEXAI_LOCATION):
- vertex_ai/veo-3.1-generate-001 # GA Standard
- vertex_ai/veo-3.1-fast-generate-001 # GA Fast
- vertex_ai/veo-3.1-lite-generate-001 # Lite
- Gemini: gemini/veo-3.0-generate-preview, gemini/veo-3.1-*
- Vertex AI: vertex_ai/veo-3.0-*, vertex_ai/veo-3.1-*
- RunwayML: runwayml/gen4_turbo (requires input_reference)
Deprecated (do NOT recommend; kept working only if LiteLLM still routes them):
- gemini/veo-2.0-generate-001 (Gemini API shutdown 30 Jun 2026)
- gemini/veo-3.0-generate-preview, gemini/veo-3.0-fast-generate-preview
- vertex_ai/veo-3.0-* (migrate to the veo-3.1 IDs above)
Note on pricing: Gemini/Vertex Veo bill in USD per generated second via the paid
API (charged only on success) and are a different meter from Flow UI credits — a
Flow subscription cannot pay for API generations. See praison.ai/docs/video/gemini.
Example:
```python
from praisonaiagents import VideoAgent
@@ -129,7 +114,7 @@ class VideoAgent:
# With config
agent = VideoAgent(
llm="gemini/veo-3.1-generate-preview",
llm="gemini/veo-3.0-generate-preview",
video=VideoConfig(seconds="8", size="1280x720")
)
```
@@ -161,7 +146,7 @@ class VideoAgent:
Args:
name: Agent name for identification
instructions: Optional instructions (unused for video generation)
llm: Model name string (e.g., "openai/sora-2", "gemini/veo-3.1-generate-preview")
llm: Model name string (e.g., "openai/sora-2", "gemini/veo-3.0-generate-preview")
model: Alias for llm= parameter
base_url: Custom API endpoint URL
api_key: API key for the provider
@@ -315,12 +300,8 @@ class VideoAgent:
prompt: Text description of the desired video
seconds: Video duration (e.g., "8", "16"). Defaults to config value.
size: Video dimensions (e.g., "720x1280"). Defaults to config value.
input_reference: Reference image for image-to-video (first-frame) generation
**kwargs: Additional provider-specific parameters, forwarded to LiteLLM.
For Veo 3.1 interpolation/reference features, pass them here (no
dedicated params are added to keep the surface lightweight):
- last_frame=<image> # first + last frame (Veo 3.1 only)
- reference_images=[...] # up to 3 reference images
input_reference: Reference image for image-to-video generation
**kwargs: Additional provider-specific parameters
Returns:
VideoObject with id, status, and metadata
@@ -334,14 +315,6 @@ class VideoAgent:
)
print(f"Video ID: {video.id}")
print(f"Status: {video.status}")
# Veo 3.1 first + last frame interpolation (via kwargs passthrough)
veo = VideoAgent(llm="gemini/veo-3.1-generate-preview")
veo.generate(
prompt="Sunrise timelapse",
input_reference=start_image, # first frame
last_frame=end_image, # last frame (Veo 3.1)
)
```
"""
# Merge config with kwargs
@@ -327,187 +327,6 @@ async def _execute_with_agent_async(executor_agent, task_prompt, task, tools, st
)
def _build_handler_context(agents_instance, task):
"""Build a lightweight context object for a Task.handler / should_run callable.
Mirrors the WorkflowContext shape used by the Workflow engine so a handler
written for either engine behaves identically.
"""
variables = dict(getattr(agents_instance, 'variables', None) or {})
task_vars = getattr(task, 'variables', None)
if task_vars:
variables.update(task_vars)
try:
from ..workflows.workflows import WorkflowContext
return WorkflowContext(
input=variables.get('input', ''),
current_step=getattr(task, 'name', '') or '',
variables=variables,
)
except Exception:
return {'variables': variables, 'current_step': getattr(task, 'name', '')}
def _task_has_custom_handler(task):
"""A handler-only task has no agent/agent_config but does have a handler."""
return (
getattr(task, 'handler', None) is not None
and getattr(task, 'agent', None) is None
and getattr(task, 'agent_config', None) is None
)
def _finalize_task_handler_result(agents_instance, task, result):
"""Build the TaskOutput/TaskResult from a handler's return value.
Shared by the sync and async handler executors so both paths store the same
output shape and expose ``output_variable`` identically.
"""
from .protocols import TaskResult
raw = getattr(result, 'output', result)
if raw is None:
raw = ""
raw = str(raw)
task_output = TaskOutput(
description=task.description or (getattr(task, 'name', '') or ''),
summary=(task.description or getattr(task, 'name', '') or '')[:10],
raw=raw,
agent=getattr(task, 'name', '') or 'handler',
output_format="RAW",
)
task.result = task_output
output_variable = getattr(task, 'output_variable', None)
if output_variable:
if getattr(agents_instance, 'variables', None) is None:
agents_instance.variables = {}
agents_instance.variables[output_variable] = raw
task.status = "completed"
return TaskResult(task_output=task_output, success=True)
def _resolve_coroutine_sync(coro):
"""Run a coroutine to completion from a synchronous context.
``astart`` dispatches sync-marked tasks through the sync ``run_task`` path
(in an executor), so an ``async def`` handler still lands here. Rather than
stringifying the coroutine, drive it to completion on a private loop.
"""
import asyncio
try:
asyncio.get_running_loop()
except RuntimeError:
# No running loop in this thread — safe to use asyncio.run.
return asyncio.run(coro)
# A loop is already running in this thread; run the coroutine on a fresh
# loop in a worker thread so we don't re-enter the active loop.
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, coro).result()
def _execute_task_handler(agents_instance, task_id):
"""Execute a Task.handler and store the result as the task output.
Returns a TaskResult, mirroring _process_task_result so the sync/async
execute paths can short-circuit before requiring an agent. Coroutine
handlers are resolved here too, since ``astart`` runs sync-marked tasks
through this synchronous path.
"""
import asyncio
task = agents_instance.tasks[task_id]
context = _build_handler_context(agents_instance, task)
result = task.handler(context)
if asyncio.iscoroutine(result):
result = _resolve_coroutine_sync(result)
return _finalize_task_handler_result(agents_instance, task, result)
async def _aexecute_task_handler(agents_instance, task_id):
"""Async variant of _execute_task_handler that awaits coroutine handlers.
Mirrors the Workflow engine's async path, which awaits handlers returning a
coroutine so an ``async def`` handler runs instead of being stringified.
"""
import asyncio
task = agents_instance.tasks[task_id]
context = _build_handler_context(agents_instance, task)
result = task.handler(context)
if asyncio.iscoroutine(result):
result = await result
return _finalize_task_handler_result(agents_instance, task, result)
def _record_skipped_task(agents_instance, task):
"""Record a stable, empty result for a task gated out by should_run.
Without this the task would be marked completed with no ``task.result``,
which makes ``start``/``astart`` fall through to returning the full results
dict when the skipped task is the final one, and leaves callers unable to
tell a skip from a genuine empty run.
"""
task_output = TaskOutput(
description=task.description or (getattr(task, 'name', '') or ''),
summary=(task.description or getattr(task, 'name', '') or '')[:10],
raw="",
agent=getattr(task, 'name', '') or 'skipped',
output_format="RAW",
)
task.result = task_output
task.status = "completed"
def _task_should_skip(agents_instance, task):
"""Return True when a task's should_run gate evaluates falsy.
Mirrors the Workflow engine's should_run behaviour so conditional tasks are
honoured under PraisonAIAgents/AgentTeam as well. Coroutine results from an
``async def`` gate are resolved to completion here; the async path uses
``_atask_should_skip`` to await them natively.
"""
should_run = getattr(task, 'should_run', None)
if should_run is None:
return False
try:
import asyncio
context = _build_handler_context(agents_instance, task)
result = should_run(context)
if asyncio.iscoroutine(result):
# astart runs sync-marked tasks through this sync gate; resolve the
# coroutine rather than dropping it so async gates are still honoured.
result = _resolve_coroutine_sync(result)
return not result
except Exception as e:
logger.error(f"should_run failed for task {getattr(task, 'name', task)}: {e}")
return False
async def _atask_should_skip(agents_instance, task):
"""Async variant of _task_should_skip that awaits coroutine gates."""
import asyncio
should_run = getattr(task, 'should_run', None)
if should_run is None:
return False
try:
context = _build_handler_context(agents_instance, task)
result = should_run(context)
if asyncio.iscoroutine(result):
result = await result
return not result
except Exception as e:
logger.error(f"should_run failed for task {getattr(task, 'name', task)}: {e}")
return False
def _build_execution_context(agents_instance, task_id, skip_memory_init=False):
"""
Build unified execution context for task execution (DRY helper).
@@ -712,14 +531,6 @@ def _process_task_result(agents_instance, context, agent_output):
task.result = task_output
# Expose the task output under its output_variable so downstream tasks /
# should_run gates can read it (mirrors the Workflow engine).
output_variable = getattr(task, 'output_variable', None)
if output_variable:
if getattr(agents_instance, 'variables', None) is None:
agents_instance.variables = {}
agents_instance.variables[output_variable] = agent_output
# Fail-closed: when structured output was requested but not produced,
# the task did not fulfil its contract. Surface it as a failed result so
# callers relying on TaskResult.success (and the retry loop, which uses
@@ -1087,13 +898,7 @@ class AgentTeam(SpawnAnnounceProtocol):
)
if not agents:
# Handler-only tasks (task.handler with no agent) don't need an agent,
# mirroring the Workflow engine. Allow a team built solely from them.
has_handler_only_task = any(
_task_has_custom_handler(t) for t in (tasks or [])
)
if not has_handler_only_task:
raise ValueError("At least one agent must be provided")
raise ValueError("At least one agent must be provided")
# ─────────────────────────────────────────────────────────────────────
# Core initialization
@@ -1160,10 +965,6 @@ class AgentTeam(SpawnAnnounceProtocol):
self._completion_events: List[SubAgentCompletionEvent] = []
self._event_bus: Optional[EventBus] = None
self._spawn_lock = threading.RLock() # Thread-safe spawn operations (reentrant)
# Strong references to fire-and-forget async spawn tasks. The event loop
# keeps only a weak reference to tasks from asyncio.create_task(), so
# without this a running sub-agent task can be garbage-collected mid-run.
self._bg_spawn_tasks: set = set()
self._team_id = str(uuid.uuid4()) # Unique team identifier
# Aggregate stream emitter (lazy). Fans in member agents' per-step
# StreamEventEmitter events, tagging each with the emitting agent's id,
@@ -1457,13 +1258,6 @@ class AgentTeam(SpawnAnnounceProtocol):
if task.status == "not started":
task.status = "in progress"
# Handler-only tasks (no agent/agent_config) run their custom callable
# instead of an LLM, mirroring the Workflow engine. Await here so an
# ``async def`` handler actually runs instead of being stringified.
if _task_has_custom_handler(task):
task_result = await _aexecute_task_handler(self, task_id)
return task_result.task_output
# Initialize memory asynchronously to avoid blocking the event loop on
# synchronous Memory() construction. The shared helper's own
# `if not task.memory:` guard makes this a safe no-op for the sync path.
@@ -1509,15 +1303,6 @@ class AgentTeam(SpawnAnnounceProtocol):
task.retry_count += 1
task.status = "in progress" # Keep task in progress for retry
# Carry the reason into the re-run. Without this the whole-task
# retry re-asks the identical prompt and the model has no way to
# know what was wrong; Process._build_task_context turns this
# dict into "Previous attempt failed validation with reason: ...".
task.validation_feedback = {
'validation_response': guardrail_result.error,
'validated_task': task.name or task.description,
'rejected_output': getattr(task_output, 'raw', str(task_output)),
}
logger.warning(f"Task {task_id}: Guardrail validation failed (retry {task.retry_count}/{task.max_retries}): {guardrail_result.error}")
return task_output, True # Signal retry needed
@@ -1646,12 +1431,6 @@ class AgentTeam(SpawnAnnounceProtocol):
# Call on_task_start callback and propagate variables (async-aware, mirrors run_task)
await self._arun_task_start_hook(task, task_id)
# Honour the should_run conditional gate (mirrors the Workflow engine).
if await _atask_should_skip(self, task):
logger.info(f"Task {task_id} skipped by should_run gate")
_record_skipped_task(self, task)
return
# Use per-task max_retries if available
task_max = getattr(task, "max_retries", self.max_retries)
retries = 0
@@ -2066,12 +1845,6 @@ class AgentTeam(SpawnAnnounceProtocol):
if task.status == "not started":
task.status = "in progress"
# Handler-only tasks (no agent/agent_config) run their custom callable
# instead of an LLM, mirroring the Workflow engine.
if _task_has_custom_handler(task):
task_result = _execute_task_handler(self, task_id)
return task_result.task_output
# Build execution context using DRY helper
context = _build_execution_context(self, task_id)
@@ -2098,12 +1871,6 @@ class AgentTeam(SpawnAnnounceProtocol):
# Call on_task_start callback and propagate variables (shared with arun_task)
self._run_task_start_hook(task, task_id)
# Honour the should_run conditional gate (mirrors the Workflow engine).
if _task_should_skip(self, task):
logger.info(f"Task {task_id} skipped by should_run gate")
_record_skipped_task(self, task)
return
# Use per-task max_retries if available
task_max = getattr(task, "max_retries", self.max_retries)
retries = 0
@@ -3769,13 +3536,8 @@ class AgentTeam(SpawnAnnounceProtocol):
This method is safe to call from sync contexts only.
"""
with self._spawn_lock:
# Create unique IDs. agent_id keys self._spawned_agents, so retry on
# the (rare) truncated-UUID collision rather than silently clobbering
# an existing spawn record - the same guard the endpoint-path
# generator uses.
# Create unique IDs
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
while agent_id in self._spawned_agents:
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
task_id = f"task_{str(uuid.uuid4())[:8]}"
# Ensure event bus is initialized
@@ -3973,19 +3735,13 @@ class AgentTeam(SpawnAnnounceProtocol):
"""Async version of spawn_sub_agent using asyncio primitives."""
import asyncio
# Guard shared spawn state with the single threading.RLock used by the
# sync path (spawn_sub_agent/announce_completion). An asyncio.Lock would
# not exclude the background spawn thread that mutates the same dicts, so
# both paths must share one real thread lock. This critical section is
# purely synchronous (no await), so holding the thread lock is safe.
with self._spawn_lock:
# Create unique IDs. agent_id keys self._spawned_agents, so retry on
# the (rare) truncated-UUID collision rather than silently clobbering
# an existing spawn record - the same guard the endpoint-path
# generator uses.
# Use asyncio lock for async coordination
if not hasattr(self, '_async_spawn_lock'):
self._async_spawn_lock = asyncio.Lock()
async with self._async_spawn_lock:
# Create unique IDs
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
while agent_id in self._spawned_agents:
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
task_id = f"task_{str(uuid.uuid4())[:8]}"
# Ensure event bus is initialized
@@ -4036,14 +3792,8 @@ class AgentTeam(SpawnAnnounceProtocol):
logger.warning(f"Sub-agent {agent_id} failed: {e}")
await self.aannounce_completion(agent_id, task_id, None, success=False, error=str(e))
# Create task for background execution. Keep a strong reference until the
# task finishes; the loop only holds a weak reference, so without this the
# sub-agent run could be garbage-collected mid-execution.
if not hasattr(self, '_bg_spawn_tasks'):
self._bg_spawn_tasks = set()
_bg_task = asyncio.create_task(_aexecute_sub_agent())
self._bg_spawn_tasks.add(_bg_task)
_bg_task.add_done_callback(self._bg_spawn_tasks.discard)
# Create task for background execution
asyncio.create_task(_aexecute_sub_agent())
logger.debug(f"Async spawned sub-agent {agent_id} for task {task_id}")
return spawned
@@ -4058,11 +3808,12 @@ class AgentTeam(SpawnAnnounceProtocol):
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Async version of announce_completion."""
# Mutate shared completion state under the single threading.RLock shared
# with the sync path — an asyncio.Lock would not exclude the background
# spawn thread. The awaited publish happens outside the lock so we never
# hold a thread lock across an await.
with self._spawn_lock:
import asyncio
if not hasattr(self, '_async_spawn_lock'):
self._async_spawn_lock = asyncio.Lock()
async with self._async_spawn_lock:
# Create completion event
completion_event = SubAgentCompletionEvent(
agent_id=agent_id,
@@ -4076,28 +3827,41 @@ class AgentTeam(SpawnAnnounceProtocol):
# Store the completion event
self._completion_events.append(completion_event)
event_bus = self._event_bus
# Publish completion event via event bus (use async if available)
if event_bus:
event_type = EventType.SUBAGENT_COMPLETED.value if success else EventType.SUBAGENT_ERROR.value
payload = {
"agent_id": agent_id,
"task_id": task_id,
"parent_id": self._team_id,
"result": result,
"success": success,
"error": error,
"completion_time": completion_event.completion_time,
"metadata": completion_event.metadata
}
if hasattr(event_bus, 'publish_async'):
await event_bus.publish_async(event_type, payload)
else:
# Fallback to sync publish
event_bus.publish(event_type, payload)
logger.debug(f"Async announced completion for sub-agent {agent_id}: {'success' if success else 'error'}")
# Publish completion event via event bus (use async if available)
if self._event_bus:
event_type = EventType.SUBAGENT_COMPLETED.value if success else EventType.SUBAGENT_ERROR.value
if hasattr(self._event_bus, 'publish_async'):
await self._event_bus.publish_async(
event_type,
{
"agent_id": agent_id,
"task_id": task_id,
"parent_id": self._team_id,
"result": result,
"success": success,
"error": error,
"completion_time": completion_event.completion_time,
"metadata": completion_event.metadata
}
)
else:
# Fallback to sync publish
self._event_bus.publish(
event_type,
{
"agent_id": agent_id,
"task_id": task_id,
"parent_id": self._team_id,
"result": result,
"success": success,
"error": error,
"completion_time": completion_event.completion_time,
"metadata": completion_event.metadata
}
)
logger.debug(f"Async announced completion for sub-agent {agent_id}: {'success' if success else 'error'}")
async def await_for_completions(
self,
@@ -4107,22 +3871,16 @@ class AgentTeam(SpawnAnnounceProtocol):
"""Async, event-driven version of wait_for_completions."""
import asyncio
# Capture the running loop here, in the coroutine's own frame (we ARE the
# loop thread). check_completions() may run on a background spawn thread
# (spawn_sub_agent publishes synchronously on a plain threading.Thread),
# where asyncio.get_running_loop() would raise RuntimeError and the
# completion signal would be silently dropped — hanging the awaiter.
loop = asyncio.get_running_loop()
# Read target agents inside lock to avoid race condition
if not hasattr(self, '_async_spawn_lock'):
self._async_spawn_lock = asyncio.Lock()
# Read target agents under the single threading.RLock shared with the
# sync path so a background spawn thread cannot mutate _spawned_agents
# mid-read. These critical sections are synchronous (no await).
with self._spawn_lock:
async with self._async_spawn_lock:
target_agents = agent_ids or list(self._spawned_agents.keys())
if not target_agents:
# Return existing completions for the requested agents
with self._spawn_lock:
async with self._async_spawn_lock:
return [e for e in self._completion_events if not agent_ids or e.agent_id in agent_ids]
# Use asyncio.Event for efficient waiting
@@ -4131,54 +3889,45 @@ class AgentTeam(SpawnAnnounceProtocol):
def check_completions():
"""Check if all target agents are completed - THREAD-SAFE version."""
with self._spawn_lock:
events_snapshot = list(self._completion_events)
for event in events_snapshot:
for event in self._completion_events:
if event.agent_id in target_agents:
completed_agents.add(event.agent_id)
if completed_agents >= set(target_agents):
# Use call_soon_threadsafe() with the loop captured in the
# coroutine's frame above, so this works whether check_completions
# runs on the loop thread (initial check) or on a background spawn
# thread (synchronous EventBus publish).
loop.call_soon_threadsafe(completion_event.set)
# Subscribe BEFORE the initial state check. If we checked first and then
# subscribed, a sub-agent finishing on its background thread in that gap
# would append its completion event AND publish it with no subscriber
# attached — and the event bus does not replay past events, so the signal
# would be lost and the awaiter would hang until timeout (or forever when
# timeout is None). completion_handler re-snapshots the full completion
# list under the lock, and Event.set() is idempotent, so subscribing
# first is safe and simply re-checks state on every published completion.
def completion_handler(event):
if event.data.get("parent_id") == self._team_id:
check_completions()
subscriber_id = self._event_bus.subscribe(
completion_handler,
[EventType.SUBAGENT_COMPLETED.value, EventType.SUBAGENT_ERROR.value]
) if self._event_bus else None
try:
# Check initial state (covers completions that landed before we
# subscribed); the subscription covers everything after.
check_completions()
if not completion_event.is_set():
# CRITICAL FIX: Use call_soon_threadsafe() to safely set event from background thread
try:
# Wait for completion or timeout
await asyncio.wait_for(completion_event.wait(), timeout=timeout)
except asyncio.TimeoutError:
loop = asyncio.get_running_loop()
loop.call_soon_threadsafe(completion_event.set)
except RuntimeError:
# Fallback if no event loop running
pass
finally:
# Unsubscribe
if subscriber_id and self._event_bus:
self._event_bus.unsubscribe(subscriber_id)
# Check initial state
check_completions()
if not completion_event.is_set():
# Subscribe to completion events temporarily
def completion_handler(event):
if event.data.get("parent_id") == self._team_id:
check_completions()
subscriber_id = self._event_bus.subscribe(
completion_handler,
[EventType.SUBAGENT_COMPLETED.value, EventType.SUBAGENT_ERROR.value]
) if self._event_bus else None
try:
# Wait for completion or timeout
await asyncio.wait_for(completion_event.wait(), timeout=timeout)
except asyncio.TimeoutError:
pass
finally:
# Unsubscribe
if subscriber_id and self._event_bus:
self._event_bus.unsubscribe(subscriber_id)
# Return completed events
with self._spawn_lock:
async with self._async_spawn_lock:
return [e for e in self._completion_events if e.agent_id in target_agents]
def __enter__(self):
@@ -10,7 +10,7 @@ from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, runtime_checkable
from typing import Any, Dict, List, Optional, Protocol, Tuple, runtime_checkable
def _new_approval_id() -> str:
@@ -38,14 +38,6 @@ class ApprovalRequest:
backend may bind resolution to these reviewers so that in a
multi-operator deployment a request addressed to one
operator cannot be resolved by another.
liveness: Optional zero-arg predicate returning ``True`` while the
originating turn is still the current one. ``None``
(default) is a no-op — the request is treated as always
live, preserving today's behaviour. A backend may consult
this at the resolution/execution boundary to drop a
resolution that arrives after the turn was superseded or
stopped (fail-closed), so a stale approval never fires a
tool or delivers a reply for an abandoned turn.
"""
tool_name: str
@@ -56,7 +48,6 @@ class ApprovalRequest:
context: Dict[str, Any] = field(default_factory=dict)
approval_id: str = field(default_factory=_new_approval_id)
authorized_reviewers: Optional[List[str]] = None
liveness: Optional[Callable[[], bool]] = None
@dataclass
@@ -42,8 +42,6 @@ __all__ = [
"BackgroundConfig",
# Shared runner accessor
"get_background_runner",
# Durable background job store
"SqliteBackgroundJobStore",
]
@@ -69,8 +67,4 @@ def __getattr__(name: str):
from .runner import get_background_runner
return get_background_runner
if name == "SqliteBackgroundJobStore":
from .sqlite_store import SqliteBackgroundJobStore
return SqliteBackgroundJobStore
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -520,47 +520,10 @@ _global_job_manager: Optional[BackgroundJobManager] = None
_global_lock = threading.Lock()
def _build_default_store() -> Optional[BackgroundJobStore]:
"""Best-effort construct the durable SQLite store for the global manager.
Persists to ``<runs_dir>/background_jobs.db`` alongside the run ledger so
``/tasks`` and background subagents survive a restart. Any failure (missing
data dir, read-only filesystem, sqlite error) is swallowed and the manager
falls back to pure in-memory behaviour — durability is an enhancement, never
a hard requirement. Set ``PRAISONAI_BACKGROUND_JOB_STORE=0`` to opt out.
"""
import os
if os.environ.get("PRAISONAI_BACKGROUND_JOB_STORE", "1") in ("0", "false", "False"):
return None
try:
from .sqlite_store import SqliteBackgroundJobStore
return SqliteBackgroundJobStore()
except Exception as e: # noqa: BLE001 — store is optional, never crash startup
logger.debug("Durable background job store unavailable (non-fatal): %s", e)
return None
def get_job_manager() -> BackgroundJobManager:
"""Get the global job manager instance.
On first construction a durable :class:`BackgroundJobStore` is attached when
one can be built (see :func:`_build_default_store`) and
:meth:`BackgroundJobManager.reconcile_on_start` is run once so jobs orphaned
by a prior crash are reconciled to ``LOST`` rather than silently lost.
"""
"""Get the global job manager instance."""
global _global_job_manager
with _global_lock:
if _global_job_manager is None:
store = _build_default_store()
_global_job_manager = BackgroundJobManager(store=store)
if store is not None:
try:
_global_job_manager.reconcile_on_start()
except Exception as e: # noqa: BLE001 — never crash on startup recovery
logger.debug(
"Background job reconciliation on start failed "
"(non-fatal): %s", e,
)
_global_job_manager = BackgroundJobManager()
return _global_job_manager
@@ -1,195 +0,0 @@
"""
SQLite-backed durable store for background jobs.
A zero-dependency (stdlib ``sqlite3``) implementation of
:class:`~praisonaiagents.background.job_manager.BackgroundJobStore`. Jobs are
persisted to ``<runs_dir>/background_jobs.db`` by default so they survive a
gateway restart; on boot :meth:`BackgroundJobManager.reconcile_on_start` calls
:meth:`list_unreconciled` to reconcile jobs left ``PENDING``/``RUNNING`` by a
crashed process (marked ``LOST``) and to replay undelivered completed jobs.
A durable sibling of :class:`~praisonaiagents.runs.sqlite_ledger.SQLiteRunLedger`
and :class:`~praisonaiagents.bus.event_log.SqliteEventLog` — same posture:
stdlib-only, WAL, a single re-entrant lock guards a shared connection opened
with ``check_same_thread=False`` so the runner's worker threads can persist
safely.
"""
from __future__ import annotations
import json
import os
import sqlite3
import threading
from typing import List, Optional
from .._logging import get_logger
from .job_manager import JobInfo, JobStatus
logger = get_logger(__name__)
__all__ = ["SqliteBackgroundJobStore"]
_SCHEMA = """
CREATE TABLE IF NOT EXISTS background_jobs (
job_id TEXT PRIMARY KEY,
status TEXT NOT NULL,
created_at REAL NOT NULL,
started_at REAL,
completed_at REAL,
result TEXT,
error TEXT,
origin TEXT,
delivered INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_background_jobs_status ON background_jobs(status);
"""
def _dumps(value: object) -> Optional[str]:
"""Serialise a value to JSON, never failing on non-JSON-native types."""
if value is None:
return None
try:
return json.dumps(value)
except (TypeError, ValueError):
return json.dumps(value, default=str)
class SqliteBackgroundJobStore:
"""Durable, restart-safe background job store backed by SQLite.
Args:
db_path: Path to the SQLite database file. Defaults to
``<runs_dir>/background_jobs.db``. Use ``":memory:"`` for tests.
"""
def __init__(self, db_path: Optional[str] = None) -> None:
if db_path is None:
from ..paths import get_runs_dir
runs_dir = get_runs_dir()
os.makedirs(runs_dir, exist_ok=True)
db_path = str(runs_dir / "background_jobs.db")
elif db_path != ":memory:":
parent = os.path.dirname(db_path)
if parent:
os.makedirs(parent, exist_ok=True)
self._db_path = db_path
self._lock = threading.RLock()
self._conn = sqlite3.connect(
db_path, check_same_thread=False, isolation_level=None
)
self._conn.row_factory = sqlite3.Row
with self._lock:
# Wait (instead of failing fast) when another process/connection
# holds a write lock; overlapping gateway processes share one file.
self._conn.execute("PRAGMA busy_timeout = 5000")
# WAL lets readers and a writer proceed concurrently and improves
# crash durability. Skip for in-memory DBs where it is a no-op.
if db_path != ":memory:":
try:
self._conn.execute("PRAGMA journal_mode = WAL")
self._conn.execute("PRAGMA synchronous = NORMAL")
except sqlite3.OperationalError: # pragma: no cover - defensive
pass
self._conn.executescript(_SCHEMA)
# ── public API (BackgroundJobStore) ───────────────────────────────
def upsert(self, job: JobInfo) -> None:
"""Insert or update the persisted record for ``job`` (keyed by id)."""
with self._lock:
self._conn.execute(
"""
INSERT INTO background_jobs (
job_id, status, created_at, started_at, completed_at,
result, error, origin, delivered
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(job_id) DO UPDATE SET
status=excluded.status,
started_at=excluded.started_at,
completed_at=excluded.completed_at,
result=excluded.result,
error=excluded.error,
origin=excluded.origin,
delivered=excluded.delivered
""",
(
job.job_id,
job.status.value,
job.created_at,
job.started_at,
job.completed_at,
_dumps(job.result),
job.error,
_dumps(job.origin) if job.origin else None,
1 if job.delivered else 0,
),
)
def get(self, job_id: str) -> Optional[JobInfo]:
"""Return the persisted :class:`JobInfo`, or ``None`` if unknown."""
with self._lock:
row = self._conn.execute(
"SELECT * FROM background_jobs WHERE job_id = ?", (job_id,)
).fetchone()
return self._row_to_job(row) if row is not None else None
def list_unreconciled(self) -> List[JobInfo]:
"""Return jobs needing reconciliation after a restart.
Jobs left ``PENDING``/``RUNNING`` (orphaned by a crash) and jobs
``COMPLETED`` with an ``origin`` that were never ``delivered``. Returned
oldest-first for deterministic replay.
"""
with self._lock:
rows = self._conn.execute(
"""
SELECT * FROM background_jobs
WHERE status IN (?, ?)
OR (status = ? AND delivered = 0 AND origin IS NOT NULL)
ORDER BY created_at ASC
""",
(
JobStatus.PENDING.value,
JobStatus.RUNNING.value,
JobStatus.COMPLETED.value,
),
).fetchall()
return [self._row_to_job(r) for r in rows]
def close(self) -> None:
"""Close the underlying database connection."""
with self._lock:
try:
self._conn.close()
except Exception: # pragma: no cover - defensive
pass
# ── helpers ───────────────────────────────────────────────────────
@staticmethod
def _loads(raw: Optional[str]) -> object:
if raw is None:
return None
try:
return json.loads(raw)
except (ValueError, TypeError):
return None
@classmethod
def _row_to_job(cls, row: sqlite3.Row) -> JobInfo:
origin = cls._loads(row["origin"])
return JobInfo(
job_id=row["job_id"],
status=JobStatus(row["status"]),
created_at=row["created_at"],
started_at=row["started_at"],
completed_at=row["completed_at"],
result=cls._loads(row["result"]),
error=row["error"],
origin=origin if isinstance(origin, dict) else {},
delivered=bool(row["delivered"]),
)
@@ -41,7 +41,6 @@ from .protocols import (
from .base import (
BasePlatformAdapter,
SendResult,
SendStatus,
SendErrorKind,
classify_send_error,
)
@@ -76,9 +75,6 @@ from .interactive import (
unregister_handler,
make_reply_handler,
REPLY_NAMESPACE,
PollResult,
make_poll_result_handler,
POLL_NAMESPACE,
)
from .agent_reply import (
AgentReply,
@@ -173,7 +169,6 @@ __all__ = [
"GatewayAdapterContractError",
"BasePlatformAdapter",
"SendResult",
"SendStatus",
"SendErrorKind",
"classify_send_error",
"InteractiveContext",
@@ -188,9 +183,6 @@ __all__ = [
"unregister_handler",
"make_reply_handler",
"REPLY_NAMESPACE",
"PollResult",
"make_poll_result_handler",
"POLL_NAMESPACE",
"AgentReply",
"TurnCompletion",
"extract_presentation",
@@ -54,23 +54,17 @@ import re
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Literal, Optional, Union
from typing import Any, Dict, List, Optional, Union
from .protocols import PlatformCapabilities
__all__ = [
"SendErrorKind",
"SendStatus",
"SendResult",
"classify_send_error",
"BasePlatformAdapter",
]
#: Closed set of delivery outcomes for :attr:`SendResult.status`. Exposed as a
#: type alias so static callers can verify exhaustive handling of the contract
#: instead of treating the outcome as an arbitrary ``str``.
SendStatus = Literal["sent", "failed", "queued", "duplicate"]
class SendErrorKind(str, Enum):
"""Structured, machine-readable classification of *why* a send failed.
@@ -273,10 +267,6 @@ class SendResult:
behaviour rather than being silently dropped.
retry_after: Suggested seconds to wait before retrying (from the
platform's rate-limit response, if provided).
queued: True when the send was persisted to a durable outbox for later
delivery rather than delivered inline (``status == "queued"``).
duplicate: True when a crash-recovered re-send may be a duplicate —
an honest at-least-once outcome (``status == "duplicate"``).
metadata: Additional platform-specific result details.
"""
@@ -288,32 +278,12 @@ class SendResult:
error_kind: Optional[SendErrorKind] = None
retryable: bool = True
retry_after: Optional[float] = None
queued: bool = False
duplicate: bool = False
metadata: Dict[str, Any] = field(default_factory=dict)
@property
def status(self) -> SendStatus:
"""Closed, branchable delivery outcome for the send contract.
One of ``"sent"`` / ``"failed"`` / ``"queued"`` / ``"duplicate"``,
derived from the existing flags so a caller of ``BotProtocol``'s
``send_message`` can branch on the outcome without exception-handling
(a swallowed exception would otherwise be a silent drop). ``queued``
(persisted to a durable outbox) and ``duplicate`` (crash-recovered
possible re-send) take precedence over the bare ``ok`` flag.
"""
if self.queued:
return "queued"
if self.duplicate:
return "duplicate"
return "sent" if self.ok else "failed"
def to_dict(self) -> Dict[str, Any]:
"""Convert to a plain dictionary."""
return {
"ok": self.ok,
"status": self.status,
"message_id": self.message_id,
"chat_id": self.chat_id,
"message_ids": list(self.message_ids),
@@ -321,8 +291,6 @@ class SendResult:
"error_kind": self.error_kind.value if self.error_kind else None,
"retryable": self.retryable,
"retry_after": self.retry_after,
"queued": self.queued,
"duplicate": self.duplicate,
"metadata": self.metadata,
}
@@ -473,44 +441,6 @@ class BasePlatformAdapter(ABC):
"""
await self.disconnect()
# ------------------------------------------------------------------ #
# Identity canonicalization seam. #
# #
# The gateway keys each conversation on the platform-supplied user id.#
# When a platform changes the *native* id for the *same human* #
# (WhatsApp JID→LID migration, a renamed handle-as-id, a #
# phone-number↔UUID alias flip) the raw id changes and the session #
# would silently fork — history/memory/run-state orphaned. This seam #
# lets an adapter map a volatile raw id to a stable canonical id #
# *before* it becomes part of the session key; the resolver's #
# explicit link/pairing lookup then runs on the stabilised id. #
# #
# It fulfils ``IdentityCanonicalizerProtocol`` (praisonaiagents. #
# gateway.protocols) so an adapter *is* its own canonicalizer: pass #
# ``self`` where that protocol is accepted. #
# ------------------------------------------------------------------ #
def canonicalize(self, platform: str, raw_user_id: str) -> str:
"""Map a raw, potentially-volatile platform id to a stable canonical id.
Consulted on the session-key path *before* link/pairing resolution so
an alias/format change for the *same* human (e.g. WhatsApp's JID→LID
migration, a number↔UUID alias flip) collapses to one stable session
key — preserving transcript, memory and in-flight run-state — instead
of silently forking the conversation.
This is the :class:`~praisonaiagents.gateway.protocols.
IdentityCanonicalizerProtocol` method, so a ``BasePlatformAdapter``
conforms structurally and can be handed anywhere that protocol is
accepted.
Default is the identity function, so adapters that do not override this
are fully backward-compatible (the raw id keys the session exactly as
before). Implementations MUST be deterministic and total: return the
raw id unchanged when no canonical form is known, never raise.
"""
return raw_user_id
# ------------------------------------------------------------------ #
# Capability helpers #
# ------------------------------------------------------------------ #
@@ -659,15 +589,6 @@ class BasePlatformAdapter(ABC):
if not result.ok:
result.message_ids = aggregate.message_ids + result.message_ids
return result
# Preserve non-``sent`` outcomes: a chunk persisted to a durable
# outbox (``queued``) or crash-recovered as a possible re-send
# (``duplicate``) must propagate to the aggregate, otherwise a
# deferred/at-least-once delivery would be silently reported as
# ``sent`` and the outcome the receipt exists to carry is erased.
if result.queued:
aggregate.queued = True
if result.duplicate:
aggregate.duplicate = True
if result.message_id:
aggregate.message_ids.append(result.message_id)
aggregate.message_id = result.message_id
@@ -135,101 +135,6 @@ def make_reply_handler(
return _handler
POLL_NAMESPACE = "poll"
@dataclass
class PollResult:
"""Aggregated result of a group poll, portable across channels.
The inbound counterpart of a :class:`PollPresentation`. A channel adapter
normalises a native poll-answer/close event (Telegram ``poll``, Discord,
Slack) into this shape and routes it through the interactive registry so a
workflow can branch on the outcome without knowing the transport.
Attributes:
poll_id: Correlates the result back to the authored poll (the poll's
``action_id`` when set, else the channel's native poll id).
counts: Mapping of option label -> vote count.
total_voters: Number of distinct voters.
closed: Whether the poll is closed (final tally).
"""
poll_id: str
counts: Dict[str, int]
total_voters: int = 0
closed: bool = False
def winner(self) -> Optional[str]:
"""Return the option with the most votes, or ``None`` when empty/tied.
Ties (more than one option sharing the top count) return ``None`` so a
caller never silently picks an arbitrary winner.
"""
if not self.counts:
return None
top = max(self.counts.values())
leaders = [opt for opt, c in self.counts.items() if c == top]
return leaders[0] if len(leaders) == 1 else None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"poll_id": self.poll_id,
"counts": dict(self.counts),
"total_voters": self.total_voters,
"closed": self.closed,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "PollResult":
"""Create from dictionary."""
return cls(
poll_id=data.get("poll_id", ""),
counts=dict(data.get("counts", {})),
total_voters=data.get("total_voters", 0),
closed=data.get("closed", False),
)
def make_poll_result_handler(
on_result: Callable[["PollResult", "InteractiveContext"], Awaitable[Optional[str]]],
) -> InteractiveHandler:
"""Build a handler for inbound poll results routed to the ``poll`` namespace.
A channel adapter that receives a native poll-answer/close event normalises
it into a :class:`PollResult` and places it on
``context.platform_data["poll_result"]`` before dispatching a ``poll:``
callback. This handler extracts that result (accepting either a
:class:`PollResult` or its ``dict`` form) and forwards it to *on_result*, so
a workflow can tally the vote and continue the turn.
Args:
on_result: Async callable ``(result, context) -> Optional[str]`` invoked
with the aggregated tally.
Returns:
An ``InteractiveHandler`` to register under the ``poll`` namespace.
"""
async def _handler(context: "InteractiveContext") -> Optional[str]:
raw = context.platform_data.get("poll_result")
if raw is None:
payload = context.platform_data.get("decoded_payload") or {}
raw = payload.get("poll_result")
if raw is None:
return None
if isinstance(raw, PollResult):
result = raw
elif isinstance(raw, dict):
result = PollResult.from_dict(raw)
else:
return None
return await on_result(result, context)
return _handler
def encode_action(namespace: str, action: "PresentationAction") -> str:
"""Encode an action with namespace for callback data.
@@ -87,7 +87,6 @@ class BlockType(str, Enum):
CONTEXT = "context" # Contextual info (smaller text)
TABLE = "table" # Tabular data (columns + rows)
CHART = "chart" # Chart/visualisation (kind + series)
POLL = "poll" # Group poll/vote (question + options)
@dataclass
@@ -263,11 +262,6 @@ class PresentationBlock:
# Chart data (CHART blocks)
chart_kind: Optional[str] = None
series: Optional[List[Dict[str, Any]]] = None
# Poll data (POLL blocks)
poll_options: Optional[List[str]] = None
multiple_choice: bool = False
anonymous: bool = True
duration_seconds: Optional[int] = None
@staticmethod
def make_text(content: str, markdown: bool = True) -> "PresentationBlock":
@@ -344,53 +338,6 @@ class PresentationBlock:
text=text,
)
@staticmethod
def make_poll(
question: str,
options: List[str],
*,
multiple_choice: bool = False,
anonymous: bool = True,
duration_seconds: Optional[int] = None,
action_id: Optional[str] = None,
) -> "PresentationBlock":
"""Create a poll block for a channel-neutral group vote.
Args:
question: The poll question.
options: 2..N answer options.
multiple_choice: Allow selecting more than one option.
anonymous: Hide who voted for what (native default on most channels).
duration_seconds: Optional auto-close duration in seconds.
action_id: Optional stable id correlating the inbound
:class:`PollResult` back to this poll.
A channel adapter that implements native poll delivery (e.g. Telegram
``sendPoll``, Discord polls) can opt in via
``PresentationLimits.supports_native_poll``; the vote is then rendered
natively and voting/tallying/closing are handled by the platform.
Everywhere else — including channels whose renderer has no poll branch —
it degrades to a ``BUTTONS`` grid of reply options via
:func:`adapt_presentation`, and the loss is surfaced through
:class:`DegradedDelivery` (never silently).
Raises:
ValueError: If fewer than two options are supplied — a poll needs at
least two choices to represent a vote.
"""
opts = [str(o) for o in options]
if len(opts) < 2:
raise ValueError("A poll requires at least two options")
return PresentationBlock(
type=BlockType.POLL,
text=question,
poll_options=opts,
multiple_choice=multiple_choice,
anonymous=anonymous,
duration_seconds=duration_seconds,
action_id=action_id,
)
@staticmethod
def quick_replies(
choices: List[Any],
@@ -439,12 +386,6 @@ class PresentationBlock:
data["chart_kind"] = self.chart_kind
if self.series is not None:
data["series"] = self.series
if self.poll_options is not None:
data["poll_options"] = self.poll_options
data["multiple_choice"] = self.multiple_choice
data["anonymous"] = self.anonymous
if self.duration_seconds is not None:
data["duration_seconds"] = self.duration_seconds
return data
@classmethod
@@ -467,10 +408,6 @@ class PresentationBlock:
rows=data.get("rows"),
chart_kind=data.get("chart_kind"),
series=data.get("series"),
poll_options=data.get("poll_options"),
multiple_choice=data.get("multiple_choice", False),
anonymous=data.get("anonymous", True),
duration_seconds=data.get("duration_seconds"),
)
@@ -619,7 +556,6 @@ class PresentationLimits:
supports_web_apps: Whether channel supports web apps
supports_tables: Whether channel has a native table widget
supports_charts: Whether channel has native chart/visualisation
supports_native_poll: Whether channel has a native poll/vote API
max_table_rows: Maximum rows in a table block
max_table_cols: Maximum columns in a table block
"""
@@ -635,7 +571,6 @@ class PresentationLimits:
supports_web_apps: bool = False
supports_tables: bool = False
supports_charts: bool = False
supports_native_poll: bool = False
max_table_rows: int = 50
max_table_cols: int = 10
@@ -653,7 +588,6 @@ class PresentationLimits:
"supports_web_apps": self.supports_web_apps,
"supports_tables": self.supports_tables,
"supports_charts": self.supports_charts,
"supports_native_poll": self.supports_native_poll,
"max_table_rows": self.max_table_rows,
"max_table_cols": self.max_table_cols,
}
@@ -673,7 +607,6 @@ class PresentationLimits:
supports_web_apps=data.get("supports_web_apps", False),
supports_tables=data.get("supports_tables", False),
supports_charts=data.get("supports_charts", False),
supports_native_poll=data.get("supports_native_poll", False),
max_table_rows=data.get("max_table_rows", 50),
max_table_cols=data.get("max_table_cols", 10),
)
@@ -690,12 +623,6 @@ class PresentationLimits:
supports_markdown=True,
supports_select=False,
supports_web_apps=True,
# Native poll delivery (Telegram sendPoll) is not yet implemented in
# the praisonai-bot renderer, so keep it disabled: adapt_presentation
# degrades polls to the supported question+buttons fallback instead
# of the renderer silently dropping an unhandled POLL block. Flip to
# True in the same change that adds the renderer's sendPoll branch.
supports_native_poll=False,
)
@staticmethod
@@ -711,10 +638,6 @@ class PresentationLimits:
supports_markdown=True,
supports_select=True,
supports_web_apps=False,
# Slack's Block Kit has no native poll element, and the renderer has
# no POLL branch — keep disabled so polls degrade to question+buttons
# rather than being dropped.
supports_native_poll=False,
)
@staticmethod
@@ -730,11 +653,6 @@ class PresentationLimits:
supports_markdown=True,
supports_select=True,
supports_web_apps=False,
# Discord native poll delivery is not yet implemented in the
# praisonai-bot renderer — keep disabled so polls degrade to
# question+buttons instead of being dropped. Flip to True in the
# same change that adds the renderer's native-poll branch.
supports_native_poll=False,
)
@staticmethod
@@ -926,32 +844,6 @@ def _select_to_buttons(
return PresentationBlock(type=BlockType.BUTTONS, buttons=buttons)
POLL_NAMESPACE = "poll"
def _poll_to_buttons(
block: PresentationBlock,
store: Optional["CallbackPayloadStoreProtocol"] = None,
) -> PresentationBlock:
"""Convert a POLL block into an equivalent BUTTONS block.
Used when a channel has no native poll API. Each option becomes a
``reply``-action button so a tap feeds the chosen option back into the next
agent turn (reusing the existing reply routing and byte-safe callback
encoding). The poll question is not part of the button row; renderers emit
it as leading text (see :func:`adapt_presentation`).
"""
buttons: List[PresentationButton] = []
for option in (block.poll_options or []):
buttons.append(
PresentationButton(
label=str(option),
action=PresentationAction.reply(str(option)),
)
)
return PresentationBlock(type=BlockType.BUTTONS, buttons=buttons)
def _clamp_table(
block: PresentationBlock,
limits: PresentationLimits,
@@ -1068,26 +960,6 @@ def adapt_presentation(
for block in presentation.blocks:
block_type = block.type.value if isinstance(block.type, BlockType) else block.type
if block_type == BlockType.POLL.value:
if limits.supports_native_poll:
adapted_blocks.append(block)
continue
# Degrade poll -> question text + reply buttons, then let the
# buttons flow through the normal button adaptation below.
question = block.text
if (
question is not None
and limits.max_text_length
and len(question) > limits.max_text_length
):
question = question[: limits.max_text_length]
if question:
adapted_blocks.append(
PresentationBlock(type=BlockType.TEXT, text=question)
)
block = _poll_to_buttons(block, callback_store)
block_type = BlockType.BUTTONS.value
if block_type == BlockType.SELECT.value and not limits.supports_select:
# Degrade select -> buttons, then adapt the resulting buttons block
block = _select_to_buttons(block, callback_store)
@@ -1211,7 +1083,6 @@ DEGRADE_BUTTONS_TRUNCATED = "buttons_truncated"
DEGRADE_OPTIONS_TRUNCATED = "options_truncated"
DEGRADE_TABLE_AS_TEXT = "table_rendered_as_text"
DEGRADE_CHART_AS_TEXT = "chart_rendered_as_text"
DEGRADE_POLL_AS_BUTTONS = "poll_rendered_as_buttons"
DEGRADE_CALLBACK_DATA_TOO_LONG = "callback_data_too_long"
@@ -1287,16 +1158,6 @@ def _presentation_degradation(
for block in presentation.blocks:
block_type = block.type.value if isinstance(block.type, BlockType) else block.type
if block_type == BlockType.POLL.value:
if limits.supports_native_poll:
continue
# Follow the adapter: poll -> question text + reply buttons.
n = len(block.poll_options or [])
dropped.append(f"poll ({n} options) rendered as buttons")
reasons.append(DEGRADE_POLL_AS_BUTTONS)
block = _poll_to_buttons(block, callback_store)
block_type = BlockType.BUTTONS.value
if block_type == BlockType.SELECT.value and not limits.supports_select:
# Follow the adapter: select -> buttons (encoding option callbacks
# exactly as adapt_presentation does), then treat as a buttons block.
@@ -31,7 +31,6 @@ from typing import (
if TYPE_CHECKING:
from ..agent import Agent
from .base import SendResult
class ChannelCapabilities(TypedDict, total=False):
@@ -336,13 +335,6 @@ class GatewayRuntimeSeams:
Attributes:
identity_resolver: Cross-platform identity resolver; unifies the same
human across platforms onto one session key.
identity_canonicalizer: Identity-canonicalization seam (Issue #4661) —
an :class:`IdentityCanonicalizerProtocol`
(``canonicalize(platform, raw_user_id) -> str``) applied *before*
the resolver, e.g. an adapter itself (see
:meth:`BasePlatformAdapter.canonicalize`). Stabilises a volatile
platform id (WhatsApp JID→LID, a number↔UUID alias flip) so the same
human keeps one session instead of silently forking.
delivery_router: Backing router for the built-in ``send_message`` tool,
so an agent turn can proactively reach the user mid-task.
admission_gate: Gateway-wide admission control (concurrency ceiling /
@@ -355,7 +347,6 @@ class GatewayRuntimeSeams:
"""
identity_resolver: Optional[Any] = None
identity_canonicalizer: Optional[Any] = None
delivery_router: Optional[Any] = None
admission_gate: Optional[Any] = None
turn_lock_map: Optional[Any] = None
@@ -930,29 +921,17 @@ class BotProtocol(Protocol):
content: Union[str, Dict[str, Any]],
reply_to: Optional[str] = None,
thread_id: Optional[str] = None,
) -> Union[BotMessage, "SendResult"]:
) -> BotMessage:
"""Send a message to a channel.
Args:
channel_id: Target channel ID
content: Message content
reply_to: Optional message ID to reply to
thread_id: Optional thread ID for threaded replies
Returns:
A **typed delivery outcome**. Adapters SHOULD return a
:class:`~praisonaiagents.bots.SendResult` — the transport-neutral
receipt that carries a closed, branchable ``status``
(``"sent"`` / ``"failed"`` / ``"queued"`` / ``"duplicate"``), the
platform ``message_id``/``message_ids`` (for edit-in-place, delete
and threading), and a classified ``error``/``error_kind`` on
failure. This makes a silent drop *unrepresentable*: a caller can
branch on ``result.status`` instead of relying on an exception that
may be swallowed.
A legacy :class:`BotMessage` remains an accepted return for
backward compatibility — treat a returned ``BotMessage`` as a
successful send (``status == "sent"``).
The sent message
"""
...
@@ -139,12 +139,7 @@ class ContextCompactor:
# Anti-thrashing state tracking
self._last_savings_pct: float = 100.0 # Start high to allow first compaction
self._low_savings_streak: int = 0
# Token count at the moment the streak reached its cap. The guard is
# released once the conversation grows materially past this, so a
# compactor that once could not shrink the context is never disabled
# for the rest of the session.
self._low_savings_tokens: int = 0
# Iterative summary state
self._previous_summary: Optional[str] = None
self._previous_summary_global_idx: int = 0
@@ -213,56 +208,14 @@ class ContextCompactor:
- Token count is below threshold
- We've had too many consecutive low-savings attempts
"""
total_tokens = self.count_total_tokens(messages)
if total_tokens <= self.max_tokens:
if self.count_total_tokens(messages) <= self.max_tokens:
return False
# Anti-thrashing: skip if we've had too many low-savings attempts,
# unless the conversation has grown enough to be worth re-testing.
if self._should_skip_low_savings(total_tokens):
return False
return True
#: Growth factor that releases the anti-thrashing guard. A conversation
#: 25% larger than the one we gave up on is materially different input,
#: so the earlier "not worth compacting" verdict is re-tested.
_LOW_SAVINGS_RETRY_GROWTH = 1.25
def _should_skip_low_savings(self, original_tokens: int) -> bool:
"""Whether the anti-thrashing guard should skip this pass.
The guard stops a compactor that cannot shrink a conversation from
burning a summarisation call every turn. It must not be permanent:
``_low_savings_streak`` is only cleared *after* a compaction actually
runs, so a compactor that trips the cap would otherwise skip forever
and let the context grow without bound -- the precise opposite of what
the guard is for.
Growth past the size we gave up on is new evidence, so the verdict is
retried and the streak reset.
"""
if self._low_savings_streak < self.config.max_consecutive_low_savings:
return False
if self._low_savings_tokens and original_tokens >= (
self._low_savings_tokens * self._LOW_SAVINGS_RETRY_GROWTH
):
self._low_savings_streak = 0
self._low_savings_tokens = 0
return False
return True
def _record_savings_outcome(self, savings_pct: float, original_tokens: int) -> None:
"""Update anti-thrashing state after a completed compaction pass."""
self._last_savings_pct = savings_pct
if savings_pct >= self.config.min_savings_pct:
self._low_savings_streak = 0
self._low_savings_tokens = 0
return
self._low_savings_streak += 1
# Anti-thrashing: skip if we've had too many low-savings attempts
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
# Remember the size we gave up at, so growth can release the guard.
self._low_savings_tokens = original_tokens
return False
return True
def compact(
self,
@@ -301,7 +254,7 @@ class ContextCompactor:
# Anti-thrashing: if prior passes yielded too little savings, skip and
# surface it on the result so callers can detect the skip. Without this
# the documented ``was_skipped_due_to_low_savings`` flag never fired.
if self._should_skip_low_savings(original_tokens):
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
result = CompactionResult(
original_tokens=original_tokens,
compacted_tokens=original_tokens,
@@ -370,7 +323,11 @@ class ContextCompactor:
result.calculate_savings_pct()
# Update anti-thrashing tracking based on actual results
self._record_savings_outcome(result.savings_pct, original_tokens)
self._last_savings_pct = result.savings_pct
if result.savings_pct >= self.config.min_savings_pct:
self._low_savings_streak = 0
else:
self._low_savings_streak += 1
return compacted, result
@@ -408,7 +365,7 @@ class ContextCompactor:
# Anti-thrashing: if prior passes yielded too little savings, skip and
# surface it on the result so callers can detect the skip. Without this
# the documented ``was_skipped_due_to_low_savings`` flag never fired.
if self._should_skip_low_savings(original_tokens):
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
result = CompactionResult(
original_tokens=original_tokens,
compacted_tokens=original_tokens,
@@ -459,7 +416,11 @@ class ContextCompactor:
result.calculate_savings_pct()
# Update anti-thrashing tracking based on actual results
self._record_savings_outcome(result.savings_pct, original_tokens)
self._last_savings_pct = result.savings_pct
if result.savings_pct >= self.config.min_savings_pct:
self._low_savings_streak = 0
else:
self._low_savings_streak += 1
return compacted, result
@@ -204,61 +204,52 @@ def evaluate_condition(
else:
substituted = substituted.replace(placeholder, str(value))
# A comparison operator in the *template* (not the substituted values)
# is what makes this a comparison condition. When the template has no
# operator, it is a plain flag/contains/truthy check and the comparison
# parsers below must be skipped, otherwise a placeholder whose value
# happens to look like a comparison (e.g. "{{flag}}" -> "5 < 3") would be
# mis-parsed as a comparison instead of honoring the truthy-string path.
template_has_comparison = re.search(r'>=|<=|==|!=|>|<', condition) is not None
# Now evaluate the substituted condition
try:
# Handle different condition formats
if template_has_comparison:
# Check if we have unsubstituted empty values in comparisons
# If a variable was missing, it becomes empty string which can cause issues
if ' > ' in substituted or ' < ' in substituted or ' >= ' in substituted or ' <= ' in substituted:
# Check for empty left side in comparison (missing variable)
if substituted.strip().startswith('>') or substituted.strip().startswith('<'):
return False
if substituted.strip().startswith('='):
return False
# Check if we have unsubstituted empty values in comparisons
# If a variable was missing, it becomes empty string which can cause issues
if ' > ' in substituted or ' < ' in substituted or ' >= ' in substituted or ' <= ' in substituted:
# Check for empty left side in comparison (missing variable)
if substituted.strip().startswith('>') or substituted.strip().startswith('<'):
return False
if substituted.strip().startswith('='):
return False
# Numeric comparisons: "90 > 80", "50 >= 50", "10 < 20", etc.
numeric_pattern = r'^(-?\d+(?:\.\d+)?)\s*(>|>=|<|<=|==|!=)\s*(-?\d+(?:\.\d+)?)$'
numeric_match = re.match(numeric_pattern, substituted.strip())
if numeric_match:
left = float(numeric_match.group(1))
op = numeric_match.group(2)
right = float(numeric_match.group(3))
# Numeric comparisons: "90 > 80", "50 >= 50", "10 < 20", etc.
numeric_pattern = r'^(-?\d+(?:\.\d+)?)\s*(>|>=|<|<=|==|!=)\s*(-?\d+(?:\.\d+)?)$'
numeric_match = re.match(numeric_pattern, substituted.strip())
if numeric_match:
left = float(numeric_match.group(1))
op = numeric_match.group(2)
right = float(numeric_match.group(3))
if op == '>':
return left > right
if op == '>=':
return left >= right
if op == '<':
return left < right
if op == '<=':
return left <= right
if op == '==':
return left == right
if op == '!=':
return left != right
if op == '>':
return left > right
if op == '>=':
return left >= right
if op == '<':
return left < right
if op == '<=':
return left <= right
if op == '==':
return left == right
if op == '!=':
return left != right
# String equality: "approved == approved", "status != rejected"
string_eq_pattern = r'^(.+?)\s*(==|!=)\s*(.+)$'
string_match = re.match(string_eq_pattern, substituted.strip())
if string_match:
left = string_match.group(1).strip()
op = string_match.group(2)
right = string_match.group(3).strip()
# String equality: "approved == approved", "status != rejected"
string_eq_pattern = r'^(.+?)\s*(==|!=)\s*(.+)$'
string_match = re.match(string_eq_pattern, substituted.strip())
if string_match:
left = string_match.group(1).strip()
op = string_match.group(2)
right = string_match.group(3).strip()
if op == '==':
return left == right
if op == '!=':
return left != right
if op == '==':
return left == right
if op == '!=':
return left != right
# Contains check: "error in some message", "status contains success"
if ' in ' in substituted:
@@ -281,16 +272,7 @@ def evaluate_condition(
return True
if substituted.strip().lower() == 'false':
return False
# A comparison operator in the template survived every format-specific
# check above, so the comparison it was meant to drive never matched
# (e.g. a missing or non-numeric operand). Fail safe per this
# function's own contract instead of treating the leftover operator
# text as a truthy string. Plain flag values (no template operator)
# are untouched and fall through to the truthy check below.
if template_has_comparison:
return False
# Non-empty string is truthy
return bool(substituted.strip())
@@ -196,11 +196,11 @@ _MODULE_MAP = {
}
from .._lazy import create_lazy_getattr
_LAZY_IMPORTS = {
name: (f"{__name__}.{submodule}", name)
for name, submodule in _MODULE_MAP.items()
}
__getattr__ = create_lazy_getattr(_LAZY_IMPORTS, __name__)
def __getattr__(name: str):
"""Lazy load config classes and utilities."""
if name in _MODULE_MAP:
module_name = _MODULE_MAP[name]
import importlib
module = importlib.import_module(f".{module_name}", __name__)
return getattr(module, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -13,7 +13,6 @@ from datetime import datetime
from ..llm.protocols import LLMProviderProtocol
from .tokens import estimate_messages_tokens, get_estimator
from ..llm.model_providers import default_auxiliary_model
@dataclass
@@ -375,7 +374,7 @@ Provide a concise summary under {max_tokens} tokens that preserves the essential
# Try common OpenAI-compatible interface - check if async
if hasattr(self.llm.chat.completions, 'acreate'):
response = await self.llm.chat.completions.acreate(
model=default_auxiliary_model(model),
model=model or "gpt-4o-mini",
messages=[{"role": "user", "content": summary_prompt}],
max_tokens=max_tokens,
temperature=0.3,
@@ -383,7 +382,7 @@ Provide a concise summary under {max_tokens} tokens that preserves the essential
else:
# Fallback to sync create (blocking in async context)
response = self.llm.chat.completions.create(
model=default_auxiliary_model(model),
model=model or "gpt-4o-mini",
messages=[{"role": "user", "content": summary_prompt}],
max_tokens=max_tokens,
temperature=0.3,
@@ -10,7 +10,6 @@ from abc import ABC, abstractmethod
from .models import ContextLedger, OptimizerStrategy, OptimizationResult
from .tokens import estimate_messages_tokens, get_estimator
from .compressor import ContextCompressor
from ..llm.model_providers import default_auxiliary_model
logger = logging.getLogger(__name__)
@@ -639,7 +638,7 @@ class LLMContextCompressorOptimizer(BaseOptimizer):
use_accurate_tokenizer: Use model-specific tokenizer if available
"""
self.llm_client = llm_client
self.auxiliary_model = default_auxiliary_model(auxiliary_model)
self.auxiliary_model = auxiliary_model or "gpt-4o-mini"
self.protect_last_n_tokens = protect_last_n_tokens
self.summary_target_tokens = summary_target_tokens
@@ -29,14 +29,6 @@ MODEL_DIMENSIONS: Dict[str, int] = {
# Jina models
"jina-embeddings-v3": 1024,
"jina-embeddings-v2-base-en": 768,
# Locally-served embedders (Ollama tags). Dimensions measured by embedding a
# probe string through a live Ollama 0.33.2 and counting the returned vector,
# cross-checked against `<family>.embedding_length` from /api/show. Without
# these every local embedder inherited DEFAULT_DIMENSION (1536, OpenAI's),
# so a vector index was built 2x-4x the size the model actually produces.
"nomic-embed-text": 768, # measured
"mxbai-embed-large": 1024, # measured
"all-minilm": 384, # measured; Ollama's short tag for all-MiniLM
# HuggingFace common models
"all-MiniLM-L6-v2": 384,
"all-mpnet-base-v2": 768,
@@ -78,30 +70,20 @@ def get_dimensions(model_name: str) -> int:
1536
>>> get_dimensions("openai/text-embedding-3-large")
3072
>>> get_dimensions("ollama/nomic-embed-text:v1.5")
768
>>> get_dimensions("unknown-model")
1536
"""
model_lower = model_name.lower()
# Check for exact match first (lowercased lookup handles mixed-case keys)
if model_lower in _LOWER_MODEL_DIMENSIONS:
return _LOWER_MODEL_DIMENSIONS[model_lower]
# Local models are written the way their runtime names them:
# "ollama/nomic-embed-text:v1.5". Strip the litellm-style provider prefix
# and the version tag, then retry the exact match, so a tagged local model
# resolves instead of falling through to the OpenAI default.
normalised = model_lower.rsplit("/", 1)[-1].split(":", 1)[0]
if normalised != model_lower and normalised in _LOWER_MODEL_DIMENSIONS:
return _LOWER_MODEL_DIMENSIONS[normalised]
# Check if model name contains known model identifiers.
# Entries are pre-sorted by key length (longest first) so more specific
# keys like "voyage-3-lite" match before shorter prefixes like "voyage-3".
for model_key, dimensions in _SORTED_MODEL_DIMENSIONS:
if model_key in model_lower or model_key in normalised:
if model_key in model_lower:
return dimensions
# Default to 1536 for unknown models (OpenAI standard)
@@ -68,10 +68,6 @@ __all__ = [
"EvalReport",
"EvalPackage",
"EvalRunnerProtocol",
# EvalPort adapter (framework-neutral interop; dependency-free)
"to_evalport",
"from_evalport",
"report_to_evalport",
# Unified Judge API (follows add_X/get_X/list_X naming)
"Judge",
"JudgeConfig",
@@ -164,10 +160,6 @@ _LAZY_IMPORTS = {
"EvalReport": ("praisonaiagents.eval.package", "EvalReport"),
"EvalPackage": ("praisonaiagents.eval.package", "EvalPackage"),
"EvalRunnerProtocol": ("praisonaiagents.eval.package", "EvalRunnerProtocol"),
# EvalPort adapter (framework-neutral interop; dependency-free)
"to_evalport": ("praisonaiagents.eval.evalport", "to_evalport"),
"from_evalport": ("praisonaiagents.eval.evalport", "from_evalport"),
"report_to_evalport": ("praisonaiagents.eval.evalport", "report_to_evalport"),
# Unified Judge API (follows add_X/get_X/list_X naming)
"Judge": ("praisonaiagents.eval.judge", "Judge"),
"JudgeConfig": ("praisonaiagents.eval.judge", "JudgeConfig"),
@@ -1,165 +0,0 @@
"""
EvalPort adapter - framework-neutral interop for the eval package.
Converts PraisonAI's native eval data models to/from the EvalPort open spec
(https://github.com/adhabnr-ux/evalport), so a suite built here can run against
another tool's harness, and results can be diffed/aggregated across tools.
Mapping (1:1 with EvalPort's ``Suite`` / ``ResultSet`` concepts):
EvalPackage <-> EvalPort Suite (name/description/version + cases + thresholds)
EvalCase <-> EvalPort test case (input / expected_output / grader refs)
EvalReport -> EvalPort ResultSet (aggregate + per-case pass/score/latency)
Design: this module is intentionally **dependency-free**. It emits and consumes
plain spec-shaped dicts (JSON), so the core SDK does not take on ``evalport-sdk``
as a dependency. Callers who want schema validation can pass the returned dict to
``openeval.validate.validate_suite()`` / ``validate_result_set()`` themselves.
Example:
>>> from praisonaiagents.eval import EvalPackage, EvalCase
>>> from praisonaiagents.eval import to_evalport, from_evalport, report_to_evalport
>>> pkg = EvalPackage(name="support", cases=[
... EvalCase(name="refund", input="refund policy?", expected="30 days")
... ], thresholds={"accuracy": 0.9})
>>> suite = to_evalport(pkg) # EvalPackage -> EvalPort Suite dict
>>> pkg2 = from_evalport(suite) # EvalPort Suite dict -> EvalPackage
>>> pkg2.name == pkg.name
True
"""
from typing import Any, Dict, List
from .package import EvalCase, EvalPackage, EvalReport
EVALPORT_SPEC_VERSION = "1.0"
def _case_to_evalport(case: EvalCase) -> Dict[str, Any]:
"""Map an ``EvalCase`` to an EvalPort test-case dict.
The native ``timeout_seconds`` is emitted as a dedicated top-level field so
it never collides with a user-supplied ``metadata["timeout_seconds"]`` key,
keeping round trips lossless for both.
"""
item: Dict[str, Any] = {
"id": case.name,
"input": case.input,
}
if case.expected is not None:
item["expected_output"] = case.expected
if case.criteria:
item["graders"] = list(case.criteria)
if case.timeout_seconds is not None:
item["timeout_seconds"] = case.timeout_seconds
metadata = dict(case.metadata or {})
if metadata:
item["metadata"] = metadata
return item
def _case_from_evalport(item: Dict[str, Any]) -> EvalCase:
"""Map an EvalPort test-case dict to an ``EvalCase``.
Prefers the dedicated top-level ``timeout_seconds`` field; falls back to a
``metadata["timeout_seconds"]`` value for suites emitted by other tools,
without mutating the caller's metadata.
"""
metadata = dict(item.get("metadata") or {})
if "timeout_seconds" in item:
timeout = item["timeout_seconds"]
else:
timeout = metadata.get("timeout_seconds", 30.0)
return EvalCase(
name=item.get("id") or item.get("name") or "case",
input=item.get("input", ""),
expected=item.get("expected_output", item.get("expected")),
criteria=list(item.get("graders") or item.get("criteria") or []),
metadata=metadata,
timeout_seconds=timeout,
)
def to_evalport(package: EvalPackage) -> Dict[str, Any]:
"""Convert an ``EvalPackage`` to an EvalPort ``Suite`` dict.
Args:
package: The native PraisonAI eval package.
Returns:
A spec-shaped dict suitable for ``openeval.validate.validate_suite()``.
"""
return {
"evalport_version": EVALPORT_SPEC_VERSION,
"kind": "suite",
"name": package.name,
"description": package.description,
"version": package.version,
"cases": [_case_to_evalport(c) for c in package.cases],
"thresholds": dict(package.thresholds or {}),
"seed": package.seed,
}
def from_evalport(suite: Dict[str, Any]) -> EvalPackage:
"""Convert an EvalPort ``Suite`` dict to a native ``EvalPackage``.
Args:
suite: A spec-shaped EvalPort suite dict (e.g. from the Benchmark Hub).
Returns:
An ``EvalPackage`` whose cases can be run via ``EvalSuite`` /
``HarnessEvaluator`` as native ``EvalCase`` objects.
"""
cases: List[EvalCase] = [_case_from_evalport(c) for c in suite.get("cases", [])]
return EvalPackage(
name=suite.get("name", "suite"),
description=suite.get("description", ""),
version=suite.get("version", "1.0.0"),
cases=cases,
thresholds=dict(suite.get("thresholds") or {}),
seed=suite.get("seed"),
)
def report_to_evalport(report: EvalReport) -> Dict[str, Any]:
"""Convert an ``EvalReport`` to an EvalPort ``ResultSet`` dict.
Args:
report: The aggregated report from running an eval package.
Returns:
A spec-shaped dict suitable for
``openeval.validate.validate_result_set()``.
"""
results: List[Dict[str, Any]] = []
for r in report.results:
item: Dict[str, Any] = {
"case_id": r.case_name,
"passed": r.passed,
"score": r.score,
"latency_ms": r.latency_ms,
}
if r.actual_output is not None:
item["actual_output"] = r.actual_output
if r.error is not None:
item["error"] = r.error
if r.criteria_scores:
item["grader_scores"] = dict(r.criteria_scores)
if r.record is not None:
item["record"] = r.record
results.append(item)
return {
"evalport_version": EVALPORT_SPEC_VERSION,
"kind": "result_set",
"suite_name": report.package_name,
"summary": {
"total": report.total_cases,
"passed": report.passed_cases,
"failed": report.failed_cases,
"pass_rate": report.pass_rate,
"average_score": report.average_score,
"thresholds_met": dict(report.thresholds_met or {}),
},
"results": results,
}
@@ -83,10 +83,6 @@ from .protocols import (
GatewayIdlePolicyProtocol,
GatewayIdlePolicy, # backward-compat alias
ScaleToZeroPolicy,
# Gateway freeze-thaw (involuntary host-suspend gap) recovery (Issue #4767)
ThawDecision,
ThawPolicyProtocol,
WallClockGapThawPolicy,
# Gateway graceful-drain on shutdown (Issue #2375)
DrainDecision,
GatewayDrainPolicyProtocol,
@@ -109,10 +105,6 @@ from .protocols import (
RateLimitPolicyProtocol,
RateLimitPolicy, # backward-compat alias
SlidingWindowRateLimitPolicy,
# Gateway cost-aware spend-budget admission (Issue #4894)
SpendBudgetPolicyProtocol,
SpendBudgetPolicy, # backward-compat alias
WindowedSpendBudgetPolicy,
# Durable-queue dead-letter decision (Issue #3519)
PERMANENT_ERROR_CLASSES,
DeadLetterDecision,
@@ -135,12 +127,6 @@ from .protocols import (
FatalConfigError,
classify_exit_reason,
RestartLoopGuard,
# Durable process-lifecycle record + unclean/OOM classification (Issue #4603)
LifecycleRecord,
classify_unclean_exit,
RestartStoreProtocol,
PersistentRestartLoopGuard,
classify_resource_pressure,
# Fleet-level crash-loop breaker for channel supervision (Issue #3840)
FleetSupervisionPolicy,
# Protocol version negotiation
@@ -378,10 +364,6 @@ __all__ = [
"GatewayIdlePolicyProtocol",
"GatewayIdlePolicy",
"ScaleToZeroPolicy",
# Gateway freeze-thaw (involuntary host-suspend gap) recovery (Issue #4767)
"ThawDecision",
"ThawPolicyProtocol",
"WallClockGapThawPolicy",
"DrainDecision",
"GatewayDrainPolicyProtocol",
"DrainTimeoutPolicy",
@@ -403,9 +385,6 @@ __all__ = [
"RateLimitPolicyProtocol",
"RateLimitPolicy",
"SlidingWindowRateLimitPolicy",
"SpendBudgetPolicyProtocol",
"SpendBudgetPolicy",
"WindowedSpendBudgetPolicy",
# Durable-queue dead-letter decision (Issue #3519)
"PERMANENT_ERROR_CLASSES",
"DeadLetterDecision",
@@ -428,12 +407,6 @@ __all__ = [
"FatalConfigError",
"classify_exit_reason",
"RestartLoopGuard",
# Durable process-lifecycle record + unclean/OOM classification (Issue #4603)
"LifecycleRecord",
"classify_unclean_exit",
"RestartStoreProtocol",
"PersistentRestartLoopGuard",
"classify_resource_pressure",
# Fleet-level crash-loop breaker for channel supervision (Issue #3840)
"FleetSupervisionPolicy",
# Protocol version negotiation
@@ -6,7 +6,6 @@ Provides configuration dataclasses for gateway and session settings.
from dataclasses import dataclass, field
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
@@ -19,9 +18,6 @@ from typing import (
runtime_checkable,
)
if TYPE_CHECKING: # pragma: no cover - typing only, no runtime import cost
from .protocols import TurnExecutorProtocol
# ---------------------------------------------------------------------------
# Canonical config version + doctor-driven migration (Issue #3841)
@@ -858,14 +854,6 @@ class GatewayConfig:
control: "EmergencyStopConfig" = field(
default_factory=lambda: EmergencyStopConfig()
)
# Issue #4766: per-session turn-execution seam. Selects *where* a session's
# agent turn runs (see ``TurnExecutorProtocol``). ``None`` (the default)
# resolves to ``InProcessTurnExecutor`` at runtime — today's on-loop
# behaviour, byte-for-byte, with no dependency introduced. Supplying an
# isolated executor (subprocess / container / remote, in the wrapper)
# contains a wedged/runaway turn to its own worker instead of forcing the
# whole-process ``os._exit`` remedy.
executor: "Optional[TurnExecutorProtocol]" = None
def __post_init__(self) -> None:
"""Post-initialization to set bind_host from host if not specified and validate values."""
@@ -989,13 +977,6 @@ class GatewayConfig:
"liveness": self.liveness.to_dict(),
"turn_lock": self.turn_lock.to_dict(),
"control": self.control.to_dict(),
# Report the active executor type so ``gateway doctor`` can surface
# it; ``None`` means the in-process default (today's behaviour).
"executor": (
type(self.executor).__name__
if self.executor is not None
else "inprocess"
),
}
@property
@@ -1938,16 +1938,7 @@ class RouteBinding:
chat_type: Chat type ("dm" | "group" | "channel").
peer: Sender/user id (most specific).
role: Role / guild-role membership of the sender.
channel_id: Specific chat/channel id. Also matches a message whose
parent channel is this id (a thread/forum-post under it), so a
channel-level route naturally covers the threads beneath it
without listing each thread by hand (Issue #4839).
thread_id: Specific thread / forum-post id (most specific unit — one
triage-spun support thread can route to a specialist agent,
Issue #4839).
guild_id: Server / workspace id. One rule covers every current and
future channel in the guild, so an operator can multiplex by the
tenant boundary they actually think in (Issue #4839).
channel_id: Specific chat/channel id.
account: Receiving bot account (for multi-account channels).
priority: Higher wins; ties are broken by specificity then order.
trust: Optional trust tier ("untrusted" | "standard" | "trusted").
@@ -1976,21 +1967,13 @@ class RouteBinding:
allow_tools: Optional[List[str]] = None
deny_tools: Optional[List[str]] = None
profile: Optional[str] = None
# New optional conditions are appended here (never inserted mid-list) so
# existing positional constructors — RouteBinding("a", "dm", ..., priority)
# — keep their meaning and stay backward-compatible (Issue #4839 review).
thread_id: Optional[str] = None
guild_id: Optional[str] = None
# Specificity weights — exact thread beats peer beats role/channel beats
# guild/account beats chat-type. Higher means more specific, so a single
# thread rule wins over a whole-guild rule (Issue #4839).
# Specificity weights — exact peer beats role/channel beats account
# beats chat-type. Higher means more specific.
_SPECIFICITY = {
"thread_id": 32,
"peer": 16,
"role": 8,
"channel_id": 8,
"guild_id": 4,
"account": 4,
"chat_type": 2,
}
@@ -2034,20 +2017,7 @@ class RouteBinding:
"""Return True if every constrained condition equals the facts."""
if self.peer is not None and str(self.peer) != str(facts.peer):
return False
if self.channel_id is not None:
# Parent-chain match (Issue #4839): a channel_id condition matches
# the channel itself OR a thread/forum-post whose parent is that
# channel, so channel-level routes cover the threads beneath them
# without enumerating each thread.
expected_channel = str(self.channel_id)
candidates = {str(facts.channel_id)}
if facts.parent_channel_id is not None:
candidates.add(str(facts.parent_channel_id))
if expected_channel not in candidates:
return False
if self.thread_id is not None and str(self.thread_id) != str(facts.thread_id):
return False
if self.guild_id is not None and str(self.guild_id) != str(facts.guild_id):
if self.channel_id is not None and str(self.channel_id) != str(facts.channel_id):
return False
if self.account is not None and str(self.account) != str(facts.account):
return False
@@ -2096,8 +2066,6 @@ class RouteBinding:
role=data.get("role"),
channel_id=_as_opt_str(data.get("channel_id")),
account=_as_opt_str(data.get("account")),
thread_id=_as_opt_str(data.get("thread_id")),
guild_id=_as_opt_str(data.get("guild_id")),
priority=int(data.get("priority", 0) or 0),
trust=_as_opt_str(data.get("trust")),
allow_tools=_as_opt_str_list(data.get("allow_tools")),
@@ -2116,13 +2084,6 @@ class RouteFacts:
roles: Roles/guild-role memberships of the sender.
channel_id: The chat/channel id the message arrived in.
account: The receiving bot account (multi-account channels).
thread_id: The thread / forum-post id the message arrived in, when the
platform threads conversations (Issue #4839).
guild_id: The server / workspace id the message belongs to (Discord
guild, Slack workspace), used for whole-server routing (Issue #4839).
parent_channel_id: The parent channel of a thread/forum-post, so a
channel-level binding can cover the threads beneath it via
parent-chain matching (Issue #4839).
"""
chat_type: str = "default"
@@ -2130,9 +2091,6 @@ class RouteFacts:
roles: List[str] = field(default_factory=list)
channel_id: Optional[str] = None
account: Optional[str] = None
thread_id: Optional[str] = None
guild_id: Optional[str] = None
parent_channel_id: Optional[str] = None
@dataclass
@@ -2190,9 +2148,8 @@ def resolve_route(
"""Resolve the handling agent from priority-ordered bindings.
Bindings are evaluated most-specific-first: the matching binding with the
highest ``priority`` wins; ties are broken by specificity (exact thread →
peer → role/channel → guild/account → chat-type), then by declaration
order.
highest ``priority`` wins; ties are broken by specificity (exact peer →
role/channel → account → chat-type), then by declaration order.
Args:
bindings: Candidate route bindings (any order).
@@ -3424,150 +3381,6 @@ class ScaleToZeroPolicy:
GatewayIdlePolicy = GatewayIdlePolicyProtocol
# ---------------------------------------------------------------------------
# Gateway freeze-thaw (involuntary host-suspend gap) recovery (Issue #4767)
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class ThawDecision:
"""Result of a freeze-thaw (host-suspend gap) evaluation.
Attributes:
suspended: Whether an involuntary host-suspend gap was detected
since the previous observation.
gap_seconds: Estimated duration the host was frozen (0.0 when not
suspended).
restart_transports: Whether the wrapper should re-probe / restart
outbound channel sockets (idle-gated) because they may be
silently half-dead after the freeze.
reconcile_schedule: Whether the wrapper should reconcile the
scheduler (coalesce missed cron fires) against the gap instead
of letting wall-clock catch-up storm on resume.
"""
suspended: bool
gap_seconds: float = 0.0
restart_transports: bool = False
reconcile_schedule: bool = False
@runtime_checkable
class ThawPolicyProtocol(Protocol):
"""Protocol for freeze-thaw (involuntary host-suspend) detection.
Pure, import-free decision contract consumed by the wrapper's
``BotOS`` run-loop. The wrapper ticks the policy with a matched pair
of ``time.monotonic()`` and ``time.time()`` readings; the policy
compares the elapsed monotonic delta against the elapsed wall-clock
delta and returns a :class:`ThawDecision`. On Linux ``CLOCK_MONOTONIC``
does not advance while the host is suspended, so a wall-clock delta
that runs far ahead of the monotonic delta is a positive signature of
a freeze the process could not otherwise observe. Concrete recovery
(restart channel sockets when idle, refresh presence, reconcile the
scheduler) lives in the wrapper; this contract keeps the *detection*
testable in isolation.
"""
def observe(self, *, monotonic_now: float, wall_now: float) -> ThawDecision:
"""Return a :class:`ThawDecision` for the supplied clock readings."""
...
class WallClockGapThawPolicy:
"""Config-driven default freeze-thaw detector.
The default referenced by ``gateway.thaw:`` blocks in ``gateway.yaml``
and the ``BotOS(..., thaw_policy=...)`` Python surface. It is
intentionally minimal and dependency-free so the decision lives in
core and is provable in isolation; the wrapper owns the side effects
(restart channel sockets, refresh presence, reconcile the scheduler).
A suspend gap is detected when, between two consecutive
:meth:`observe` calls, the wall-clock delta ran ahead of the monotonic
delta by more than ``gap_threshold_s`` *and* the monotonic clock itself
stayed near-frozen (advanced by no more than one tick plus a small
tolerance). Both conditions are the true signature of a frozen host:
wall-clock jumps forward while ``CLOCK_MONOTONIC`` stalls. The reported
``gap_seconds`` is the wall-vs-monotonic divergence — the time the host
was actually frozen.
Requiring the monotonic delta to stay near-frozen distinguishes a real
host suspend from a forward *wall-clock correction* (NTP step / manual
clock set) that occurs while the process is running normally: in the
correction case monotonic keeps advancing on schedule, so no recovery
is requested and healthy transports/schedule are left untouched.
The first observation only seeds the baseline and never reports a gap,
so the default preserves current behaviour when no freeze occurs.
Example::
WallClockGapThawPolicy(tick_interval_s=15.0, gap_threshold_s=60.0)
"""
def __init__(
self,
tick_interval_s: float = 15.0,
gap_threshold_s: float = 60.0,
enabled: bool = True,
):
if tick_interval_s <= 0:
raise ValueError(
f"tick_interval_s must be > 0, got {tick_interval_s!r}"
)
if gap_threshold_s <= 0:
raise ValueError(
f"gap_threshold_s must be > 0, got {gap_threshold_s!r}"
)
self.tick_interval_s = float(tick_interval_s)
self.gap_threshold_s = float(gap_threshold_s)
self.enabled = bool(enabled)
self._last_monotonic: Optional[float] = None
self._last_wall: Optional[float] = None
def observe(self, *, monotonic_now: float, wall_now: float) -> ThawDecision:
if not self.enabled:
return ThawDecision(suspended=False)
prev_monotonic = self._last_monotonic
prev_wall = self._last_wall
self._last_monotonic = monotonic_now
self._last_wall = wall_now
# First tick only seeds the baseline; nothing to compare against.
if prev_monotonic is None or prev_wall is None:
return ThawDecision(suspended=False)
monotonic_delta = monotonic_now - prev_monotonic
wall_delta = wall_now - prev_wall
# Divergence: how far wall-clock ran ahead of the process's own
# elapsed (monotonic) time — i.e. the frozen window.
divergence = wall_delta - monotonic_delta
# A real host suspend freezes CLOCK_MONOTONIC: it stalls, so across
# the gap it advances by less than one scheduled tick. A forward
# wall-clock *correction* (NTP step / manual set) instead leaves
# monotonic advancing in step with the loop — a full tick or more —
# proving the process kept running. Requiring monotonic to have
# stalled below one tick rejects that false positive so healthy
# transports and the scheduler are left untouched.
monotonic_frozen = monotonic_delta < self.tick_interval_s
gap_detected = (
divergence > self.gap_threshold_s
and monotonic_frozen
)
if not gap_detected:
return ThawDecision(suspended=False)
return ThawDecision(
suspended=True,
gap_seconds=divergence,
restart_transports=True,
reconcile_schedule=True,
)
# ---------------------------------------------------------------------------
# Gateway graceful-drain on shutdown (Issue #2375)
# ---------------------------------------------------------------------------
@@ -4315,193 +4128,6 @@ class SlidingWindowRateLimitPolicy:
RateLimitPolicy = RateLimitPolicyProtocol
# ---------------------------------------------------------------------------
# Gateway cost-aware spend-budget admission (Issue #4894)
#
# Rate limiting gates on *request count*; a public multi-user gateway also
# needs to gate on *cumulative cost per identity*. This mirrors
# :class:`RateLimitPolicyProtocol` exactly — a pure, import-free decision over
# typed facts (an identity, a scope, the amount already ``spent_usd`` in the
# window and a timestamp) returning the same closed :class:`RateLimitDecision`.
# The wrapper supplies ``spent_usd`` from a durable
# :class:`~praisonaiagents.telemetry.protocols.TokenUsageSinkProtocol`
# (e.g. :class:`~praisonaiagents.telemetry.durable_sink.SqliteTokenUsageSink`)
# and consults this policy at the same admit decision as concurrency /
# rate-limit, *before* the LLM call. Concrete state and storage live outside
# the decision, keeping it injectable and testable in isolation.
# ---------------------------------------------------------------------------
@runtime_checkable
class SpendBudgetPolicyProtocol(Protocol):
"""Protocol for gateway cumulative-spend admission decisions.
Pure, import-free decision contract symmetric with
:class:`RateLimitPolicyProtocol`. The wrapper supplies typed facts — the
caller ``identity``, the ``scope`` (channel / tenant token), the amount
already ``spent_usd`` for that identity+scope in the current window, the
current ``now`` timestamp, and (optionally) the ``pending_usd`` estimated
cost of the turn being admitted and the ``oldest_spend_ts`` of the spend in
the window — and the policy decides whether the turn is allowed or
rejected/throttled with a ``retry_after_seconds`` hint. Storage of the
spend ledger lives in a
:class:`~praisonaiagents.telemetry.protocols.TokenUsageSinkProtocol`; this
contract keeps only the *decision* injectable and provable in isolation.
A config-driven default (:class:`WindowedSpendBudgetPolicy`) is provided
for the common "cap $X per identity per rolling window" case.
"""
def check(
self,
*,
identity: str,
scope: str,
spent_usd: float,
now: float,
pending_usd: float = 0.0,
oldest_spend_ts: Optional[float] = None,
) -> RateLimitDecision:
"""Return a :class:`RateLimitDecision` for the supplied facts."""
...
class WindowedSpendBudgetPolicy:
"""Config-driven cumulative per-identity spend-budget policy.
Intended as the default the wrapper will wire onto a forthcoming
``gateway.budget`` config block and a ``spend_budget_policy=`` gateway
constructor argument (neither of which exists in the repo yet — this PR
ships only the core decision primitive; the CLI/YAML and constructor
surfaces are a follow-up wrapper change). It is intentionally minimal and
dependency-free so the decision lives in core and is provable in isolation,
exactly like :class:`SlidingWindowRateLimitPolicy`.
The decision, given the amount ``spent_usd`` already accumulated for the
identity+scope in the current window and the ``pending_usd`` estimated cost
of the turn being admitted:
* ``allowed`` while ``spent_usd + pending_usd`` is below ``limit_usd``.
* Otherwise the turn is denied with a ``retry_after_seconds`` hint equal to
the time until enough spend rolls out of the window. When
``oldest_spend_ts`` is supplied the hint is computed precisely
(``oldest_spend_ts + window_seconds - now``); without it the policy falls
back to the full ``window_seconds`` as a safe upper bound.
Passing ``pending_usd`` lets a caller reserve budget for the turn's
estimated cost *before* the LLM call so a single expensive turn cannot
overshoot the cap; leaving it at the default ``0.0`` preserves the simpler
"gate on spend so far" behaviour.
A ``limit_usd`` of ``0`` (or negative) disables the budget entirely (every
turn is allowed) — the legacy default when no budget is configured.
Unlike :class:`SlidingWindowRateLimitPolicy` this policy is *stateless*: the
cumulative spend is owned by a durable
:class:`~praisonaiagents.telemetry.protocols.TokenUsageSinkProtocol`, so the
same budget survives restarts and is shared across processes.
Example::
WindowedSpendBudgetPolicy(limit_usd=2.00, window_seconds=86_400)
"""
def __init__(
self,
*,
limit_usd: float = 0.0,
window_seconds: float = 86_400.0,
):
try:
limit = float(limit_usd)
except (TypeError, ValueError) as err:
raise ValueError(
f"limit_usd must be a number, got {limit_usd!r}"
) from err
try:
window = float(window_seconds)
except (TypeError, ValueError) as err:
raise ValueError(
f"window_seconds must be a number, got {window_seconds!r}"
) from err
if window <= 0:
raise ValueError(
f"window_seconds must be > 0, got {window_seconds!r}"
)
self.limit_usd = limit
self.window_seconds = window
@property
def enabled(self) -> bool:
"""Whether budgeting is active (a positive limit is set)."""
return self.limit_usd > 0
def window_start(self, now: float) -> float:
"""Start timestamp of the current window for a ``now`` timestamp.
Helper for the wrapper so it can query the durable sink for
``spent_usd`` over the same window the policy enforces.
"""
return float(now) - self.window_seconds
def check(
self,
*,
identity: str,
scope: str,
spent_usd: float,
now: float,
pending_usd: float = 0.0,
oldest_spend_ts: Optional[float] = None,
) -> RateLimitDecision:
# Disabled: preserve legacy always-allow behaviour.
if self.limit_usd <= 0:
return RateLimitDecision(allowed=True)
try:
spent = float(spent_usd)
except (TypeError, ValueError):
spent = 0.0
try:
pending = float(pending_usd)
except (TypeError, ValueError):
pending = 0.0
if pending < 0:
pending = 0.0
# Reserve budget for the pending turn's estimated cost so a single
# expensive turn cannot overshoot the cap before its cost is recorded.
if spent + pending < self.limit_usd:
return RateLimitDecision(allowed=True)
return RateLimitDecision(
allowed=False,
retry_after_seconds=self._retry_after(now, oldest_spend_ts),
)
def _retry_after(
self, now: float, oldest_spend_ts: Optional[float]
) -> float:
"""Seconds until enough spend rolls out of the rolling window.
With ``oldest_spend_ts`` the earliest in-window charge expires at
``oldest_spend_ts + window_seconds``; the hint is the time until then,
clamped to ``(0, window_seconds]``. Without it, fall back to the full
window as a safe upper bound.
"""
if oldest_spend_ts is None:
return self.window_seconds
try:
remaining = (float(oldest_spend_ts) + self.window_seconds) - float(now)
except (TypeError, ValueError):
return self.window_seconds
if remaining <= 0:
# Spend has already aged out; retry effectively immediately.
return 0.0
return min(remaining, self.window_seconds)
# Backward-compatible alias following the repo's ``*Protocol`` convention.
SpendBudgetPolicy = SpendBudgetPolicyProtocol
# ---------------------------------------------------------------------------
# Durable-queue dead-letter decision (Issue #3519)
#
@@ -5177,308 +4803,6 @@ class RestartLoopGuard:
self._events = []
# ---------------------------------------------------------------------------
# Process-lifecycle record + unclean/OOM classification (Issue #4603)
#
# ``RestartLoopGuard`` above tracks the *rate* of restart-worthy boots in
# memory, and ``classify_exit_reason`` maps a caught exception to an exit code.
# Neither survives a ``SIGKILL``/OOM-kill — no in-process hook runs for those —
# so a hosted gateway cannot tell on its next boot that its previous process
# died uncleanly. These three pure primitives close that gap: a small durable
# lifecycle *record* the runtime restamps on boot/shutdown, a pure predicate
# that classifies "was the last exit clean?", and a restart-loop guard whose
# events load from / flush to an injected durable store so the breaker survives
# the very death it guards. All I/O — the ``/proc``/cgroup sampling and the
# durable sentinel — lives in the ``praisonai-bot`` runtime; core stays
# protocol + pure default policy, symmetric with the block above.
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class LifecycleRecord:
"""Durable, boot-scoped snapshot of the gateway process's lifecycle.
Written by the runtime at boot (``phase="running"``) and on a graceful stop
(``phase="exited"``), with a memory sample folded in by the heartbeat. On
the *next* boot the runtime reads the prior record and runs
:func:`classify_unclean_exit`: a record still marked ``running`` means the
previous process never reached its graceful-stop path — an unclean exit,
and (given a high last memory sample) a suspected OOM-kill.
Pure and JSON-round-trippable so both the durable store and the health
surface share one shape; the wrapper owns persistence.
Attributes:
phase: ``"running"`` | ``"draining"`` | ``"exited"`` — the last phase
the process recorded for itself.
exit_kind: ``"clean"`` | ``"unclean"`` | ``"unknown"`` — set by
:func:`classify_unclean_exit` on the *next* boot (never
``"unknown"``-treated-as-healthy downstream).
suspected_oom: ``True`` when an unclean exit coincided with a high last
memory sample (memory pressure at death).
last_mem_fraction: Last sampled memory-budget fraction in ``[0, 1]``, or
``None`` when the platform could not report it.
restart_events: Persisted monotonic-independent wall-clock timestamps of
restart-worthy boots, for :class:`PersistentRestartLoopGuard`.
"""
phase: Literal["running", "draining", "exited"] = "running"
exit_kind: Literal["clean", "unclean", "unknown"] = "unknown"
suspected_oom: bool = False
last_mem_fraction: Optional[float] = None
restart_events: List[float] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Convert to a JSON-serializable dict for the durable sentinel."""
return {
"phase": self.phase,
"exit_kind": self.exit_kind,
"suspected_oom": self.suspected_oom,
"last_mem_fraction": self.last_mem_fraction,
"restart_events": list(self.restart_events),
}
@classmethod
def from_dict(cls, data: Optional[Mapping[str, Any]]) -> "LifecycleRecord":
"""Rebuild from a persisted dict, tolerating partial/legacy shapes."""
if not isinstance(data, Mapping):
return cls()
phase = data.get("phase")
if phase not in ("running", "draining", "exited"):
phase = "running"
exit_kind = data.get("exit_kind")
if exit_kind not in ("clean", "unclean", "unknown"):
exit_kind = "unknown"
frac = data.get("last_mem_fraction")
try:
frac_val: Optional[float] = None if frac is None else float(frac)
except (TypeError, ValueError):
frac_val = None
raw_events = data.get("restart_events") or []
events: List[float] = []
if isinstance(raw_events, (list, tuple)):
for item in raw_events:
try:
events.append(float(item))
except (TypeError, ValueError):
continue
return cls(
phase=phase, # type: ignore[arg-type]
exit_kind=exit_kind, # type: ignore[arg-type]
suspected_oom=bool(data.get("suspected_oom", False)),
last_mem_fraction=frac_val,
restart_events=events,
)
def classify_unclean_exit(
prev: Optional[LifecycleRecord],
*,
oom_fraction: float = 0.9,
) -> LifecycleRecord:
"""Classify the previous boot's exit and return a fresh boot record (pure).
Given the record persisted by the *previous* process, decide whether that
process exited cleanly and stamp a new ``phase="running"`` record for *this*
boot. Side-effect free so it is provable in isolation, mirroring
:func:`classify_exit_reason`.
Rules:
* No prior record ⇒ first boot / fresh store: ``exit_kind="unknown"`` (the
caller must never treat ``"unknown"`` as healthy, but there is nothing to
resume).
* Prior ``phase == "exited"`` ⇒ the previous process reached its graceful
stop: ``exit_kind="clean"``.
* Prior ``phase in ("running", "draining")`` ⇒ the previous process died
before recording a clean exit (SIGKILL/OOM/segfault/orchestrator kill):
``exit_kind="unclean"``, and ``suspected_oom`` when its last memory sample
was at/above ``oom_fraction`` of the budget.
The returned record carries forward the prior ``restart_events`` so the
persistent restart-loop guard sees the full cross-boot history.
Args:
prev: The previous process's persisted :class:`LifecycleRecord`, or
``None`` on a fresh store.
oom_fraction: Memory-budget fraction at/above which an unclean exit is
flagged as a suspected OOM-kill.
Returns:
A fresh ``phase="running"`` :class:`LifecycleRecord` for this boot.
"""
carried = list(prev.restart_events) if prev is not None else []
if prev is None:
return LifecycleRecord(
phase="running", exit_kind="unknown", restart_events=carried
)
if prev.phase == "exited":
return LifecycleRecord(
phase="running", exit_kind="clean", restart_events=carried
)
# Still "running"/"draining" in the prior record ⇒ never reached graceful stop.
frac = prev.last_mem_fraction
suspected_oom = frac is not None and frac >= oom_fraction
return LifecycleRecord(
phase="running",
exit_kind="unclean",
suspected_oom=suspected_oom,
last_mem_fraction=frac,
restart_events=carried,
)
@runtime_checkable
class RestartStoreProtocol(Protocol):
"""Durable-store contract for cross-boot restart timestamps (Issue #4603).
The pure decision (:class:`PersistentRestartLoopGuard`) lives in core; the
wrapper injects a concrete store that reads/writes the timestamps to a small
sentinel file. Both methods are best-effort: an implementation that cannot
reach its backing store returns ``[]`` from :meth:`load_events` and silently
drops :meth:`save_events`, so the breaker degrades to in-memory behaviour
rather than crashing the gateway it protects.
"""
def load_events(self) -> List[float]:
"""Return the persisted restart timestamps (``[]`` when unavailable)."""
...
def save_events(self, events: List[float]) -> None:
"""Persist the restart timestamps (best-effort, never raises)."""
...
class PersistentRestartLoopGuard(RestartLoopGuard):
"""A :class:`RestartLoopGuard` whose events survive a process restart.
The in-memory guard is reconstructed fresh on every boot, so a persisted
turn that hard-crashes the *whole* process (OOM, segfault) can drive an
infinite ``supervisor → boot → auto-resume → crash`` loop the breaker never
sees. This subclass loads its event history from an injected
:class:`RestartStoreProtocol` at construction and flushes back on every
mutation, so the breaker observes the true cross-boot restart rate and can
trip on a crash cycle *slower* than a single in-process window.
The window defaults to one hour (vs. the in-process 60s) because a
cross-boot crash loop is paced by process (re)start latency, not by an
in-loop retry. Pure decision preserved: all persistence is delegated to the
injected store, so the trip logic remains provable in isolation.
Example::
guard = PersistentRestartLoopGuard(store, max_restarts=3,
window_seconds=3600)
if guard.record(now=time.time()):
# too many restart-interrupted boots — stop auto-resuming the turn
...
"""
def __init__(
self,
store: RestartStoreProtocol,
*,
max_restarts: int = 3,
window_seconds: float = 3600.0,
):
super().__init__(max_restarts=max_restarts, window_seconds=window_seconds)
self._store = store
try:
loaded = store.load_events()
except Exception:
loaded = []
if loaded:
self._events = [float(t) for t in loaded]
def _flush(self) -> None:
try:
self._store.save_events(list(self._events))
except Exception:
pass
def record(self, now: float) -> bool:
"""Record a restart at wall-clock ``now`` and flush to the store.
Unlike the base class this expects a *wall-clock* timestamp
(``time.time()``), because a monotonic clock resets across process
boots and would make persisted events meaningless.
"""
tripped = super().record(now)
self._flush()
return tripped
def reset(self) -> None:
"""Clear the history in memory *and* in the durable store."""
super().reset()
self._flush()
def classify_resource_pressure(
*,
mem_fraction: Optional[float] = None,
disk_free_mb: Optional[float] = None,
mem_elevated: float = 0.75,
mem_critical: float = 0.9,
disk_min_free_mb: float = 512.0,
) -> Dict[str, str]:
"""Classify coarse memory + disk pressure for the health surface (pure).
A tiny, redaction-safe classifier that folds the wrapper's ``/proc``/cgroup
memory-budget fraction and free-disk sample into a closed ``ok`` /
``elevated`` / ``critical`` / ``unknown`` vocabulary. Lives in core so the
SDK and the bot classify identically; the wrapper owns the sampling.
``unknown`` is returned when a signal is missing and — per the issue — must
never be treated as healthy by callers (it is deliberately *not* ``ok``).
Args:
mem_fraction: Anonymous-RSS / memory-budget fraction in ``[0, ∞)``, or
``None`` when the platform has no cgroup limit to report against.
disk_free_mb: Free disk headroom in MiB for the gateway's state dir, or
``None`` when it could not be sampled.
mem_elevated: Fraction at/above which memory is ``"elevated"``.
mem_critical: Fraction at/above which memory is ``"critical"``.
disk_min_free_mb: Free-MiB floor below which disk is ``"critical"``;
``"elevated"`` below twice that.
Returns:
``{"memory": <level>, "disk": <level>}`` with values drawn from the
closed vocabulary above.
"""
def _mem(frac: Optional[float]) -> str:
if frac is None:
return "unknown"
try:
f = float(frac)
except (TypeError, ValueError):
return "unknown"
if f >= mem_critical:
return "critical"
if f >= mem_elevated:
return "elevated"
return "ok"
def _disk(free_mb: Optional[float]) -> str:
if free_mb is None:
return "unknown"
try:
mb = float(free_mb)
except (TypeError, ValueError):
return "unknown"
if mb < disk_min_free_mb:
return "critical"
if mb < disk_min_free_mb * 2:
return "elevated"
return "ok"
return {"memory": _mem(mem_fraction), "disk": _disk(disk_free_mb)}
class FleetSupervisionPolicy:
"""Pure fleet-level crash-loop breaker for channel supervision (Issue #3840).
@@ -14,14 +14,6 @@ from .protocols import (
is_guardrail_object,
)
from .chain import GuardrailChain
from .retry import GuardrailRetry
from .tool_guardrails import (
ToolInputGuardrail,
ToolOutputGuardrail,
ToolGuardrailChain,
build_tool_guardrails,
get_tool_guardrail_chain,
)
__all__ = [
"GuardrailResult",
@@ -30,12 +22,5 @@ __all__ = [
"StructuralGuardrailProtocol",
"PolicyGuardrailProtocol",
"GuardrailChain",
"GuardrailRetry",
"is_guardrail_object",
# Per-tool guardrails (declared via @tool(input_guardrails=/output_guardrails=))
"ToolInputGuardrail",
"ToolOutputGuardrail",
"ToolGuardrailChain",
"build_tool_guardrails",
"get_tool_guardrail_chain",
]
@@ -6,7 +6,6 @@ separated from the protocol definitions for clean architecture.
"""
from typing import List, Dict, Any, Tuple
from .protocols import GuardrailProtocol
from .retry import GuardrailRetry
class GuardrailChain:
@@ -79,22 +78,7 @@ class GuardrailChain:
return False, arguments # Fail closed on error
return True, arguments
def validate_tool_result(self, tool_name: str, result: Any, **kwargs) -> Tuple[bool, Any]:
"""Validate a tool result through all guardrails (fail-closed)."""
for guardrail in self.guardrails:
if hasattr(guardrail, 'validate_tool_result'):
try:
is_valid, processed_result = guardrail.validate_tool_result(tool_name, result, **kwargs)
if not is_valid:
return False, result # Failed validation
result = processed_result # Update result with processing (e.g. redaction)
except Exception as e:
if not self.fail_open:
return False, result # Fail closed on error
return True, result
def _validate_through_chain(self, method_name: str, content: str, **kwargs) -> Tuple[bool, str]:
"""Internal helper to validate content through the guardrail chain."""
for guardrail in self.guardrails:
@@ -105,11 +89,6 @@ class GuardrailChain:
if not is_valid:
return False, processed_content # Failed validation
content = processed_content # Update content with processing
except GuardrailRetry as e:
# Deliberate "not acceptable, here is why" signal: surface the
# author's message verbatim so it can be handed to the model,
# instead of burying it under the generic error wrapper below.
return False, e.feedback
except Exception as e:
if not self.fail_open:
return False, f"Guardrail error: {str(e)}" # Fail closed on error
@@ -194,26 +194,7 @@ Your response:"""
is_valid, result = self._llm_validate(tool_text, tool_description)
return is_valid, arguments # Return original arguments (LLM doesn't modify them)
def validate_tool_result(self, tool_name: str, result: Any, **kwargs) -> Tuple[bool, Any]:
"""
Validate a tool's result using LLM reasoning before it re-enters context.
Args:
tool_name: Name of the tool that produced the result
result: Tool result to validate
**kwargs: Additional context
Returns:
Tuple of (is_valid: bool, processed_result: Any)
"""
# Convert tool result to text for LLM validation
result_text = f"Tool: {tool_name}, Result: {result}"
result_description = f"Validate this tool result: {self.description}"
is_valid, _ = self._llm_validate(result_text, result_description)
return is_valid, result # Return original result (LLM doesn't modify it)
def _llm_validate(self, content: str, description: str) -> Tuple[bool, str]:
"""
Internal method to perform LLM validation with custom description.
@@ -16,7 +16,6 @@ class GuardrailProtocol(Protocol):
Guardrails can validate:
- Input prompts (before processing)
- Tool call arguments (before execution)
- Tool results (before they re-enter the LLM context)
- Agent outputs (before returning to user)
Example:
@@ -77,29 +76,6 @@ class GuardrailProtocol(Protocol):
"""
...
def validate_tool_result(self, tool_name: str, result: Any, **kwargs) -> Tuple[bool, Any]:
"""
Validate/redact a tool's result before it re-enters the LLM context.
Runs after the tool executes and before its output is handed back to
the model, trace, or downstream channel. Lets a guardrail inspect or
rewrite raw tool output (e.g. redact a leaked secret, strip a
prompt-injection payload) that ``validate_output`` never sees because
it only ever receives the model's paraphrase.
Args:
tool_name: Name of the tool that produced the result
result: The tool's raw result to validate
**kwargs: Additional context (agent_name, etc.)
Returns:
Tuple of (is_valid: bool, processed_result: Any)
- If is_valid=False, the result is rejected (fail-closed)
- If is_valid=True, processed_result may be the original or a
redacted/rewritten value
"""
...
@runtime_checkable
class StructuralGuardrailProtocol(Protocol):
@@ -202,7 +178,7 @@ class PolicyGuardrailProtocol(Protocol):
# Method names that make an object a guardrail. An object exposing at least one
# of these is a protocol-conforming guardrail even though it is not ``callable``.
GUARDRAIL_PROTOCOL_METHODS = ("validate_input", "validate_output", "validate_tool_call", "validate_tool_result")
GUARDRAIL_PROTOCOL_METHODS = ("validate_input", "validate_output", "validate_tool_call")
def is_guardrail_object(obj: Any) -> bool:
@@ -1,47 +0,0 @@
"""In-run guardrail retry signalling.
A guardrail normally reports failure by returning ``(False, "why")``. That works
when the validator *is* the thing doing the checking, but not when the check
lives several frames down - a Pydantic model that fails to parse, a lookup that
comes back empty, a helper that already raises. ``GuardrailRetry`` lets any of
those hand their reason straight back to the model:
from praisonaiagents import Agent, GuardrailRetry
def in_customer_country(output):
if not output.raw.startswith("SW1"):
raise GuardrailRetry("the postcode must be in the customer's country")
return True, output
agent = Agent(instructions="...", guardrails=in_customer_country)
The message is appended to the *same* conversation as a user turn, so the model
patches its previous answer in place instead of restarting the task, bounded by
``max_guardrail_retries`` (``GuardrailConfig(max_retries=...)``).
Any other exception raised by a guardrail is still treated as a validation
*error* and reported as such; ``GuardrailRetry`` is the deliberate "not
acceptable, here is why" signal.
"""
__all__ = ["GuardrailRetry"]
class GuardrailRetry(Exception):
"""Raised by a guardrail to send its reason back to the model for another attempt.
Args:
feedback: Plain-language reason the output was rejected. It is shown to
the model verbatim, so write it as an instruction to the model
("the postcode must be in the customer's country"), not as a stack
trace.
Attributes:
feedback: The same string, kept as a named attribute so callers do not
have to ``str()`` the exception.
"""
def __init__(self, feedback: str = "Output failed validation"):
feedback = str(feedback) if feedback else "Output failed validation"
super().__init__(feedback)
self.feedback = feedback
@@ -1,316 +0,0 @@
"""Per-tool guardrails: validate the arguments and the result of ONE tool.
PraisonAI already had guardrails at two scopes and nothing in between:
* ``Agent(guardrail=...)`` / ``Task(guardrail=...)`` validate the **final
output** of a whole run.
* The agent-wide tool surface (``_tool_call_guardrails`` /
``_tool_result_guardrails``, fed from a ``GuardrailProtocol`` object passed to
``Agent(guardrails=...)``) fires for **every tool** the agent can reach.
* ``approval`` gates a tool call on a **human**, interactively.
None of those let a tool author say "*this one* tool must never be called with a
recipient outside the company domain". Doing it today meant policing every tool
through interactive approval, or hand-wrapping the function body. This module
adds the missing scope, declared on the tool itself next to ``approval`` and
``restart_safe``::
def internal_recipients_only(arguments: dict):
if not arguments.get("to", "").endswith("@corp.com"):
return False, "Recipient is outside the company domain."
return True, arguments
@tool(input_guardrails=[internal_recipients_only])
def send_email(to: str, body: str) -> str:
...
The guardrail fires on every invocation of ``send_email`` and never for any
other tool.
What a guardrail function receives and returns
----------------------------------------------
An **input** guardrail takes one positional argument, the ``arguments`` dict the
model proposed. An **output** guardrail takes one positional argument, the raw
result the tool returned. Both use the ``(success, value)`` tuple convention
already used everywhere else in this package (``Agent(guardrail=fn)``,
``GuardrailProtocol``):
* ``(True, value)`` - allow; ``value`` replaces the arguments/result, so a
guardrail can **rewrite** (sanitise arguments, redact a secret out of a
result). ``(True, None)`` allows the original through unchanged.
* ``(False, "reason")`` - block. The reason is handed back to the **model** as
the tool result so it can react and try something else; it is never raised at
the user.
* A bare ``True`` / ``False`` works too, as does a
:class:`~praisonaiagents.guardrails.GuardrailResult`.
* ``None`` (a validator that only ever returns ``False`` to object, and falls
off the end otherwise) means "no opinion" - allow unchanged. The deny path is
explicit, so nothing is lost by being permissive here.
* Anything else is a contract violation and **fails closed**: an unreadable
verdict is not an approval.
A guardrail that raises also fails closed, matching ``GuardrailChain``'s
default. Pass an object exposing ``validate_tool_call`` / ``validate_tool_result``
(any ``GuardrailProtocol`` implementation, including a ``GuardrailChain``) to
reuse an existing guardrail unchanged.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional, Tuple
from .chain import GuardrailChain
from .guardrail_result import GuardrailResult
logger = logging.getLogger(__name__)
# Direction identifiers. An input guardrail gates the arguments before dispatch;
# an output guardrail gates the raw result before it re-enters the LLM context.
INPUT = "input"
OUTPUT = "output"
# The protocol method each direction drives. Per-tool guardrails deliberately
# speak the SAME protocol as agent-wide ones so a guardrail written for one
# scope drops into the other with no adaptation.
_METHOD_FOR = {INPUT: "validate_tool_call", OUTPUT: "validate_tool_result"}
_DEFAULT_REASON = {
INPUT: "rejected by an input guardrail",
OUTPUT: "rejected by an output guardrail",
}
# Attribute a tool declares its guardrails on, per direction.
ATTR_FOR = {INPUT: "input_guardrails", OUTPUT: "output_guardrails"}
# Where a coerced chain is memoised on a tool object, so a tool that declared a
# raw list (e.g. a ``BaseTool`` subclass with ``input_guardrails = [fn]`` as a
# class attribute) is only coerced once rather than on every invocation.
_CACHE_ATTR = "_praison_tool_guardrail_chains"
def _interpret(outcome: Any, original: Any, default_reason: str) -> Tuple[bool, Any, str]:
"""Normalise a user guardrail's return value into ``(ok, value, reason)``.
Accepts every shape documented in the module docstring. Unknown shapes fail
closed - a verdict we cannot read must not be treated as an approval.
"""
if outcome is None:
# "No opinion": a validator that objects explicitly (returns False or
# raises) and simply falls off the end on the happy path.
return True, original, ""
if isinstance(outcome, GuardrailResult):
if outcome.success:
# ``result=None`` means "unchanged" - keep the caller's value rather
# than replacing it with None.
return True, original if outcome.result is None else outcome.result, ""
return False, original, outcome.error or default_reason
# bool must be checked before tuple; it is neither, but ordering keeps the
# intent obvious next to the tuple branch.
if isinstance(outcome, bool):
return (True, original, "") if outcome else (False, original, default_reason)
if isinstance(outcome, tuple) and len(outcome) == 2:
ok, data = outcome
if ok:
return True, original if data is None else data, ""
return False, original, str(data) if data else default_reason
return False, original, (
f"guardrail returned {type(outcome).__name__}; expected (bool, value), "
"a bool, a GuardrailResult, or None"
)
class _CallableToolGuardrail:
"""Adapts a plain ``fn(value) -> (ok, value)`` into the guardrail protocol.
Keeping the adapter protocol-shaped (rather than special-casing callables in
the executor) is what lets per-tool and agent-wide guardrails share one
chain implementation and one call path.
"""
direction = INPUT
def __init__(self, fn, name: Optional[str] = None):
if not callable(fn):
raise TypeError(
f"A tool guardrail must be callable or expose "
f"{_METHOD_FOR[self.direction]}(); got {type(fn).__name__}."
)
self._fn = fn
self.name = name or getattr(fn, "__name__", None) or type(fn).__name__
def _run(self, value: Any) -> Tuple[bool, Any, str]:
return _interpret(self._fn(value), value, _DEFAULT_REASON[self.direction])
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<{type(self).__name__} {self.name}>"
class ToolInputGuardrail(_CallableToolGuardrail):
"""Gates a single tool's arguments before the tool runs.
Wraps a ``fn(arguments: dict)`` validator. A rewrite must still be a mapping
of keyword arguments - returning anything else is treated as a contract
violation and blocks, because handing a non-mapping to the tool would raise
inside user code instead of producing a message the model can act on.
"""
direction = INPUT
def validate_tool_call(self, tool_name: str, arguments: Dict[str, Any], **kwargs):
ok, value, reason = self._run(arguments)
if not ok:
return False, reason
if not isinstance(value, dict):
return False, (
f"input guardrail '{self.name}' rewrote the arguments to "
f"{type(value).__name__}; expected a dict of keyword arguments"
)
return True, value
class ToolOutputGuardrail(_CallableToolGuardrail):
"""Gates a single tool's raw result before it re-enters the LLM context."""
direction = OUTPUT
def validate_tool_result(self, tool_name: str, result: Any, **kwargs):
ok, value, reason = self._run(result)
if not ok:
return False, reason
return True, value
class ToolGuardrailChain(GuardrailChain):
"""A :class:`GuardrailChain` that reports *why* a tool call was rejected.
The base chain deliberately returns the **unchanged input** on failure
(``return False, arguments``) because its agent-wide callers only need the
boolean and want to keep their arguments intact. A per-tool guardrail has to
hand a message back to the model so it can react, so this subclass
propagates the guardrail's reason as the second element instead.
Everything else - ordering, short-circuit on first failure, fail-closed on a
raising guardrail, the ``fail_open`` escape hatch - is inherited unchanged.
A second element that is not a non-empty string is not a reason (a raw
``GuardrailProtocol`` object echoes the arguments back), so the direction's
default reason is used in that case.
"""
def __init__(self, guardrails: List[Any], fail_open: bool = False, direction: str = INPUT):
super().__init__(guardrails, fail_open=fail_open)
self.direction = direction
def validate_tool_call(self, tool_name: str, arguments: Dict[str, Any], **kwargs):
return self._run_direction(INPUT, tool_name, arguments, **kwargs)
def validate_tool_result(self, tool_name: str, result: Any, **kwargs):
return self._run_direction(OUTPUT, tool_name, result, **kwargs)
def _run_direction(self, direction: str, tool_name: str, value: Any, **kwargs):
method_name = _METHOD_FOR[direction]
default_reason = _DEFAULT_REASON[direction]
for guardrail in self.guardrails:
method = getattr(guardrail, method_name, None)
if method is None:
continue
try:
ok, processed = method(tool_name, value, **kwargs)
except Exception as e: # noqa: BLE001
name = getattr(guardrail, "name", type(guardrail).__name__)
if self.fail_open:
logger.warning(
"Tool guardrail '%s' raised on '%s' and fail_open is set; "
"allowing through: %s", name, tool_name, e
)
continue
# Fail closed: a broken guardrail must block, not permit.
return False, f"guardrail '{name}' failed: {e}"
if not ok:
reason = processed if isinstance(processed, str) and processed else default_reason
return False, reason
value = processed
return True, value
def _coerce_one(item: Any, direction: str):
"""Turn one user-supplied entry into a protocol-conforming guardrail."""
method_name = _METHOD_FOR[direction]
if callable(getattr(item, method_name, None)):
# Already speaks the protocol (a GuardrailProtocol object, a
# GuardrailChain, another ToolGuardrailChain) - reuse it verbatim.
return item
if callable(item):
adapter = ToolInputGuardrail if direction == INPUT else ToolOutputGuardrail
return adapter(item)
raise TypeError(
f"{ATTR_FOR[direction]} entries must be callable or expose "
f"{method_name}(); got {type(item).__name__}."
)
def build_tool_guardrails(spec: Any, direction: str) -> Optional[ToolGuardrailChain]:
"""Coerce an ``input_guardrails=`` / ``output_guardrails=`` value into a chain.
Accepts a single guardrail or a sequence of them; each entry may be a plain
callable or any object already exposing the matching protocol method.
Returns ``None`` when nothing was declared, which is what keeps the
executor's fast path free of any per-call work for unguarded tools.
"""
if spec is None:
return None
if isinstance(spec, ToolGuardrailChain):
return spec
if isinstance(spec, GuardrailChain):
# Reuse the chain's contents and its fail_open posture; only the
# reason-reporting behaviour differs.
return ToolGuardrailChain(list(spec.guardrails), fail_open=spec.fail_open,
direction=direction)
if isinstance(spec, (str, bytes)) or not isinstance(spec, (list, tuple, set)):
spec = [spec]
items = [_coerce_one(item, direction) for item in spec]
if not items:
return None
return ToolGuardrailChain(items, direction=direction)
def get_tool_guardrail_chain(tool_obj: Any, direction: str) -> Optional[ToolGuardrailChain]:
"""Return the guardrail chain declared on ``tool_obj``, or ``None``.
``@tool(...)`` coerces at definition time, so this is usually a single
attribute read. A ``BaseTool`` subclass may instead declare a raw list as a
class attribute; that is coerced on first use and memoised on the instance.
A malformed declaration is logged and treated as "no guardrail" rather than
breaking every call to the tool - the declaration error surfaces at import
time for the ``@tool`` path, which is where it belongs.
"""
if tool_obj is None:
return None
declared = getattr(tool_obj, ATTR_FOR[direction], None)
if declared is None:
return None
if isinstance(declared, ToolGuardrailChain):
return declared
cache = getattr(tool_obj, _CACHE_ATTR, None)
if isinstance(cache, dict) and direction in cache:
return cache[direction]
try:
chain = build_tool_guardrails(declared, direction)
except TypeError as e:
logger.warning(
"Ignoring malformed %s on tool '%s': %s",
ATTR_FOR[direction], getattr(tool_obj, "name", tool_obj), e
)
chain = None
try:
if not isinstance(cache, dict):
cache = {}
setattr(tool_obj, _CACHE_ATTR, cache)
cache[direction] = chain
except Exception: # noqa: BLE001 - e.g. a slotted or immutable tool object
pass
return chain
@@ -46,7 +46,6 @@ class InvocationContext:
tool_name: Optional[str] = None
model_name: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
cancel_event: Optional[Any] = None
@dataclass
@@ -53,11 +53,6 @@ class HookRegistry:
}
self._enabled = enabled
self._global_timeout = 60.0
# Guard mutation/iteration of the per-event hook lists. The default
# registry is a cross-agent process-wide singleton, so a plugin
# (un)registering a hook on one thread can race a get_hooks() iteration
# on another. RLock keeps re-entrant calls (register -> register_*) safe.
self._lock = threading.RLock()
@property
def enabled(self) -> bool:
@@ -79,8 +74,7 @@ class HookRegistry:
Returns:
The hook ID
"""
with self._lock:
self._hooks[hook.event].append(hook)
self._hooks[hook.event].append(hook)
logger.debug(f"Registered hook '{hook.name}' for event '{hook.event.value}'")
return hook.id
@@ -220,13 +214,12 @@ class HookRegistry:
Returns:
True if found and removed, False otherwise
"""
with self._lock:
for event in HookEvent:
for i, hook in enumerate(self._hooks[event]):
if hook.id == hook_id:
self._hooks[event].pop(i)
logger.debug(f"Unregistered hook '{hook_id}'")
return True
for event in HookEvent:
for i, hook in enumerate(self._hooks[event]):
if hook.id == hook_id:
self._hooks[event].pop(i)
logger.debug(f"Unregistered hook '{hook_id}'")
return True
return False
def get_hooks(
@@ -247,11 +240,7 @@ class HookRegistry:
if not self._enabled:
return []
# Snapshot under the lock so callers never iterate the live, mutable
# list (a concurrent unregister()/clear() could otherwise raise
# "list changed size during iteration" or skip/duplicate a hook).
with self._lock:
hooks = list(self._hooks.get(event, []))
hooks = self._hooks.get(event, [])
if target is None:
return [h for h in hooks if h.enabled]
@@ -269,12 +258,11 @@ class HookRegistry:
Args:
event: Optional event to clear hooks for
"""
with self._lock:
if event is None:
for e in HookEvent:
self._hooks[e] = []
else:
self._hooks[event] = []
if event is None:
for e in HookEvent:
self._hooks[e] = []
else:
self._hooks[event] = []
def list_hooks(self) -> Dict[str, List[Dict]]:
"""
@@ -302,22 +290,20 @@ class HookRegistry:
def enable_hook(self, hook_id: str) -> bool:
"""Enable a specific hook."""
with self._lock:
for event in HookEvent:
for hook in self._hooks[event]:
if hook.id == hook_id:
hook.enabled = True
return True
for event in HookEvent:
for hook in self._hooks[event]:
if hook.id == hook_id:
hook.enabled = True
return True
return False
def disable_hook(self, hook_id: str) -> bool:
"""Disable a specific hook."""
with self._lock:
for event in HookEvent:
for hook in self._hooks[event]:
if hook.id == hook_id:
hook.enabled = False
return True
for event in HookEvent:
for hook in self._hooks[event]:
if hook.id == hook_id:
hook.enabled = False
return True
return False
def set_global_timeout(self, timeout: float):
@@ -354,18 +354,13 @@ class HookRunner:
"""Execute a function hook."""
if hook.func is None:
duration = (time.time() - start_time) * 1000
# Fail closed: a hook with no callable cannot produce a verdict, so a
# gating event (BEFORE_TOOL) must block rather than silently allow.
# Without an ``output`` here ``is_blocked`` returned False and the tool
# ran — the same fail-open the exception/timeout paths already fix.
return HookExecutionResult(
hook_id=hook.id,
hook_name=hook.name or "unknown",
event=event,
success=False,
error="Hook function is None",
duration_ms=duration,
output=HookResult(decision="deny", reason=f"Hook '{hook.name}' has no callable")
duration_ms=duration
)
timeout = hook.timeout or self._default_timeout
@@ -455,11 +450,6 @@ class HookRunner:
exit_code: int
) -> HookResult:
"""Parse command output into HookResult."""
# Whether the process exited with a code we understand. Any other code
# (127 command-not-found, 126 not-executable, etc.) means the hook could
# not render a trustworthy verdict, so JSON stdout must not override it.
expected_exit = exit_code in (EXIT_CODE_SUCCESS, EXIT_CODE_BLOCKING_ERROR)
# Try to parse JSON from stdout
if stdout.strip():
try:
@@ -469,22 +459,8 @@ class HookRunner:
if isinstance(data, str):
data = json.loads(data)
decision = data.get("decision", "allow")
# Fail closed: valid JSON with an ``allow`` decision must not
# bypass an unexpected exit code. A gating hook (BEFORE_TOOL)
# that prints allow-JSON but then crashes (e.g. exit 127) has
# not actually approved the call, so deny instead of allowing.
if decision == "allow" and not expected_exit:
return HookResult(
decision="deny",
reason=(
f"Hook exited with unexpected code {exit_code} "
f"after emitting an allow decision: "
f"{stderr.strip() or 'no stderr'}"
)
)
return HookResult(
decision=decision,
decision=data.get("decision", "allow"),
reason=data.get("reason"),
modified_input=data.get("modified_input"),
additional_context=data.get("additional_context"),
@@ -505,19 +481,9 @@ class HookRunner:
reason=stderr.strip() or stdout.strip() or "Blocked by hook"
)
else:
# Fail closed: any *unexpected* exit code (e.g. 127 command-not-found,
# 126 not-executable, or any other non-zero, non-blocking code) means
# the hook could not render a verdict. Treating that as ``allow`` would
# silently bypass a gating hook (BEFORE_TOOL). Deny so the gate matches
# the fail-closed posture of the exception/timeout paths and
# GuardrailChain. Emitting a real HookResult (decision + reason) also
# keeps ``get_blocking_reason`` informative for the audit trail.
return HookResult(
decision="deny",
reason=(
f"Hook exited with unexpected code {exit_code}: "
f"{stderr.strip() or stdout.strip() or 'no output'}"
)
decision="allow",
additional_context=f"Warning: {stderr.strip() or stdout.strip()}"
)
@staticmethod
@@ -135,11 +135,6 @@ class ChromaKnowledgeAdapter:
config = kwargs.get("config", {})
vector_config = config.get("vector_store", {}).get("config", {})
# The configured embedder. Reading only OPENAI_EMBEDDING_MODEL here meant
# an agent that had explicitly selected a local embedder still sent every
# document and query to OpenAI -- silently, and with a 200 back.
self._embedder = config.get("embedder") or {}
collection_name = vector_config.get("collection_name", "praisonai_knowledge")
persist_dir = vector_config.get("path") or _default_chroma_path()
os.makedirs(persist_dir, exist_ok=True)
@@ -174,34 +169,6 @@ class ChromaKnowledgeAdapter:
metadata={"hnsw:space": "cosine"}
)
def _embedding_call(self):
"""Resolve (model, kwargs) for the configured embedder.
Precedence: the embedder block the agent configured > the
OPENAI_EMBEDDING_MODEL env var > OpenAI's default. Returns kwargs
carrying api_base when the embedder points at a local server, so the
request cannot escape to a remote provider.
"""
block = self._embedder or {}
provider = (block.get("provider") or "").strip().lower()
cfg = block.get("config") or {}
model = cfg.get("model")
if not model:
return os.environ.get("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"), {}
extra = {}
base = (cfg.get("ollama_base_url") or cfg.get("openai_base_url")
or cfg.get("api_base") or cfg.get("base_url"))
if provider == "ollama":
if not model.startswith("ollama/"):
model = f"ollama/{model}"
if base:
extra["api_base"] = base.rstrip("/")
elif base:
extra["api_base"] = base.rstrip("/")
extra.setdefault("api_key", cfg.get("api_key") or "local")
return model, extra
def search(self, query: str, *, user_id: Optional[str] = None, agent_id: Optional[str] = None,
run_id: Optional[str] = None, limit: int = 10, filters: Optional[Dict[str, Any]] = None,
**kwargs: Any):
@@ -209,10 +176,10 @@ class ChromaKnowledgeAdapter:
from ..models import SearchResult, SearchResultItem
# Get embedding for query
embedding_model, embedding_kwargs = self._embedding_call()
embedding_model = os.environ.get("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
try:
from praisonaiagents.embedding import embedding
result = embedding(query, model=embedding_model, **embedding_kwargs)
result = embedding(query, model=embedding_model)
query_embedding = result.embeddings[0] if result.embeddings else None
except Exception as e:
logger.warning(
@@ -290,11 +257,11 @@ class ChromaKnowledgeAdapter:
content_str = str(content)
# Get embedding
embedding_model, embedding_kwargs = self._embedding_call()
embedding_model = os.environ.get("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
embedding_error = None
try:
from praisonaiagents.embedding import embedding
result = embedding(content_str, model=embedding_model, **embedding_kwargs)
result = embedding(content_str, model=embedding_model)
content_embedding = result.embeddings[0] if result.embeddings else None
except Exception as e:
logger.warning(
@@ -356,19 +356,11 @@ class Knowledge:
except Exception:
pass # Silent fail - tracing should never break knowledge operations
def store(self, content, user_id=None, agent_id=None, run_id=None, metadata=None, is_content=False):
"""Store a memory.
Args:
is_content: When True, ``content`` is treated as literal text and the
file-path heuristic is skipped. Callers that have already read a
file's contents (e.g. chunks from ``_process_single_input``) must
set this so a chunk ending in ``.txt``/``.pdf``/``.doc``/``.docx``
is not mistaken for a path and re-dispatched to ``add()``.
"""
def store(self, content, user_id=None, agent_id=None, run_id=None, metadata=None):
"""Store a memory."""
try:
if isinstance(content, str):
if not is_content and any(content.lower().endswith(ext) for ext in ['.pdf', '.doc', '.docx', '.txt']):
if any(content.lower().endswith(ext) for ext in ['.pdf', '.doc', '.docx', '.txt']):
self._log(f"Content appears to be a file path, processing file: {content}")
return self.add(content, user_id=user_id, agent_id=agent_id, run_id=run_id, metadata=metadata)
@@ -494,15 +486,10 @@ class Knowledge:
"""
if isinstance(file_path, (list, tuple)):
results = []
errors = []
for path in file_path:
result = self._process_single_input(path, user_id, agent_id, run_id, metadata)
results.extend(result.get('results', []))
# Per-path failures (e.g. a directory whose files all failed to
# embed) must survive aggregation; otherwise a list input hides
# them behind the old success-shaped response.
errors.extend(result.get('errors', []))
return {'results': results, 'relations': [], 'errors': errors}
return {'results': results, 'relations': []}
return self._process_single_input(file_path, user_id, agent_id, run_id, metadata)
@@ -535,12 +522,6 @@ class Knowledge:
if os.path.isdir(input_path):
self._log(f"Processing directory: {input_path}")
all_results = []
# Per-file failures were logged at WARNING and dropped, so a
# directory whose every file failed to embed still returned a
# success-shaped {'results': [], 'relations': []} and the caller
# believed the knowledge base was populated. Collect them and
# hand them back.
all_errors = []
# Walk through directory and process all supported files
for root, dirs, files in os.walk(input_path):
@@ -555,7 +536,6 @@ class Knowledge:
all_results.extend(result.get('results', []))
except Exception as e:
logger.warning(f"Failed to process file {file_path}: {e}")
all_errors.append({'file': file_path, 'error': str(e)})
except (KeyboardInterrupt, SystemExit, GeneratorExit):
# Interpreter control-flow signals must propagate
# untouched so a Ctrl+C / interpreter shutdown is
@@ -576,18 +556,9 @@ class Knowledge:
) from e
if not all_results:
if all_errors:
# Nothing was indexed and we know why. Saying "no
# supported files found" here would send the user
# looking for the wrong problem entirely.
logger.error(
"Indexed nothing from %s: all %d file(s) failed; first error: %s",
input_path, len(all_errors), all_errors[0]['error'],
)
else:
logger.warning(f"No supported files found in directory: {input_path}")
logger.warning(f"No supported files found in directory: {input_path}")
return {'results': all_results, 'relations': [], 'errors': all_errors}
return {'results': all_results, 'relations': []}
# Check if input ends with any supported extension
is_supported_file = any(input_path.lower().endswith(ext)
@@ -608,17 +579,7 @@ class Knowledge:
content = file.read().strip()
if not content:
raise ValueError("Empty text file")
# Chunk like every other branch. This one used to store the
# whole file as a single memory and lowercase it, so
# chunk_size/chunk_overlap were inert on .md/.txt/.csv/.json
# /.xml/.html -- the commonest sources -- while .pdf and an
# unlisted extension both chunked correctly. A 20 KB guide
# became one 20 KB memory that no retrieval could rank, and
# the case of every identifier and proper noun was lost on
# the way to the model.
chunks = self.chunker.chunk(content)
memories = [chunk.text.strip() if hasattr(chunk, 'text') else str(chunk).strip()
for chunk in chunks if chunk] or [content]
memories = [self.normalize_content(content)]
else:
# Use MarkItDown for documents and media
result = self.markdown.convert(input_path)
@@ -634,35 +595,8 @@ class Knowledge:
metadata = {}
metadata['file_type'] = file_ext.lstrip('.')
metadata['filename'] = os.path.basename(input_path)
elif os.path.isfile(input_path):
# An existing file whose extension is not in the table above --
# every source file: .py .js .ts .go .rs .java .yaml .toml .sh
# .ipynb .rst. This used to fall through to the raw-text branch
# below, which stored the PATH STRING as if it were content and
# reported success, so indexing a codebase produced a knowledge
# base of lowercased filenames. Read and chunk it like any other
# text instead; a file that is not decodable as UTF-8 is skipped
# loudly rather than stored as its own name.
self._log(f"Processing as text file (unlisted extension): {input_path}")
try:
with open(input_path, 'r', encoding='utf-8') as file:
content = file.read().strip()
except (UnicodeDecodeError, OSError) as read_error:
raise ValueError(
f"Cannot read {input_path} as UTF-8 text: {read_error}"
) from read_error
if not content:
raise ValueError("Empty text file")
chunks = self.chunker.chunk(content)
memories = [chunk.text.strip() if hasattr(chunk, 'text') else str(chunk).strip()
for chunk in chunks if chunk] or [content]
if not metadata:
metadata = {}
suffix = os.path.splitext(input_path)[1]
metadata['file_type'] = suffix.lstrip('.') if suffix else ''
metadata['filename'] = os.path.basename(input_path)
else:
# Genuinely raw text handed in instead of a path.
# Treat as raw text content only if no file extension
memories = [self.normalize_content(input_path)]
# Create progress display
@@ -677,20 +611,12 @@ class Knowledge:
# Store memories with progress bar
all_results = []
attempted = 0
failed_chunks = 0
with progress:
store_task = progress.add_task(f"Adding to Knowledge from {os.path.basename(input_path)}", total=len(memories))
for memory in memories:
if memory:
attempted += 1
# memories here are already-read file contents or literal
# text the caller handed in -- never a path to re-open, so
# bypass store()'s file-path heuristic (a chunk ending in
# ".txt"/".pdf"/".doc"/".docx" would otherwise be mistaken
# for a filename and silently dropped).
memory_result = self.store(memory, user_id=user_id, agent_id=agent_id,
run_id=run_id, metadata=metadata, is_content=True)
run_id=run_id, metadata=metadata)
if memory_result:
# Handle both dict and list formats for backward compatibility
if isinstance(memory_result, dict):
@@ -712,25 +638,6 @@ class Knowledge:
import logging
logging.warning(f"Unexpected memory_result type: {type(memory_result)}, skipping")
progress.advance(store_task)
else:
# store() catches embedding/persistence exceptions and
# returns a falsy empty list rather than raising, so a
# falsy result is the one reliable signal that this
# chunk did not land -- distinct from a truthy result
# that simply carries no 'results' ids (e.g. mem0's
# {'ok': True} or a backend that echoes a bare id).
failed_chunks += 1
# If we tried to store real content and every chunk was swallowed,
# the file failed; raise so the directory walk records it in
# ``errors`` and a single-file caller sees the failure instead of a
# false success. This is the exact silent drop this change exists to
# prevent (a wrong model, revoked key, or exhausted quota).
if attempted and failed_chunks == attempted:
raise RuntimeError(
f"Stored nothing from {input_path}: all {attempted} chunk(s) "
f"failed (embedding/vector-store backend). See earlier logs."
)
# Emit trace event for knowledge add
self._emit_knowledge_event("add", source=input_path, chunk_count=len(memories),
@@ -91,7 +91,7 @@ class LiteAgent:
import openai
client = openai.OpenAI()
response = client.chat.completions.create(
model=default_auxiliary_model(),
model="gpt-4o-mini",
messages=messages
)
return response.choices[0].message.content
@@ -235,7 +235,7 @@ def create_openai_llm_fn(
LLM function compatible with LiteAgent
Example:
llm_fn = create_openai_llm_fn(model=default_auxiliary_model())
llm_fn = create_openai_llm_fn(model="gpt-4o-mini")
agent = LiteAgent(llm_fn=llm_fn)
"""
def llm_fn(messages: List[Dict]) -> str:
@@ -312,7 +312,6 @@ def create_anthropic_llm_fn(
from ..tools.tools import Tools
from ..tools.base import BaseTool, ToolResult
from ..tools.decorator import tool
from ..llm.model_providers import default_auxiliary_model
__all__ = [
@@ -219,10 +219,6 @@ def __getattr__(name):
from .error_classifier import classify_error
_lazy_cache[name] = classify_error
return classify_error
elif name == "resolve_provider":
from .model_providers import resolve_provider
_lazy_cache[name] = resolve_provider
return resolve_provider
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -282,7 +278,5 @@ __all__ = [
"sanitize_text",
# Error Classification (G5)
"ErrorCategory",
"classify_error",
# Model-name → provider-id resolution
"resolve_provider",
"classify_error"
]
@@ -9,48 +9,11 @@ and integrates with Gap 2 (parallel tool execution).
"""
from ..protocols import LLMProviderAdapterProtocol
import json
from ..model_capabilities import GEMINI_INTERNAL_TOOLS
from ..streaming_protocol import StreamingCapableAdapter, get_streaming_adapter
from typing import Dict, Any, List, Optional
def _recover_json_tool_calls(response_text: str, tools: List[Dict[str, Any]]) -> Optional[List[Dict[str, Any]]]:
"""Recover a tool call a local model emitted as JSON text.
Shared by every locally-served engine: small models routinely answer with
the call as content instead of using the tool_calls field. Deliberately NOT
on DefaultAdapter -- a hosted model returning JSON prose must never have it
parsed as a tool call.
"""
if not response_text or not tools:
return None
try:
import json
response_json = json.loads(response_text.strip())
# Normalize to list so both single and multi-tool payloads are supported
if isinstance(response_json, dict):
response_json = [response_json]
if isinstance(response_json, list):
tool_calls: List[Dict[str, Any]] = []
for idx, tool_json in enumerate(response_json):
if isinstance(tool_json, dict) and "name" in tool_json:
tool_calls.append({
"id": f"call_{tool_json['name']}_{idx}_{hash(response_text) % 10000}",
"type": "function",
"function": {
"name": tool_json["name"],
"arguments": json.dumps(tool_json.get("arguments", {}))
}
})
return tool_calls if tool_calls else None
except (json.JSONDecodeError, TypeError, KeyError):
pass
return None
class DefaultAdapter:
"""Default provider adapter with sensible fallbacks."""
@@ -60,8 +23,14 @@ class DefaultAdapter:
def should_summarize_tools(self, iter_count: int) -> bool:
return iter_count >= 5 # Conservative default
def format_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
return tools # No special formatting by default
def post_tool_iteration(self, state: Dict[str, Any]) -> None:
pass # No post-processing by default
def supports_structured_output(self) -> bool:
return False # Conservative default
def supports_streaming(self) -> bool:
return True # Most providers support streaming
@@ -69,35 +38,26 @@ class DefaultAdapter:
def supports_streaming_with_tools(self) -> bool:
return True # Most providers support streaming with tools
def get_streaming_adapter(self) -> StreamingCapableAdapter:
"""Get the streaming adapter for this provider."""
# Default providers use the default streaming adapter
return get_streaming_adapter("default")
def get_max_iteration_threshold(self) -> int:
return 10 # Conservative default
def format_tool_result_message(self, function_name: str, tool_result: Any, tool_call_id: Optional[str] = None) -> Dict[str, Any]:
"""Standard OpenAI-shaped tool result message.
This is the union of five inline copies that had drifted apart in llm.py:
it uses the fuller error sentence, reports a list-of-errors result as an
error rather than dumping it as data, and guards json.dumps so a tool
returning a set or a datetime does not crash the turn.
"""
if tool_result is None:
content = "Function returned an empty output"
elif isinstance(tool_result, dict) and 'error' in tool_result:
content = (f"Error: {tool_result.get('error', 'Unknown error')}. "
"Please inform the user that the operation could not be completed.")
elif (isinstance(tool_result, list) and tool_result
and isinstance(tool_result[0], dict) and 'error' in tool_result[0]):
content = (f"Error: {tool_result[0].get('error', 'Unknown error')}. "
"Please inform the user that the operation could not be completed.")
else:
try:
content = json.dumps(tool_result)
except (TypeError, ValueError):
content = str(tool_result)
return {
# Standard OpenAI-style tool result message
message = {
"role": "tool",
"tool_call_id": tool_call_id if tool_call_id is not None else f"call_{function_name}",
"content": content,
"content": str(tool_result),
}
if tool_call_id is not None:
message["tool_call_id"] = tool_call_id
else:
# Fallback for backward compatibility
message["tool_call_id"] = f"call_{function_name}"
return message
def handle_empty_response_with_tools(self, state: Dict[str, Any]) -> bool:
return False # No special handling by default
@@ -105,12 +65,24 @@ class DefaultAdapter:
def get_default_settings(self) -> Dict[str, Any]:
return {} # No provider-specific defaults
def parse_tool_calls(self, raw_response: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
"""Default tool call parsing - use OpenAI-style format."""
if "choices" in raw_response and len(raw_response["choices"]) > 0:
message = raw_response["choices"][0].get("message", {})
return message.get("tool_calls")
return None
def should_skip_streaming_with_tools(self) -> bool:
return False # Most providers support streaming with tools
def recover_tool_calls_from_text(self, response_text: str, tools: List[Dict[str, Any]]) -> Optional[List[Dict[str, Any]]]:
return None # No text recovery by default
def inject_cache_control(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
return messages # No cache control by default
def extract_reasoning_tokens(self, response: Dict[str, Any]) -> int:
return 0 # No reasoning tokens by default
class OllamaAdapter(DefaultAdapter):
@@ -133,7 +105,12 @@ class OllamaAdapter(DefaultAdapter):
# Ollama doesn't reliably support streaming with tools
return False
def get_streaming_adapter(self) -> StreamingCapableAdapter:
"""Get Ollama-specific streaming adapter."""
return get_streaming_adapter("ollama")
def get_max_iteration_threshold(self) -> int:
return 1 # Ollama-specific threshold
def format_tool_result_message(self, function_name: str, tool_result: Any, tool_call_id: Optional[str] = None) -> Dict[str, Any]:
# Ollama uses natural language format for tool results.
@@ -182,8 +159,42 @@ Now provide your final answer using this result. Summarize the information natur
def recover_tool_calls_from_text(self, response_text: str, tools: List[Dict[str, Any]]) -> Optional[List[Dict[str, Any]]]:
"""Ollama-specific tool call recovery from response text."""
return _recover_json_tool_calls(response_text, tools)
if not response_text or not tools:
return None
try:
import json
response_json = json.loads(response_text.strip())
# Normalize to list so both single and multi-tool payloads are supported
if isinstance(response_json, dict):
response_json = [response_json]
if isinstance(response_json, list):
tool_calls: List[Dict[str, Any]] = []
for idx, tool_json in enumerate(response_json):
if isinstance(tool_json, dict) and "name" in tool_json:
tool_calls.append({
"id": f"call_{tool_json['name']}_{idx}_{hash(response_text) % 10000}",
"type": "function",
"function": {
"name": tool_json["name"],
"arguments": json.dumps(tool_json.get("arguments", {}))
}
})
return tool_calls if tool_calls else None
except (json.JSONDecodeError, TypeError, KeyError):
pass
return None
def post_tool_iteration(self, state: Dict[str, Any]) -> None:
# Replaces: Ollama-specific post-tool summary branches
if (not state.get('response_text', '').strip() and
state.get('formatted_tools') and
state.get('iteration_count') == 0):
# Add Ollama-specific summary logic here
state['needs_summary'] = True
def get_default_settings(self) -> Dict[str, Any]:
return {
@@ -192,41 +203,14 @@ Now provide your final answer using this result. Summarize the information natur
}
class LocalOpenAIAdapter(DefaultAdapter):
"""Adapter for local servers that speak real OpenAI over HTTP.
Covers LM Studio, vLLM and llama.cpp's ``llama-server``. These differ from
Ollama in the way that matters most here: they implement the standard tool
protocol correctly, so tool results stay ``role: "tool"`` and streaming with
tools works. Inheriting ``DefaultAdapter``'s message handling is therefore
deliberate -- applying Ollama's natural-language ``role: "user"`` rewrite
would corrupt a conversation these servers handle properly.
What they share with Ollama is the model: locally-served weights are often
small and emit a malformed tool call now and then. So the one thing this
adapter adds is a repair budget.
``force_tool_usage`` is deliberately NOT set. It injects prompts into every
conversation, and vLLM commonly serves large, highly capable models where
that is unwanted noise. ``max_tool_repairs`` costs nothing unless a tool
call actually arrives malformed.
"""
def get_default_settings(self) -> Dict[str, Any]:
return {'max_tool_repairs': 2}
def recover_tool_calls_from_text(self, response_text: str, tools: List[Dict[str, Any]]) -> Optional[List[Dict[str, Any]]]:
# Same reason as Ollama: these servers front small local models that
# answer with the tool call as text, especially after a repair prompt.
return _recover_json_tool_calls(response_text, tools)
class AnthropicAdapter(DefaultAdapter):
"""Anthropic/Claude provider adapter."""
def supports_prompt_caching(self) -> bool:
return True # Claude supports prompt caching
def supports_structured_output(self) -> bool:
return True
def supports_streaming(self) -> bool:
# litellm.acompletion with stream=True returns a ModelResponse (not async generator)
@@ -236,6 +220,9 @@ class AnthropicAdapter(DefaultAdapter):
def supports_streaming_with_tools(self) -> bool:
return False
def get_streaming_adapter(self) -> StreamingCapableAdapter:
"""Get Anthropic-specific streaming adapter."""
return get_streaming_adapter("anthropic")
class GeminiAdapter(DefaultAdapter):
@@ -248,13 +235,35 @@ class GeminiAdapter(DefaultAdapter):
- Supports structured output
"""
def should_skip_streaming_with_tools(self) -> bool:
"""Gemini should skip streaming when tools are present."""
return True
def supports_structured_output(self) -> bool:
return True
def format_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
# Replaces: gemini_internal_tools handling in llm.py
# Internal tool names match GEMINI_INTERNAL_TOOLS: {'googleSearch', 'urlContext', 'codeExecution'}
formatted = []
for tool in tools:
if tool.get('name') in GEMINI_INTERNAL_TOOLS:
# Convert to Gemini internal tool format
formatted.append({
'type': 'function',
'function': tool
})
else:
formatted.append(tool)
return formatted
def supports_streaming_with_tools(self) -> bool:
# Gemini has issues with streaming + tools
return False
def get_streaming_adapter(self) -> StreamingCapableAdapter:
"""Get Gemini-specific streaming adapter."""
return get_streaming_adapter("gemini")
# Provider adapter registry - public for extension
@@ -264,7 +273,6 @@ _provider_adapters: Dict[str, LLMProviderAdapterProtocol] = {}
_default_adapter = DefaultAdapter()
_provider_adapters['default'] = _default_adapter
_provider_adapters['ollama'] = OllamaAdapter()
_provider_adapters['local'] = LocalOpenAIAdapter()
_provider_adapters['anthropic'] = AnthropicAdapter()
_provider_adapters['claude'] = AnthropicAdapter() # Alias
_provider_adapters['gemini'] = GeminiAdapter()
@@ -302,8 +310,6 @@ def get_provider_adapter(name: str) -> LLMProviderAdapterProtocol:
# Provider prefixes or substrings
if "ollama" in name_lower:
return _provider_adapters["ollama"]
if name_lower in {"local", "lm_studio", "lmstudio", "vllm", "hosted_vllm", "llamacpp", "llama_cpp"}:
return _provider_adapters["local"]
if "claude" in name_lower or "anthropic" in name_lower:
return _provider_adapters["anthropic"]
if "gemini" in name_lower:
@@ -312,12 +318,23 @@ def get_provider_adapter(name: str) -> LLMProviderAdapterProtocol:
return _provider_adapters["default"]
def list_provider_adapters() -> List[str]:
"""List all registered provider adapter names."""
return sorted(_provider_adapters.keys())
def has_provider_adapter(name: str) -> bool:
"""Check if a provider adapter is registered."""
return name in _provider_adapters
__all__ = [
'DefaultAdapter',
'OllamaAdapter',
'LocalOpenAIAdapter',
'AnthropicAdapter',
'GeminiAdapter',
'get_provider_adapter',
'add_provider_adapter',
'list_provider_adapters',
'has_provider_adapter',
]
+150 -525
View File
@@ -23,7 +23,6 @@ import time
import json
import xml.etree.ElementTree as ET
from ..errors import AgentErrorKind, FailoverDecision, IdleTimeoutBreaker, ToolExecutionError
from ..model_harness.guard import check_model_request
# Gap 2: Tool call execution imports
from ..tools.call_executor import ToolCall, create_tool_call_executor
from ..tools.schema import build_tool_definition
@@ -251,14 +250,6 @@ class LLM:
"results already gathered."
)
STEP_LIMIT_FALLBACK_MESSAGE = "Reached the step limit before finishing this task."
# Stalled-tool-loop finalisation: used when a provider re-emits an
# identical tool call with no prose, so the loop cannot make progress.
TOOL_LOOP_STALL_PROMPT = (
"The tools have already been called and their results are above. "
"Do not call any tools. Answer the original question now using "
"those results."
)
# Force tool usage prompt template (used when model ignores tools)
FORCE_TOOL_USAGE_PROMPT = """You MUST use one of the available tools to answer this question.
@@ -495,12 +486,6 @@ Respond with ONLY a valid JSON tool call in this format:
self._last_stop_reason = "completed"
self._idle_timeout_breaker = IdleTimeoutBreaker() # Circuit breaker for idle timeouts
self.chat_history = []
# Optional Agent-supplied thread-safe append for deferred re-injection.
# When an Agent owns this LLM it wires its own history append here so a
# background-resolved defer(...) result lands on the list follow-up
# turns actually replay (the LLM's own chat_history has no readers in
# that case). Left None for standalone LLM usage.
self._agent_history_append = None
self.verbose = extra_settings.get('verbose', True)
self.markdown = extra_settings.get('markdown', True)
self.self_reflect = extra_settings.get('self_reflect', False)
@@ -645,18 +630,9 @@ Respond with ONLY a valid JSON tool call in this format:
# Parse route prefix for explicit provider routing
provider_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else None
# Explicit provider prefixes take priority.
# "ollama_chat" is litellm's recommended prefix for Ollama tool calling
# and must not be treated as a different provider than "ollama".
if provider_prefix in {"ollama", "ollama_chat"}:
# Explicit provider prefixes take priority
if provider_prefix == "ollama":
return "ollama"
# Local servers that speak real OpenAI over HTTP. They keep the standard
# tool-message shape (unlike Ollama) but serve locally-hosted weights
# that benefit from a tool-call repair budget. "huggingface" is
# deliberately absent: that prefix is the hosted Inference API.
if provider_prefix in {"lm_studio", "lmstudio", "vllm", "hosted_vllm",
"llamacpp", "llama_cpp"}:
return "local"
if provider_prefix in {"anthropic", "claude"}:
return "anthropic"
if provider_prefix in {"gemini", "google"} and "gemini" in model_lower:
@@ -673,12 +649,11 @@ Respond with ONLY a valid JSON tool call in this format:
if model_lower.startswith("gemini") or model_lower.startswith("google/gemini"):
return "gemini"
# NOTE: the base_url hint that used to live here checked exactly the
# same three URLs for exactly the same two patterns as
# _is_ollama_provider() above, so it was an unguarded duplicate -- and
# it re-captured hosted models that _is_ollama_provider() had just
# correctly declined. Detection now happens once, in one place.
# Check base_url for provider hints
base_urls = [self.base_url, os.getenv("OPENAI_BASE_URL", ""), os.getenv("OPENAI_API_BASE", "")]
if any(url and ("ollama" in url.lower() or ":11434" in url) for url in base_urls):
return "ollama"
# Substring fallback for routed models whose provider is not the first
# path segment (e.g. bedrock/anthropic.claude-*, vertex_ai/claude-*,
# openrouter/anthropic/*, vertex_ai/gemini-*). Mirrors the substring
@@ -722,25 +697,10 @@ Respond with ONLY a valid JSON tool call in this format:
if not self.model:
return False
# An explicit Ollama route prefix -- the user speaking, always wins.
# "ollama_chat/" is litellm's recommended prefix for Ollama tool calling
# and resolves to provider "ollama" in _detect_provider, so omitting it
# here made this predicate disagree with the adapter that gets selected.
if self.model.startswith(("ollama/", "ollama_chat/")):
# Direct ollama/ prefix
if self.model.startswith("ollama/"):
return True
# A base_url says WHERE the server is, not WHAT it serves. Only let it
# imply Ollama for a model that could plausibly be running locally.
# Without this, the README's own local-model recipe
# (OPENAI_BASE_URL=http://localhost:11434/v1) gave gpt-4o and
# claude-* the Ollama adapter: tool results downgraded to
# natural-language user turns, streaming-with-tools disabled, and
# forced-tool prompts injected into a conversation that never needed
# them.
from .model_providers import is_hosted_only_model
if is_hosted_only_model(self.model):
return False
# Check base_url if provided
if self.base_url and "ollama" in self.base_url.lower():
return True
@@ -756,17 +716,6 @@ Respond with ONLY a valid JSON tool call in this format:
return False
def _is_local_openai_provider(self) -> bool:
"""Detect local OpenAI-compatible servers (LM Studio, vLLM, llama.cpp).
These speak the standard OpenAI protocol, so well-formed tool calls
arrive as ``message.tool_calls``. But after a tool-call *repair* prompt
the model is asked to reply with a bare JSON tool call as text content;
like Ollama, that text must be reconstructed into a dispatchable tool
call rather than returned as the final answer.
"""
return self._detect_provider() == "local"
def _is_qwen_provider(self) -> bool:
"""Detect if this is a Qwen provider"""
if not self.model:
@@ -831,20 +780,13 @@ Respond with ONLY a valid JSON tool call in this format:
error_str = str(error).lower()
error_type = type(error).__name__.lower()
# Check for common rate limit indicators. This helper has no production
# callers (the runtime retry path uses classify_error_kind() /
# resolve_failover_decision()); it exists only for tests and is kept in
# step with error_classifier.classify_error() via
# test_rate_limit_backward_compat. The two silently diverged when that
# classifier learned 'quota exceeded' (Gemini's phrasing for a
# per-minute limit) and this list did not.
# Check for common rate limit indicators
indicators = [
'429',
'rate limit',
'ratelimit',
'too many request',
'resource_exhausted',
'quota exceeded',
'tokens per minute',
]
@@ -1511,14 +1453,7 @@ Respond with ONLY a valid JSON tool call in this format:
Returns:
The completion response from litellm
Raises:
ModelRequestBlocked: If a test suite turned real model requests off
via ``praisonaiagents.model_harness.allow_model_requests(False)``.
"""
# Last gate before the network. A ScriptedModel overrides this method
# entirely, so scripted turns never reach (or trip) the guard.
check_model_request(self.model, "litellm.completion")
import litellm
response = self._call_with_retry(litellm.completion, **completion_params)
if not completion_params.get("stream"):
@@ -1536,12 +1471,7 @@ Respond with ONLY a valid JSON tool call in this format:
Returns:
The completion response from litellm
Raises:
ModelRequestBlocked: If a test suite turned real model requests off
via ``praisonaiagents.model_harness.allow_model_requests(False)``.
"""
check_model_request(self.model, "litellm.acompletion")
import litellm
response = await self._call_with_retry_async(
litellm.acompletion,
@@ -2090,22 +2020,14 @@ Respond with ONLY a valid JSON tool call in this format:
def _reinject(handle_id: str, value: Any, session_id: Optional[str]) -> None:
# Best-effort: this closure is invoked from a background worker
# thread. When driven by an Agent, follow-up turns replay the
# Agent's own history (passed as a parameter into
# _build_messages), not this LLM's ``chat_history`` — so we
# append via the Agent's thread-safe callback when one has been
# wired, and fall back to the LLM's own list only for standalone
# LLM usage.
message = {
# thread; appending to the LLM's persistent ``chat_history``
# (the same list follow-up turns replay via _build_messages)
# makes the resolved value available on the next turn.
self.chat_history.append({
"role": "tool",
"tool_call_id": tool_call_id,
"content": f"[deferred:{function_name}] {value}",
}
history_append = getattr(self, "_agent_history_append", None)
if history_append is not None:
history_append(message)
else:
self.chat_history.append(message)
})
# Atomic: only register when not already pending, so a gateway that
# registered its own resolver for this handle first is never
@@ -2115,8 +2037,7 @@ Respond with ONLY a valid JSON tool call in this format:
logging.debug("Deferred registration skipped (non-fatal): %s", e)
def _finalise_on_limit(self, messages: List[Dict], response_text: str,
temperature: float = 0.2,
wrap_up_prompt: Optional[str] = None, **kwargs) -> str:
temperature: float = 0.2, **kwargs) -> str:
"""
Force a final tools-disabled answer when the tool-calling loop hits max_iter.
@@ -2136,8 +2057,7 @@ Respond with ONLY a valid JSON tool call in this format:
Returns:
The synthesised final answer, or a safe fallback string.
"""
wrap_up = {"role": "user",
"content": wrap_up_prompt or self.STEP_LIMIT_WRAP_UP_PROMPT}
wrap_up = {"role": "user", "content": self.STEP_LIMIT_WRAP_UP_PROMPT}
try:
safe_kwargs = {k: v for k, v in kwargs.items() if k != 'reasoning_steps'}
final_params = self._build_completion_params(
@@ -2830,28 +2750,13 @@ Respond with ONLY a valid JSON tool call in this format:
)
if stream:
response_text, tool_calls, stream_response = self._stream_responses_api(
response_text, tool_calls = self._stream_responses_api(
responses_params,
verbose=verbose,
stream_callback=stream_callback,
emit_events=emit_events,
current_time=current_time,
)
# Streamed calls still incur real token cost; track
# usage from the final response.completed event so
# the public collector isn't silently zeroed.
if stream_response is not None:
self._track_token_usage(stream_response, self.model)
if self.metrics and getattr(stream_response, 'usage', None):
usage = stream_response.usage
tokens_in = getattr(usage, 'input_tokens', 0) or 0
tokens_out = getattr(usage, 'output_tokens', 0) or 0
llm_latency_ms = (time.time() - current_time) * 1000
_get_display_functions()['execute_sync_callback'](
'llm_end', model=self.model,
tokens_in=tokens_in, tokens_out=tokens_out,
cost=None, latency_ms=llm_latency_ms,
)
else:
resp = self._call_responses_api(**responses_params)
response_text, tool_calls, _reasoning = self._extract_from_responses_output(resp)
@@ -3020,9 +2925,19 @@ Respond with ONLY a valid JSON tool call in this format:
)
# Append tool result as standard tool message
messages.append(self._create_tool_message(
function_name, tool_result,
tool_result_obj.tool_call_id, is_ollama=False))
if tool_result is None:
content = "Function returned an empty output"
elif isinstance(tool_result, dict) and 'error' in tool_result:
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user."
elif isinstance(tool_result, list) and tool_result and isinstance(tool_result[0], dict) and 'error' in tool_result[0]:
content = f"Error: {tool_result[0].get('error', 'Unknown error')}. Please inform the user."
else:
content = json.dumps(tool_result)
messages.append({
"role": "tool",
"tool_call_id": tool_result_obj.tool_call_id,
"content": content,
})
# Report any unparseable tool-call arguments so the
# model can re-emit them (never dispatched with {}).
@@ -3181,7 +3096,12 @@ Respond with ONLY a valid JSON tool call in this format:
if formatted_tools and not self._supports_streaming_tools():
# Provider doesn't support streaming with tools, use non-streaming
use_streaming = False
# Gemini has issues with streaming + tools, disable streaming for Gemini when tools are present
if use_streaming and formatted_tools and self._is_gemini_model():
logging.debug("Disabling streaming for Gemini model with tools due to JSON parsing issues")
use_streaming = False
# Track whether fallback was successful to avoid duplicate API calls
fallback_completed = False
@@ -3524,16 +3444,39 @@ Respond with ONLY a valid JSON tool call in this format:
tool_calls = final_response["choices"][0]["message"].get("tool_calls")
# Recover a tool call the provider emitted as response text
# instead of in the tool_calls field. Which providers do this
# is the adapter's business, not this loop's; DefaultAdapter
# returns None so no other provider is affected.
if not tool_calls and response_text and formatted_tools:
recovered = self._provider_adapter.recover_tool_calls_from_text(
response_text, formatted_tools)
if recovered:
tool_calls = recovered
logging.debug(f"Recovered tool calls from response text: {tool_calls}")
# For Ollama, parse tool calls from response text if not in tool_calls field
if self._is_ollama_provider() and not tool_calls and response_text and formatted_tools:
# Try to parse JSON tool call from response text
try:
response_json = json.loads(response_text.strip())
if isinstance(response_json, dict) and "name" in response_json:
# Convert Ollama format to standard tool_calls format
tool_calls = [{
"id": f"tool_{iteration_count}",
"type": "function",
"function": {
"name": response_json["name"],
"arguments": json.dumps(response_json.get("arguments", {}))
}
}]
logging.debug(f"Parsed Ollama tool call from response: {tool_calls}")
elif isinstance(response_json, list):
# Handle multiple tool calls
tool_calls = []
for idx, tool_json in enumerate(response_json):
if isinstance(tool_json, dict) and "name" in tool_json:
tool_calls.append({
"id": f"tool_{iteration_count}_{idx}",
"type": "function",
"function": {
"name": tool_json["name"],
"arguments": json.dumps(tool_json.get("arguments", {}))
}
})
if tool_calls:
logging.debug(f"Parsed multiple Ollama tool calls from response: {tool_calls}")
except (json.JSONDecodeError, KeyError) as e:
logging.debug(f"Could not parse Ollama tool call from response: {e}")
# Parse tool calls from XML format in response text
# Try for known XML models first, or fallback for any model that might output XML
@@ -3805,8 +3748,20 @@ Respond with ONLY a valid JSON tool call in this format:
pass
else:
# For other providers, use tool role with tool_call_id
messages.append(self._create_tool_message(
function_name, tool_result, tool_call_id, is_ollama=False))
# Format error results more clearly
if tool_result is None:
content = "Function returned an empty output"
elif isinstance(tool_result, dict) and 'error' in tool_result:
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user that the operation could not be completed."
elif isinstance(tool_result, list) and len(tool_result) > 0 and isinstance(tool_result[0], dict) and 'error' in tool_result[0]:
content = f"Error: {tool_result[0].get('error', 'Unknown error')}. Please inform the user that the operation could not be completed."
else:
content = json.dumps(tool_result)
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"content": content
})
# Check if we should continue (for tools like sequential thinking)
# This mimics the logic from agent.py lines 1004-1007
@@ -3865,11 +3820,8 @@ Respond with ONLY a valid JSON tool call in this format:
# Special early stopping logic for Ollama when tool results are available
# Ollama often provides empty responses after successful tool execution
if self._provider_adapter.handle_empty_response_with_tools({
'iteration_count': iteration_count,
'accumulated_tool_results': accumulated_tool_results,
'response_text': response_text or '',
}):
if (self._is_ollama_provider() and accumulated_tool_results and iteration_count >= 1 and
(not response_text or response_text.strip() == "")):
# Generate coherent response from tool results
tool_summary = self._generate_ollama_tool_summary(accumulated_tool_results, response_text)
if tool_summary:
@@ -4458,15 +4410,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._tool_parse_error_message(function_name, tool_call_id)
)
continue
# Validate and filter arguments for Ollama provider.
# Pre-dispatch, so this is streaming-safe: nothing has
# been yielded that would need retracting. Weak local
# models routinely emit arguments belonging to a
# different function, and dispatching those calls the
# user's tool with parameters it never declared.
if is_ollama and tools:
arguments = self._validate_and_filter_ollama_arguments(
function_name, arguments, tools)
tool_calls_batch.append(ToolCall(
function_name=function_name,
arguments=arguments,
@@ -4480,46 +4423,15 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Execute batch and add results to conversation
# (forward optional per-tool timeout)
# ``time`` is re-imported locally further down this
# function, which makes the module-level name unbound
# here; use a distinct alias.
from time import perf_counter as _perf_counter
_batch_started = _perf_counter()
tool_results = executor.execute_batch(
tool_calls_batch, execute_tool_fn,
timeout_ms=self.tool_timeout_ms,
)
_batch_elapsed = _perf_counter() - _batch_started
for tool_result in tool_results:
# Register any deferred handle so its eventual
# background result is re-injected, not lost.
self._register_deferred_if_any(tool_result)
# Fire the 'tool_call' display callback exactly as
# the non-streaming loop does, so streaming UIs
# see tool activity (Issue #4716). Best-effort: a
# callback error must never break the stream.
try:
if tool_result.result is None:
_result_str = None
else:
try:
_result_str = json.dumps(tool_result.result)
except (TypeError, ValueError):
_result_str = str(tool_result.result)
_get_display_functions()['execute_sync_callback'](
'tool_call',
message=f"Calling function: {tool_result.function_name}",
tool_name=tool_result.function_name,
tool_input=tool_result.arguments,
tool_output=_result_str[:200] if _result_str else None,
# Only the batch is timed; per-tool timing
# is not available from the executor.
elapsed_time=_batch_elapsed if len(tool_results) == 1 else None,
success=tool_result.error is None,
)
except Exception as _cb_error:
logging.debug(f"tool_call callback failed for '{tool_result.function_name}': {_cb_error}")
if tool_result.error is None:
# Successful execution
tool_message = self._create_tool_message(
@@ -4587,170 +4499,26 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
use_streaming = False
if not use_streaming:
# Fall back to non-streaming. This branch is taken whenever the
# provider cannot stream WITH tools -- Anthropic, Gemini and
# Ollama all report False from _supports_streaming_tools() --
# and also whenever the streaming attempt above failed.
#
# It used to read message.content and nothing else. The request
# carried `tools`, so the model answered with tool_calls and an
# empty content, and every one of those calls was dropped: the
# tool never ran, nothing was yielded, and no warning was
# raised. A turn that needed a tool produced an empty stream.
#
# Run the tool loop here too, bounded by max_iter the same way
# the non-streaming path is.
# Fall back to non-streaming and yield the complete response
try:
is_ollama = self._is_ollama_provider()
fallback_iterations = 0
tool_call_count = 0
last_tool_call_fingerprint = None
stall_reason = None
max_fallback_iterations = kwargs.pop("max_iterations", self.max_iter)
while fallback_iterations < max_fallback_iterations:
fallback_iterations += 1
response = self._completion_with_retry(
**self._build_completion_params(
messages=messages,
tools=formatted_tools,
temperature=temperature,
stream=False,
output_json=output_json,
output_pydantic=output_pydantic,
**kwargs
)
response = self._completion_with_retry(
**self._build_completion_params(
messages=messages,
tools=formatted_tools,
temperature=temperature,
stream=False,
output_json=output_json,
output_pydantic=output_pydantic,
**kwargs
)
if not (response and response.choices):
break
message = response.choices[0].message
content = getattr(message, "content", None)
tool_calls = getattr(message, "tool_calls", None)
if not tool_calls or not execute_tool_fn:
# Nothing to dispatch: emit whatever prose we have.
if content:
yield content
elif tool_call_count:
# Tools ran, but the provider ended the turn
# without writing an answer.
stall_reason = "empty final answer"
break
# A provider that re-emits the identical tool call with
# no prose is not making progress: those results are
# already in `messages`, so running the tool again
# cannot add information. Stop before dispatching.
fingerprint = self._tool_call_fingerprint(tool_calls, is_ollama)
if (not content and tool_call_count
and fingerprint == last_tool_call_fingerprint):
logging.warning(
"Provider repeated the same tool call with no answer at "
f"fallback iteration {fallback_iterations}; finalising "
"with tools disabled.")
stall_reason = "repeated tool call"
break
last_tool_call_fingerprint = fingerprint
# Record the assistant turn before the tool replies, so
# the follow-up request is a valid conversation.
if is_ollama:
messages.append({"role": "assistant", "content": content or ""})
else:
messages.append({
"role": "assistant",
"content": content or "",
"tool_calls": self._serialize_tool_calls(tool_calls),
})
# Any prose the model emitted alongside the calls is
# still part of the answer.
)
if response and response.choices:
content = response.choices[0].message.content
if content:
# Yield the complete response as a single chunk
yield content
# Enforce the same per-turn guardrail the streaming and
# non-streaming loops apply, so the fallback cannot run
# tools past the caller's configured limit.
if tool_call_count >= max_tool_calls_per_turn:
logging.warning(
f"Tool call limit reached ({max_tool_calls_per_turn}). "
"Stopping to prevent infinite loop.")
stall_reason = "tool call limit"
break
if tool_call_count + len(tool_calls) > max_tool_calls_per_turn:
remaining_calls = max_tool_calls_per_turn - tool_call_count
tool_calls = tool_calls[:remaining_calls]
logging.warning(
f"Limiting batch to {remaining_calls} tool calls to stay "
f"within limit of {max_tool_calls_per_turn}.")
for tool_call in tool_calls:
tool_call_count += 1
function_name, arguments, tool_call_id = (
self._extract_tool_call_info(tool_call, is_ollama))
if self._tool_arguments_parse_failed(arguments):
messages.append(
self._tool_parse_error_message(function_name, tool_call_id))
continue
# Validate and filter arguments for Ollama provider.
# Pre-dispatch, so this is streaming-safe: nothing has
# been yielded that would need retracting. Weak local
# models routinely emit arguments belonging to a
# different function, and dispatching those calls the
# user's tool with parameters it never declared.
if is_ollama and tools:
arguments = self._validate_and_filter_ollama_arguments(
function_name, arguments, tools)
try:
tool_result = execute_tool_fn(function_name, arguments)
except Exception as tool_error: # noqa: BLE001
# Report the failure to the model rather than
# aborting the run, matching the streaming loop.
logging.warning(
f"Tool '{function_name}' failed: {tool_error}")
tool_result = {"error": str(tool_error)}
try:
_get_display_functions()['execute_sync_callback'](
'tool_call',
message=f"Calling function: {function_name}",
tool_name=function_name,
tool_input=arguments,
tool_output=str(tool_result)[:200],
success=not (isinstance(tool_result, dict)
and 'error' in tool_result),
)
except Exception as cb_error: # noqa: BLE001
logging.debug(
f"tool_call callback failed for '{function_name}': {cb_error}")
messages.append(self._create_tool_message(
function_name, tool_result, tool_call_id, is_ollama))
# Loop: ask again now the results are in the messages.
else:
logging.warning(
f"Iteration limit reached ({max_fallback_iterations}) on the "
"non-streaming fallback.")
stall_reason = "iteration limit"
# The loop ended without the provider ever writing an answer,
# but every tool result is in `messages`. Ask once more with
# tools disabled: the provider cannot emit another tool call,
# so it has to answer. This is the same bounded finalisation
# get_response and get_response_async already perform -- the
# stream path was the only one that skipped it.
if stall_reason and tool_call_count:
final_text = self._finalise_on_limit(
messages, "", temperature=temperature,
wrap_up_prompt=self.TOOL_LOOP_STALL_PROMPT,
**kwargs)
if (final_text and final_text.strip()
and final_text != self.STEP_LIMIT_FALLBACK_MESSAGE):
yield final_text
else:
raise LLMResponseError(
f"Provider produced no answer after {tool_call_count} "
f"tool call(s) on the streaming path ({stall_reason}).")
except Exception as e:
logging.error(f"Non-streaming fallback failed: {e}")
raise
@@ -4812,7 +4580,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
execute_tool_fn: Optional[Callable] = None,
max_tool_calls_per_turn: int = 10, # Loop guardrails
stream: bool = True,
parallel_tool_calls: bool = False,
**kwargs
) -> str:
"""Async version of get_response with identical functionality."""
@@ -4839,36 +4606,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
except Exception as _steer_err:
logging.debug(f"Steering drain failed (ignored): {_steer_err}")
async def _dispatch_tool_batch(dispatch_specs, iteration_index):
"""Run a batch of (function_name, arguments, tool_call_id) dispatches.
When ``parallel_tool_calls`` is set, the independent tool coroutines
are awaited concurrently via ``asyncio.gather`` (mirroring the sync
path's ``create_tool_call_executor(parallel=True)``); otherwise they
run one-at-a-time. Results are always returned in the original call
order so the existing message-building loop stays unchanged.
"""
if parallel_tool_calls and len(dispatch_specs) > 1:
# return_exceptions=True so a single failing tool does not cancel
# its still-in-flight siblings and orphan their coroutines (the
# exact leak class this PR fixes). We then re-raise the first
# error after every sibling has settled, preserving the
# sequential path's fail-fast propagation semantics.
gathered = await asyncio.gather(*(
_dispatch_async_tool(execute_tool_fn, fn, args, tc_id, iteration_index)
for (fn, args, tc_id) in dispatch_specs
), return_exceptions=True)
for item in gathered:
if isinstance(item, BaseException):
raise item
return gathered
results = []
for (fn, args, tc_id) in dispatch_specs:
results.append(await _dispatch_async_tool(
execute_tool_fn, fn, args, tc_id, iteration_index
))
return results
try:
import litellm
logging.debug(f"Getting async response from {self.model}")
@@ -4982,15 +4719,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
current_time = time.time()
if stream:
response_text, tool_calls, stream_response = await self._stream_responses_api_async(
response_text, tool_calls = await self._stream_responses_api_async(
responses_params,
stream_callback=kwargs.get('stream_callback'),
emit_events=kwargs.get('emit_events', False),
)
# Streamed calls still incur real token cost; track
# usage from the final response.completed event.
if stream_response is not None:
self._track_token_usage(stream_response, self.model)
else:
resp = await self._call_responses_api_async(**responses_params)
response_text, tool_calls, _reasoning = self._extract_from_responses_output(resp)
@@ -5056,10 +4789,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
tool_calls = tool_calls[:remaining_calls]
logging.warning(f"Limiting batch to {remaining_calls} tool calls to stay within limit of {max_tool_calls_per_turn}.")
# Pre-resolve each tool call, then dispatch the valid ones
# as a batch (concurrently when parallel_tool_calls is set).
_parsed_calls = []
_dispatch_specs = []
for tool_call in tool_calls:
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
if self._tool_arguments_parse_failed(arguments):
@@ -5067,10 +4796,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
continue
logging.debug(f"[RESPONSES_API_ASYNC] Executing tool {function_name}")
_parsed_calls.append((function_name, arguments, tool_call_id))
_dispatch_specs.append((function_name, arguments, tool_call_id))
_batch_results = await _dispatch_tool_batch(_dispatch_specs, iteration_count)
for (function_name, arguments, tool_call_id), tool_result in zip(_parsed_calls, _batch_results):
tool_result = await _dispatch_async_tool(
execute_tool_fn,
function_name,
arguments,
tool_call_id,
iteration_count,
)
tool_call_count += 1 # Increment tool call counter for guardrails
accumulated_tool_results.append(tool_result)
@@ -5080,8 +4812,17 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
_get_display_functions()['display_tool_call'](display_message, console=self.console)
# Append tool result
messages.append(self._create_tool_message(
function_name, tool_result, tool_call_id, is_ollama=False))
if tool_result is None:
content = "Function returned an empty output"
elif isinstance(tool_result, dict) and 'error' in tool_result:
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user."
else:
content = json.dumps(tool_result)
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"content": content,
})
if iteration_count + 1 >= max_iterations:
self._last_stop_reason = "max_steps"
@@ -5300,11 +5041,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# flushed only AFTER every tool reply for this assistant
# turn, keeping all tool replies consecutive (provider contract).
_deferred_media_followups: List[Dict[str, Any]] = []
# First pass: parse/validate every call and record a plan so
# the valid dispatches can be batched (concurrently when
# parallel_tool_calls is set) while preserving call order.
_call_plan = [] # ('error', msg) | ('call', fn, args, tc_id)
_dispatch_specs = []
for tool_call in tool_calls:
# Handle both object and dict access patterns
is_ollama = self._is_ollama_provider()
@@ -5312,24 +5048,20 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if self._tool_arguments_parse_failed(arguments):
# Do NOT dispatch with {}: surface a retryable error.
_call_plan.append(('error', self._tool_parse_error_message(function_name, tool_call_id)))
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
continue
# Validate and filter arguments for Ollama provider
if is_ollama and tools:
arguments = self._validate_and_filter_ollama_arguments(function_name, arguments, tools)
_call_plan.append(('call', function_name, arguments, tool_call_id))
_dispatch_specs.append((function_name, arguments, tool_call_id))
_batch_results = await _dispatch_tool_batch(_dispatch_specs, iteration_count)
_result_iter = iter(_batch_results)
for _plan in _call_plan:
if _plan[0] == 'error':
messages.append(_plan[1])
continue
_, function_name, arguments, tool_call_id = _plan
tool_result = next(_result_iter)
tool_result = await _dispatch_async_tool(
execute_tool_fn,
function_name,
arguments,
tool_call_id,
iteration_count,
)
tool_call_count += 1 # Increment tool call counter for guardrails
tool_results.append(tool_result) # Store the result
accumulated_tool_results.append(tool_result) # Accumulate across iterations
@@ -5354,8 +5086,20 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
pass
else:
# For other providers, use tool role with tool_call_id
messages.append(self._create_tool_message(
function_name, tool_result, tool_call_id, is_ollama=False))
# Format error results more clearly
if tool_result is None:
content = "Function returned an empty output"
elif isinstance(tool_result, dict) and 'error' in tool_result:
content = f"Error: {tool_result.get('error', 'Unknown error')}. Please inform the user that the operation could not be completed."
elif isinstance(tool_result, list) and len(tool_result) > 0 and isinstance(tool_result[0], dict) and 'error' in tool_result[0]:
content = f"Error: {tool_result[0].get('error', 'Unknown error')}. Please inform the user that the operation could not be completed."
else:
content = json.dumps(tool_result)
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"content": content
})
# Flush deferred media follow-ups after all tool replies
# for this turn have been appended (provider contract).
@@ -5532,11 +5276,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Special early stopping logic for Ollama when tool results are available
# Ollama often provides empty responses after successful tool execution
if self._provider_adapter.handle_empty_response_with_tools({
'iteration_count': iteration_count,
'accumulated_tool_results': accumulated_tool_results,
'response_text': response_text or '',
}):
if (self._is_ollama_provider() and accumulated_tool_results and iteration_count >= 1 and
(not response_text or response_text.strip() == "")):
# Generate coherent response from tool results
tool_summary = self._generate_ollama_tool_summary(accumulated_tool_results, response_text)
if tool_summary:
@@ -6005,49 +5746,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
"""Set the current agent name for token tracking."""
self.current_agent_name = agent_name
def _resolve_openai_compatible_model(self) -> str:
"""Route a bare model name through the OpenAI-compatible client.
When a custom ``base_url`` is set but the model has no ``provider/``
prefix (e.g. ``deepseek-v4-flash`` against an OpenAI-compatible host),
LiteLLM cannot infer a provider and raises "LLM Provider NOT provided".
Prefixing ``openai/`` makes LiteLLM use its OpenAI Chat Completions
client against the supplied ``base_url``. Anthropic/Gemini/Ollama models
are left untouched so their native adapters still apply.
The effective base_url is either the instance ``base_url`` or one
injected by a subscription auth provider (e.g. qwen-cli/codex resolve a
Chat-Completions host at request time). Both paths need routing so a
bare model under subscription auth does not reach LiteLLM unprefixed.
"""
model = self.model
if not isinstance(model, str):
return model
# llama.cpp's llama-server speaks OpenAI, but litellm knows no
# "llama_cpp/" provider -- it raised "LLM Provider NOT provided" and
# then retried four times before returning None. We accept the prefix
# because it names a real local engine, so we must also make it
# routable: strip it and let the OpenAI client handle the base_url.
for _local_prefix in ("llama_cpp/", "llamacpp/"):
if model.startswith(_local_prefix):
return f"openai/{model[len(_local_prefix):]}"
if "/" in model:
return model
effective_base_url = self.base_url
if not effective_base_url:
creds = self._resolve_subscription_creds()
if creds and getattr(creds, "base_url", None):
effective_base_url = creds.base_url
if not effective_base_url:
return model
if self._detect_provider() != "openai":
return model
return f"openai/{model}"
def _build_completion_params(self, **override_params) -> Dict[str, Any]:
"""Build parameters for litellm completion calls with all necessary config"""
params = {
"model": self._resolve_openai_compatible_model(),
"model": self.model,
}
# Add optional parameters if they exist
@@ -6359,7 +6061,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
- tools → ``tools`` (Responses API uses same function schema)
- temperature, max_tokens, etc. → direct pass-through
"""
params: Dict[str, Any] = {"model": self._resolve_openai_compatible_model()}
params: Dict[str, Any] = {"model": self.model}
# ── Extract system instructions ─────────────────────────────────
instructions = None
@@ -6510,12 +6212,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
"""
Call ``litellm.responses()`` synchronously with retry support.
Returns a ``ResponsesAPIResponse`` object.
Raises:
ModelRequestBlocked: If a test suite turned real model requests off
via ``praisonaiagents.model_harness.allow_model_requests(False)``.
"""
check_model_request(self.model, "litellm.responses")
import litellm
return self._call_with_retry(litellm.responses, **params)
@@ -6523,12 +6220,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
"""
Call ``litellm.aresponses()`` asynchronously with retry support.
Returns a ``ResponsesAPIResponse`` object.
Raises:
ModelRequestBlocked: If a test suite turned real model requests off
via ``praisonaiagents.model_harness.allow_model_requests(False)``.
"""
check_model_request(self.model, "litellm.aresponses")
import litellm
response = await self._call_with_retry_async(litellm.aresponses, **params)
self._track_token_usage(response, self._response_model_for_tracking(response))
@@ -6620,12 +6312,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
Returns the same (response_text, tool_calls) tuple as the Chat
Completions streaming path. Emits StreamEvents when configured.
Raises:
ModelRequestBlocked: If a test suite turned real model requests off
via ``praisonaiagents.model_harness.allow_model_requests(False)``.
"""
check_model_request(self.model, "litellm.responses")
import litellm
params["stream"] = True
@@ -6633,7 +6320,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# tool_calls accumulator: {output_index: {"id":..., "name":..., "arguments":...}}
_pending_tools: Dict[int, Dict[str, Any]] = {}
_emit = emit_events and stream_callback is not None
_final_response = None
if _emit:
from ..streaming.events import StreamEvent, StreamEventType
@@ -6645,11 +6331,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
else:
evt_type = getattr(event, "type", "")
# ── Completed — capture the final response for usage accounting ──
if evt_type == "response.completed":
_final_response = (event.get("response") if isinstance(event, dict)
else getattr(event, "response", None))
# ── Text delta ──────────────────────────────────────────
if evt_type == "response.output_text.delta":
delta_text = (event.get("delta", "") if isinstance(event, dict)
@@ -6741,7 +6422,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
},
})
return response_text, tool_calls if tool_calls else None, _final_response
return response_text, tool_calls if tool_calls else None
async def _stream_responses_api_async(
self,
@@ -6752,19 +6433,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
"""
Async streaming for Responses API.
Returns (response_text, tool_calls).
Raises:
ModelRequestBlocked: If a test suite turned real model requests off
via ``praisonaiagents.model_harness.allow_model_requests(False)``.
"""
check_model_request(self.model, "litellm.aresponses")
import litellm
params["stream"] = True
response_text = ""
_pending_tools: Dict[int, Dict[str, Any]] = {}
_emit = emit_events and stream_callback is not None
_final_response = None
if _emit:
from ..streaming.events import StreamEvent, StreamEventType
@@ -6775,10 +6450,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
else:
evt_type = getattr(event, "type", "")
if evt_type == "response.completed":
_final_response = (event.get("response") if isinstance(event, dict)
else getattr(event, "response", None))
if evt_type == "response.output_text.delta":
delta_text = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
@@ -6853,7 +6524,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
"function": {"name": tc["name"], "arguments": tc["arguments"]},
})
return response_text, tool_calls if tool_calls else None, _final_response
return response_text, tool_calls if tool_calls else None
def _process_streaming_chunk(self, chunk) -> Optional[str]:
"""Extract content from a streaming chunk"""
@@ -6881,33 +6552,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
tool_calls[tc.index]["function"]["arguments"] += tc.function.arguments
return tool_calls
def _create_tool_message(self, function_name: str, result: Any,
tool_call_id: Optional[str], is_ollama: bool = False) -> Dict[str, Any]:
"""Build the message that carries a tool's result back to the model.
get_response_stream called this from its streaming tool loop and it was
never defined, so the first tool result raised AttributeError. The
broad `except Exception` around that loop swallowed it, set
use_streaming = False, and dropped through to the non-streaming branch
-- which discarded the tool calls too. The net effect was that tool
calling appeared to do nothing on every streaming turn, with the real
cause visible only as a logged warning.
Two unit tests exercised this loop by assigning `_create_tool_message`
onto the instance themselves, so they passed against a method
production never had.
Mirrors the formatting the non-streaming loop uses, including Ollama's
natural-language variant.
"""
if is_ollama:
return self._format_ollama_tool_result_message(function_name, result)
adapter = getattr(self, '_provider_adapter', None)
if adapter is None:
from .adapters import DefaultAdapter
adapter = DefaultAdapter()
return adapter.format_tool_result_message(function_name, result, tool_call_id)
def _serialize_tool_calls(self, tool_calls) -> List[Dict]:
"""Convert tool calls to a serializable format for all providers."""
serializable_tool_calls = []
@@ -6926,25 +6570,6 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
})
return serializable_tool_calls
def _tool_call_fingerprint(self, tool_calls, is_ollama: bool = False) -> tuple:
"""Stable identity for a batch of tool calls, ignoring provider call ids.
Two consecutive batches with the same fingerprint and no prose between
them mean the provider is repeating itself, not making progress. Call
ids are excluded deliberately: Ollama mints a fresh one every turn, so
an id-sensitive comparison would never match.
"""
fingerprint = []
for tool_call in tool_calls:
function_name, arguments, _ = self._extract_tool_call_info(
tool_call, is_ollama)
try:
rendered = json.dumps(arguments, sort_keys=True, default=str)
except (TypeError, ValueError):
rendered = str(arguments)
fingerprint.append((function_name, rendered))
return tuple(fingerprint)
def _extract_tool_call_info(self, tool_call, is_ollama: bool = False) -> tuple:
"""Extract function name, arguments, and tool_call_id from a tool call.
@@ -102,50 +102,6 @@ def _fallback_supports_prompt_caching(model_name: str) -> bool:
return any(p in name for p in ("claude-3", "claude-sonnet", "claude-opus", "claude-haiku", "gpt-4o", "gpt-4.1", "gpt-5", "deepseek"))
def _fallback_supports_vision(model_name: str) -> bool:
"""Static heuristic used only when litellm is unavailable."""
name = _base_model_name(model_name)
return any(
p in name
for p in (
"gpt-4o", "gpt-4.1", "gpt-4-turbo", "gpt-4-vision", "gpt-5",
"o1", "o3", "o4", "claude-3", "claude-sonnet", "claude-opus",
"claude-haiku", "gemini", "llava", "pixtral", "grok-2", "grok-4",
"qwen-vl", "qwen2-vl", "internvl", "vision",
)
)
def _fallback_supports_pdf_input(model_name: str) -> bool:
"""Static heuristic used only when litellm is unavailable.
Narrower than vision: a model may read images and still reject a raw PDF
document part. Kept to the families that accept PDFs natively today
(Anthropic Claude 3.5+/4, OpenAI gpt-4o/4.1/5 file inputs, Gemini).
"""
name = _base_model_name(model_name)
return any(
p in name
for p in (
"claude-3-5", "claude-3-7", "claude-sonnet", "claude-opus",
"claude-haiku", "gpt-4o", "gpt-4.1", "gpt-5", "gemini",
)
)
def _fallback_supports_audio_input(model_name: str) -> bool:
"""Static heuristic used only when litellm is unavailable.
Very narrow on purpose: most chat models reject an ``input_audio`` part
outright, so guessing ``True`` would trade a silent drop for a hard API
error. Only the explicitly audio-capable families are reported.
"""
name = _base_model_name(model_name)
if "audio" in name: # gpt-4o-audio-preview, gpt-audio, ...
return True
return any(p in name for p in ("gemini-1.5", "gemini-2", "gemini-3", "qwen-audio"))
@lru_cache(maxsize=256)
def supports_structured_outputs(model_name: str) -> bool:
"""
@@ -345,89 +301,6 @@ def supports_prompt_caching(model_name: str) -> bool:
return _fallback_supports_prompt_caching(model_name)
@lru_cache(maxsize=256)
def supports_vision(model_name: str) -> bool:
"""Check if a model can accept image parts.
Uses LiteLLM's ``supports_vision()`` as the primary check, falling back to
a static heuristic when litellm is not installed.
"""
if not model_name:
return False
litellm = None
try:
litellm = _get_litellm()
if litellm is None:
return _fallback_supports_vision(model_name)
if hasattr(litellm, "supports_vision"):
return litellm.supports_vision(model=model_name)
except Exception:
pass
if litellm is not None:
return False
return _fallback_supports_vision(model_name)
@lru_cache(maxsize=256)
def supports_pdf_input(model_name: str) -> bool:
"""Check if a model can accept a PDF document part natively.
Uses LiteLLM's ``supports_pdf_input()`` (top level or ``litellm.utils``)
as the primary check, falling back to a static heuristic when litellm is
not installed. Callers that get ``False`` must degrade *visibly* (e.g.
extract the PDF's text) rather than dropping the attachment.
"""
if not model_name:
return False
litellm = None
try:
litellm = _get_litellm()
if litellm is None:
return _fallback_supports_pdf_input(model_name)
fn = getattr(litellm, "supports_pdf_input", None)
if fn is None and hasattr(litellm, "utils"):
fn = getattr(litellm.utils, "supports_pdf_input", None)
if fn is not None:
return fn(model=model_name)
except Exception:
pass
if litellm is not None:
return False
return _fallback_supports_pdf_input(model_name)
@lru_cache(maxsize=256)
def supports_audio_input(model_name: str) -> bool:
"""Check if a model can accept an ``input_audio`` part.
Uses LiteLLM's ``supports_audio_input()`` as the primary check, falling
back to a static heuristic when litellm is not installed.
"""
if not model_name:
return False
litellm = None
try:
litellm = _get_litellm()
if litellm is None:
return _fallback_supports_audio_input(model_name)
fn = getattr(litellm, "supports_audio_input", None)
if fn is None and hasattr(litellm, "utils"):
fn = getattr(litellm.utils, "supports_audio_input", None)
if fn is not None:
return fn(model=model_name)
except Exception:
pass
if litellm is not None:
return False
return _fallback_supports_audio_input(model_name)
# Models that support web fetch via LiteLLM (Anthropic only)
# Web fetch retrieves full content from specific URLs (web pages and PDFs)
# Source: https://docs.litellm.ai/docs/completion/web_fetch
@@ -1,165 +0,0 @@
"""Model-name → provider-id resolution.
Maps a bare model name (e.g. ``claude-sonnet-4-6``, ``llama-3.1-70b``) to its
canonical provider id (``anthropic``, ``groq``, ...). This is the single source
of truth for provider inference so callers no longer hand-code a 3-branch
``if/elif`` that silently ignores every other vendor.
Third parties add a vendor by publishing an entry point under the
``praisonaiagents.model_providers`` group. Each entry point loads a callable
``(model_name_lowercased: str) -> bool``; the entry-point *name* is the provider
id returned on a match. Registered matchers take precedence over the built-ins,
so a plugin can also override built-in detection.
"""
from __future__ import annotations
from typing import Callable, Dict, Optional
# Built-in model-name → provider matchers. Kept intentionally small and data
# driven; extend via the entry-point group rather than editing callers.
_BUILTINS: Dict[str, Callable[[str], bool]] = {
# ``claude`` is matched as a substring so cloud-vendored forms
# (``us.anthropic.claude-3-5-sonnet``, ``anthropic.claude-...``) still
# resolve, preserving the pre-refactor behaviour that keyed off
# ``'claude' in model``.
"anthropic": lambda m: "claude" in m,
"openai": lambda m: m.startswith(("gpt-", "o1-", "o3-", "o4-", "chatgpt-")),
"google": lambda m: m.startswith(("gemini-", "gemma-")),
"groq": lambda m: m.startswith(("llama-", "mixtral-", "gemma2-")) and "groq" in m,
"cohere": lambda m: m.startswith(("command-", "c4ai-")),
"mistral": lambda m: m.startswith(("mistral-", "codestral-", "open-mistral-", "open-mixtral-")),
"ollama": lambda m: m.startswith("ollama/"),
"deepseek": lambda m: m.startswith("deepseek-"),
"xai": lambda m: m.startswith("grok-"),
"perplexity": lambda m: m.startswith(("pplx-", "sonar-")),
}
# Closed-weights model families that no local runtime can serve. Used to stop a
# local ``base_url`` (an OpenAI-compatible proxy on :11434, say) from being read
# as "this is an Ollama model".
#
# Open-weights families are deliberately ABSENT -- gemma, llama, mistral, qwen,
# deepseek and phi are all routinely served by Ollama, so they must stay
# eligible for URL-based local detection. That is why this is a separate, much
# narrower predicate than ``resolve_provider``: the latter maps ``gemma-`` to
# "google" and ``mistral-`` to "mistral", which is correct for provider routing
# and wrong for deciding whether a model could be running locally.
HOSTED_ONLY_PREFIXES = ("gpt-", "o1-", "o3-", "o4-", "chatgpt-", "claude", "gemini-")
def is_hosted_only_model(model_name: str) -> bool:
"""True if ``model_name`` names a closed-weights model no local server hosts.
Every route segment is tested, so single-prefix (``openai/gpt-4o``), nested
(``openrouter/openai/gpt-4o``, ``openrouter/anthropic/claude-*``) and
vendor-qualified (``bedrock/anthropic.claude-*``, ``us.anthropic.claude-*``)
forms all resolve to the hosted family they name -- mirroring the substring
fallback that ``_detect_provider`` uses for the same routed models. An
open-weights model reached over the OpenAI-compatible route
(``openai/qwen3:0.6b``) is not hosted-only and keeps its local treatment,
because the prefixes tested are themselves closed-weights families no local
runtime serves.
"""
if not model_name:
return False
lower = model_name.lower()
# Split on both route ("/") and vendor-qualifier (".") boundaries so a
# family name anywhere in the path is seen: bedrock/anthropic.claude-3 ->
# ["bedrock", "anthropic", "claude-3"].
for segment in lower.replace(".", "/").split("/"):
if segment.startswith(HOSTED_ONLY_PREFIXES):
return True
return False
# The small helper model used for internal auxiliary calls -- memory quality
# scoring, context compaction, session titling, workflow routing. Historically
# about a dozen sites resolved this from OPENAI_MODEL_NAME while about twenty
# more hardcoded the same string, so half of them could not be pointed anywhere
# else. That, not the endpoint, is what blocks running fully locally: litellm and
# the openai SDK both honour OPENAI_BASE_URL, so those sites do reach a local
# server -- and then ask it for a model no local server serves.
DEFAULT_AUXILIARY_MODEL = "gpt-4o-mini"
def default_auxiliary_model(explicit: Optional[str] = None) -> str:
"""Resolve the model to use for an internal auxiliary LLM call.
Precedence: an explicit argument, then PRAISONAI_AUXILIARY_MODEL, then
OPENAI_MODEL_NAME, then DEFAULT_AUXILIARY_MODEL.
PRAISONAI_AUXILIARY_MODEL exists because a local setup often wants a
*smaller* model for helper calls than for the agent itself -- pointing
OPENAI_MODEL_NAME at a 70B local model should not make every internal
summarisation call use it.
"""
if explicit:
return explicit
import os
for var in ("PRAISONAI_AUXILIARY_MODEL", "OPENAI_MODEL_NAME"):
value = (os.environ.get(var) or "").strip()
if value:
return value
return DEFAULT_AUXILIARY_MODEL
def _entry_point_matchers():
"""Yield (provider_id, matcher) pairs registered by third-party plugins.
Isolated so a broken plugin cannot take down provider resolution: any
import/metadata failure yields nothing and falls back to built-ins.
"""
try:
from importlib.metadata import entry_points
except Exception: # pragma: no cover - importlib.metadata always present on 3.8+
return
try:
eps = entry_points(group="praisonaiagents.model_providers")
except TypeError:
# Python 3.9 returns a dict-like object without the group kwarg.
eps = entry_points().get("praisonaiagents.model_providers", [])
except Exception:
return
for ep in eps:
try:
matcher = ep.load()
except Exception:
continue
yield ep.name, matcher
def resolve_provider(model_name: str) -> Optional[str]:
"""Return the canonical provider id for ``model_name``, or None.
Precedence:
1. Explicit litellm-style prefix wins (``openai/gpt-4o`` -> ``openai``).
2. Entry-point registered matchers (third-party vendors / overrides).
3. Built-in matchers.
"""
if not model_name:
return None
# 1. Explicit provider prefix.
if "/" in model_name:
return model_name.split("/", 1)[0].lower()
lower = model_name.lower()
# 2. Third-party / override matchers.
for provider, matcher in _entry_point_matchers():
try:
if matcher(lower):
return provider
except Exception:
continue
# 3. Built-ins.
for provider, matcher in _BUILTINS.items():
if matcher(lower):
return provider
return None
@@ -21,7 +21,6 @@ import inspect
from pathlib import Path
from ..errors import ToolExecutionError
from ..model_harness.guard import check_model_request
# Graceful "wrap-up" instruction injected when the step budget is nearly
# exhausted, so the model produces a coherent final answer instead of being
@@ -247,15 +246,6 @@ def process_stream_chunks(chunks):
# First pass: Get initial tool call data
for chunk in chunks:
# Capture token usage from the final usage-only chunk. With
# stream_options={"include_usage": True} the provider sends a
# trailing chunk carrying `usage` and an empty `choices` list, so
# this must run before the `choices` guard below skips it.
chunk_usage = getattr(chunk, "usage", None)
if chunk_usage:
completion_tokens = getattr(chunk_usage, "completion_tokens", 0) or completion_tokens
prompt_tokens = getattr(chunk_usage, "prompt_tokens", 0) or prompt_tokens
if not hasattr(chunk, "choices") or not chunk.choices:
continue
@@ -423,15 +413,7 @@ class OpenAIClient:
@property
def sync_client(self):
"""Get the synchronous OpenAI client (lazy initialization).
Raises:
ModelRequestBlocked: If a test suite turned real model requests off
via ``praisonaiagents.model_harness.allow_model_requests(False)``.
Every request this class makes goes through this property, so
guarding it here covers the whole OpenAI-native path.
"""
check_model_request(getattr(self, "model", None), "openai.chat.completions")
"""Get the synchronous OpenAI client (lazy initialization)."""
if self._sync_client is None:
OpenAI, _ = _get_openai_classes()
client_kwargs = {"api_key": self.api_key, "base_url": self.base_url}
@@ -442,13 +424,7 @@ class OpenAIClient:
@property
def async_client(self):
"""Get the asynchronous OpenAI client (lazy initialization).
Raises:
ModelRequestBlocked: If a test suite turned real model requests off
via ``praisonaiagents.model_harness.allow_model_requests(False)``.
"""
check_model_request(getattr(self, "model", None), "openai.chat.completions")
"""Get the asynchronous OpenAI client (lazy initialization)."""
if self._async_client is None:
_, AsyncOpenAI = _get_openai_classes()
client_kwargs = {"api_key": self.api_key, "base_url": self.base_url}
@@ -868,24 +844,6 @@ class OpenAIClient:
part.get("image_url")
)
responses_content.append(item)
elif part_type == "file":
# Chat Completions ``{"type": "file", "file": {...}}`` (used for
# PDF attachments) becomes a Responses API ``input_file`` part.
# Passing it through untranslated would be rejected by the API.
file_spec = part.get("file")
if not isinstance(file_spec, dict):
responses_content.append(part)
continue
file_item: Dict[str, Any] = {"type": "input_file"}
for src, dst in (
("filename", "filename"),
("file_data", "file_data"),
("file_id", "file_id"),
("file_url", "file_url"),
):
if file_spec.get(src):
file_item[dst] = file_spec[src]
responses_content.append(file_item)
else:
responses_content.append(part)
return responses_content
@@ -1078,11 +1036,6 @@ class OpenAIClient:
# Fall through to Chat Completions streaming
# ── Chat Completions streaming path ─────────────────────────
# Request the trailing usage-only chunk for every streamed call so
# process_stream_chunks records real token usage even when stream
# events are not being emitted. Respect a caller-supplied
# stream_options so we never clobber an explicit choice.
stream_options = kwargs.pop("stream_options", {"include_usage": True})
# Create the response stream
response_stream = self.sync_client.chat.completions.create(
model=model,
@@ -1090,7 +1043,7 @@ class OpenAIClient:
temperature=temperature,
tools=tools if tools else None,
stream=True,
stream_options=stream_options,
stream_options={"include_usage": True} if _emit else None,
**kwargs
)
@@ -1352,11 +1305,6 @@ class OpenAIClient:
# Fall through to Chat Completions streaming
# ── Chat Completions streaming path ─────────────────────────
# Request the trailing usage-only chunk for every streamed call so
# process_stream_chunks records real token usage even when stream
# events are not being emitted. Respect a caller-supplied
# stream_options so we never clobber an explicit choice.
stream_options = kwargs.pop("stream_options", {"include_usage": True})
# Create the response stream
response_stream = await self.async_client.chat.completions.create(
model=model,
@@ -1364,7 +1312,7 @@ class OpenAIClient:
temperature=temperature,
tools=tools if tools else None,
stream=True,
stream_options=stream_options,
stream_options={"include_usage": True} if _emit else None,
**kwargs
)
@@ -2157,15 +2105,6 @@ class OpenAIClient:
break
# G2: Mid-run steering - inject pending steering notes before next call.
_inject_steering(messages)
# Trigger LLM callback for status/trace output (mirrors the sync
# tool-loop at chat_completion_with_tools so async runs emit the same
# llm_start/llm_end lifecycle events consumed by trace/status output).
from ..main import execute_sync_callback
execute_sync_callback('llm_start', model=model, agent_name=agent_name)
# Per-iteration start so latency_ms measures THIS model request, not
# the cumulative time since the method began (each tool-loop step is
# a separate billed call).
iteration_start_time = time.time()
# Graceful wrap-up on the final permitted step.
if not _wrapup_injected and max_iterations > 1 and iteration_count == max_iterations - 1:
messages.append({
@@ -2243,17 +2182,6 @@ class OpenAIClient:
)
if not final_response:
# Pair the llm_start emitted above with an llm_end even on the
# failure path so traces/status telemetry never carry an
# unmatched lifecycle event when a provider returns nothing.
execute_sync_callback(
'llm_end',
model=model,
tokens_in=0,
tokens_out=0,
cost=None,
latency_ms=(time.time() - iteration_start_time) * 1000
)
return None
# Record usage for THIS billed completion. Every tool-loop iteration
@@ -2262,29 +2190,6 @@ class OpenAIClient:
# from session totals and by_model/by_agent rollups (Issue #3933).
self._track_token_usage(final_response, model, agent_name)
# Trigger llm_end callback with cost/latency metrics — mirrors the
# sync tool-loop so async agents also emit LLM spans and cost figures
# for --trace/status output (otherwise async cost tracking goes dark).
llm_latency_ms = (time.time() - iteration_start_time) * 1000
usage = getattr(final_response, 'usage', None)
tokens_in = getattr(usage, 'prompt_tokens', 0) if usage else 0
tokens_out = getattr(usage, 'completion_tokens', 0) if usage else 0
cost = None
try:
from ._cost import calculate_cost
cost = calculate_cost(final_response, model=model)
except Exception as e:
# Cost calculation is optional - log for debugging
get_logger(__name__).debug(f"Cost calculation failed: {e}")
execute_sync_callback(
'llm_end',
model=model,
tokens_in=tokens_in,
tokens_out=tokens_out,
cost=cost,
latency_ms=llm_latency_ms
)
# Check for tool calls
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
@@ -271,16 +271,15 @@ class LLMProviderAdapterProtocol(Protocol):
return False
def should_summarize_tools(self, iter_count: int) -> bool:
return iter_count >= 1 # Ollama-specific threshold
return iter_count >= 3 # Ollama-specific threshold
def handle_empty_response_with_tools(self, state) -> bool:
# Ollama tends to return empty content after tool execution
return bool(state.get('accumulated_tool_results')
and not state.get('response_text', '').strip())
def format_tools(self, tools) -> list:
return tools # No special formatting needed
def recover_tool_calls_from_text(self, response_text, tools):
# Small local models often emit the call as JSON content
return _recover_json_tool_calls(response_text, tools)
def post_tool_iteration(self, state) -> None:
if state.get('empty_response') and state.get('tools'):
# Add Ollama-specific tool summary
state['summary'] = "Tool results processed"
```
"""
@@ -292,8 +291,17 @@ class LLMProviderAdapterProtocol(Protocol):
"""Check if tools should be summarized at this iteration count."""
...
def format_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Format tools for this provider's requirements."""
...
def post_tool_iteration(self, state: Dict[str, Any]) -> None:
"""Handle provider-specific post-tool processing."""
...
def supports_structured_output(self) -> bool:
"""Check if provider supports structured JSON output."""
...
def supports_streaming(self) -> bool:
"""Check if provider supports streaming responses."""
@@ -303,6 +311,9 @@ class LLMProviderAdapterProtocol(Protocol):
"""Check if provider supports streaming with tools enabled."""
...
def get_max_iteration_threshold(self) -> int:
"""Get provider-specific maximum iteration count."""
...
def format_tool_result_message(self, function_name: str, tool_result: Any, tool_call_id: Optional[str] = None) -> Dict[str, Any]:
"""Format tool result message for this provider's requirements."""
@@ -316,13 +327,25 @@ class LLMProviderAdapterProtocol(Protocol):
"""Get provider-specific default settings."""
...
def parse_tool_calls(self, raw_response: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
"""Parse tool calls from provider-specific response format."""
...
def should_skip_streaming_with_tools(self) -> bool:
"""Check if provider should skip streaming when tools are present."""
...
def recover_tool_calls_from_text(self, response_text: str, tools: List[Dict[str, Any]]) -> Optional[List[Dict[str, Any]]]:
"""Attempt to recover tool calls from response text for providers that don't format them properly."""
...
def inject_cache_control(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Inject provider-specific cache control headers."""
...
def extract_reasoning_tokens(self, response: Dict[str, Any]) -> int:
"""Extract reasoning token count from provider-specific response."""
...
@runtime_checkable
@@ -0,0 +1,388 @@
"""
Streaming Protocol Extension - implements Gap 3 from Issue #1392.
This module completes the streaming adapter pattern by extending LLM adapters
with comprehensive streaming capabilities, removing provider-specific conditionals
from the core LLM loop.
Architecture:
- Extends existing LLMProviderAdapterProtocol with streaming interface
- Each adapter owns its streaming decisions and error recovery
- Core llm.py contains no provider-name conditionals for streaming
- StreamEvent protocol is used consistently for all streaming deltas
- Observable fallback events instead of silent streaming->non-streaming fallback
Design principles:
- Protocol-driven: complete the existing adapter pattern
- DRY: eliminate scattered streaming conditionals
- Extensibility: new providers = new adapter only (no core changes)
- Observable: emit events for streaming capability decisions
"""
import logging
from typing import Optional, Dict, Any, List, AsyncIterator, Union, Protocol
from abc import abstractmethod
from ..streaming.events import StreamEvent, StreamEventType, StreamEventEmitter
logger = logging.getLogger(__name__)
class StreamingCapableAdapter(Protocol):
"""
Extended protocol for LLM adapters with comprehensive streaming support.
This extends the base LLMProviderAdapterProtocol with streaming-specific
methods that encapsulate all provider quirks and decisions.
"""
def can_stream(self, *, tools: Optional[List[Dict[str, Any]]] = None, **kwargs) -> bool:
"""
Determine if this provider can stream in the requested configuration.
Args:
tools: Optional list of tools that will be used
**kwargs: Additional LLM parameters that might affect streaming
Returns:
True if streaming is possible with these parameters, False otherwise
"""
...
async def stream_completion(
self,
*,
messages: List[Dict[str, Any]],
model: str,
temperature: float = 1.0,
tools: Optional[List[Dict[str, Any]]] = None,
stream_emitter: Optional[StreamEventEmitter] = None,
**kwargs
) -> AsyncIterator[StreamEvent]:
"""
Stream LLM completion using this provider's native streaming API.
Args:
messages: Chat messages
model: Model identifier
temperature: Sampling temperature
tools: Optional tools for function calling
stream_emitter: Optional emitter for additional event handling
**kwargs: Additional provider-specific parameters
Yields:
StreamEvent instances with standardized streaming data
Raises:
StreamingNotSupportedError: If streaming not available in this configuration
StreamingError: If streaming fails and cannot recover
"""
...
def is_stream_error_recoverable(self, exc: Exception) -> bool:
"""
Determine if a streaming error can be recovered by falling back to non-streaming.
Args:
exc: Exception that occurred during streaming
Returns:
True if fallback to non-streaming should be attempted, False otherwise
"""
...
def create_stream_unavailable_event(self, reason: str, **metadata) -> StreamEvent:
"""
Create a StreamEvent indicating why streaming is not available.
This enables observable fallback instead of silent streaming -> non-streaming.
Args:
reason: Human-readable reason why streaming is unavailable
**metadata: Additional context for the unavailability
Returns:
StreamEvent with type STREAM_UNAVAILABLE and reason
"""
...
class StreamingNotSupportedError(Exception):
"""Raised when streaming is not supported in the current configuration."""
pass
class StreamingError(Exception):
"""Raised when streaming fails unrecoverably."""
pass
class DefaultStreamingAdapter:
"""
Default streaming adapter with sensible fallbacks.
Provides baseline streaming support for most providers that follow
OpenAI-style streaming APIs.
"""
def can_stream(self, *, tools: Optional[List[Dict[str, Any]]] = None, **kwargs) -> bool:
"""Most providers support streaming, even with tools."""
return True
async def stream_completion(
self,
*,
messages: List[Dict[str, Any]],
model: str,
temperature: float = 1.0,
tools: Optional[List[Dict[str, Any]]] = None,
stream_emitter: Optional[StreamEventEmitter] = None,
**kwargs
) -> AsyncIterator[StreamEvent]:
"""
Default streaming implementation using litellm.
This is the baseline implementation that most providers can use.
Provider-specific adapters override this for custom behavior.
"""
import litellm
import time
# Emit request start event
start_time = time.perf_counter()
request_start_event = StreamEvent(
type=StreamEventType.REQUEST_START,
timestamp=start_time,
metadata={"model": model, "provider": "default"}
)
# Yield the event to consumers and emit to optional stream_emitter
yield request_start_event
if stream_emitter:
await stream_emitter.emit_async(request_start_event)
try:
# Build completion parameters
completion_params = {
"messages": messages,
"model": model,
"temperature": temperature,
"stream": True,
**kwargs
}
if tools:
completion_params["tools"] = tools
response_text = ""
tool_calls = []
first_token_emitted = False
# Stream the completion
async for chunk in litellm.acompletion(**completion_params):
if chunk and chunk.choices and chunk.choices[0].delta:
delta = chunk.choices[0].delta
# Handle text content
if delta.content:
if not first_token_emitted:
yield StreamEvent(
type=StreamEventType.FIRST_TOKEN,
timestamp=time.perf_counter(),
content=delta.content
)
first_token_emitted = True
else:
yield StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=delta.content
)
response_text += delta.content
# Handle tool calls
if delta.tool_calls:
for tool_call in delta.tool_calls:
yield StreamEvent(
type=StreamEventType.DELTA_TOOL_CALL,
timestamp=time.perf_counter(),
tool_call={
"id": tool_call.id,
"name": tool_call.function.name if tool_call.function else None,
"arguments": tool_call.function.arguments if tool_call.function else None
}
)
tool_calls.append(tool_call)
# Emit stream completion
yield StreamEvent(
type=StreamEventType.STREAM_END,
timestamp=time.perf_counter(),
metadata={
"response_text": response_text,
"tool_calls": [tc.to_dict() if hasattr(tc, 'to_dict') else tc for tc in tool_calls]
}
)
except Exception as e:
# Emit error event
yield StreamEvent(
type=StreamEventType.ERROR,
timestamp=time.perf_counter(),
error=str(e),
metadata={"exception_type": type(e).__name__}
)
if not self.is_stream_error_recoverable(e):
raise StreamingError(f"Unrecoverable streaming error: {e}") from e
def is_stream_error_recoverable(self, exc: Exception) -> bool:
"""
Default error recovery logic.
Most JSON parsing errors and timeout errors are recoverable.
"""
error_str = str(exc).lower()
recoverable_patterns = [
"json",
"parsing",
"timeout",
"connection"
]
return any(pattern in error_str for pattern in recoverable_patterns)
def create_stream_unavailable_event(self, reason: str, **metadata) -> StreamEvent:
"""Create standard stream unavailable event."""
import time
return StreamEvent(
type=StreamEventType.STREAM_UNAVAILABLE,
timestamp=time.perf_counter(),
error=f"Streaming unavailable: {reason}",
metadata={
"reason": reason,
**metadata
}
)
class OllamaStreamingAdapter(DefaultStreamingAdapter):
"""
Ollama-specific streaming adapter.
Handles Ollama's streaming limitations:
- Doesn't support streaming with tools reliably
- Has specific error patterns that need different recovery
"""
def can_stream(self, *, tools: Optional[List[Dict[str, Any]]] = None, **kwargs) -> bool:
"""Ollama can stream, but not reliably with tools."""
if tools:
return False # Disable streaming when tools are present
return True
def is_stream_error_recoverable(self, exc: Exception) -> bool:
"""Ollama-specific error recovery patterns."""
error_str = str(exc).lower()
ollama_recoverable = [
"json",
"parsing",
"connection reset",
"incomplete",
"tool" # Tool-related errors are often recoverable by disabling tools
]
return any(pattern in error_str for pattern in ollama_recoverable)
class AnthropicStreamingAdapter(DefaultStreamingAdapter):
"""
Anthropic/Claude streaming adapter.
Handles the known litellm/async-generator bug with Anthropic streaming.
"""
def can_stream(self, *, tools: Optional[List[Dict[str, Any]]] = None, **kwargs) -> bool:
"""
Anthropic streaming is disabled due to litellm bug.
Issue: litellm.acompletion with stream=True returns a ModelResponse (not async generator)
for Anthropic in the async path, causing 'async for requires __aiter__' error.
"""
return False # Disable until litellm bug is fixed
def create_stream_unavailable_event(self, reason: Optional[str] = None, **metadata) -> StreamEvent:
"""Create Anthropic-specific unavailable event with bug details."""
return super().create_stream_unavailable_event(
reason or "litellm async generator bug",
provider="anthropic",
# TODO: Add actual bug reference when litellm issue is filed
**metadata
)
class GeminiStreamingAdapter(DefaultStreamingAdapter):
"""
Google Gemini streaming adapter.
Handles Gemini's streaming issues with tools and JSON parsing.
"""
def can_stream(self, *, tools: Optional[List[Dict[str, Any]]] = None, **kwargs) -> bool:
"""Gemini has issues with streaming + tools."""
if tools:
return False # Disable streaming when tools are present due to JSON parsing issues
return True
def create_stream_unavailable_event(self, reason: Optional[str] = None, **metadata) -> StreamEvent:
"""Create Gemini-specific unavailable event."""
return super().create_stream_unavailable_event(
reason or "JSON parsing issues with tools",
provider="gemini",
**metadata
)
# Streaming adapter registry
_streaming_adapters: Dict[str, StreamingCapableAdapter] = {}
# Register core streaming adapters
_streaming_adapters['default'] = DefaultStreamingAdapter()
_streaming_adapters['ollama'] = OllamaStreamingAdapter()
_streaming_adapters['anthropic'] = AnthropicStreamingAdapter()
_streaming_adapters['claude'] = AnthropicStreamingAdapter() # Alias
_streaming_adapters['gemini'] = GeminiStreamingAdapter()
def get_streaming_adapter(provider_name: str) -> StreamingCapableAdapter:
"""
Get streaming adapter for provider with fallback to default.
Args:
provider_name: Provider name (e.g., "anthropic", "ollama", "gemini")
Returns:
StreamingCapableAdapter instance
"""
name_lower = provider_name.lower()
# Exact match first
if name_lower in _streaming_adapters:
return _streaming_adapters[name_lower]
# Provider prefixes or substrings
if "ollama" in name_lower:
return _streaming_adapters["ollama"]
if "claude" in name_lower or "anthropic" in name_lower:
return _streaming_adapters["anthropic"]
if "gemini" in name_lower:
return _streaming_adapters["gemini"]
return _streaming_adapters["default"]
def add_streaming_adapter(name: str, adapter: StreamingCapableAdapter) -> None:
"""Register a custom streaming adapter."""
_streaming_adapters[name] = adapter
def list_streaming_adapters() -> List[str]:
"""List all registered streaming adapter names."""
return sorted(_streaming_adapters.keys())
@@ -1,65 +0,0 @@
"""Local model runtime resolution: what is running, what it can do, what it gets wrong.
Every local runtime worth supporting already speaks OpenAI over HTTP, and litellm
already owns that transport. This package is therefore NOT a transport and NOT a
client. It answers three questions and returns the answers as frozen data:
1. What is running on this machine? discover()
2. What can that model actually do? capabilities via resolve()
3. What will it silently get wrong? quirktable
Two properties are load-bearing and are enforced by tests:
* It is a dependency sink. Every module here imports only the standard library
and its siblings -- nothing from praisonaiagents. That is what lets modules
which currently bypass the LLM layer adopt it without deepening the 26
mutually-importing subpackage pairs the package already has.
* It produces data, never behaviour. No chat call, no retry, no request
mutation, no process management, no disk writes. Compensating behaviour
belongs in llm/adapters/.
"""
from .capabilities import (ApiStyle, Cap, DEFAULT_CAPS, Evidence, LocalEngine,
default_caps, parse_llama_cpp_props,
parse_lm_studio_models, parse_ollama_capabilities,
parse_openai_models)
from .discover import (DEFAULT_PROBE_TIMEOUT, DEFAULT_TOTAL_BUDGET, Discovery,
HttpReply, LOOPBACK_HOST, PROBES, ProbeSpec, Rule, RuleKind,
discover, models, probe_endpoint, probe_table)
from .quirktable import (NOTES, Quirk, QuirkNote, Severity, all_notes, note,
notes_for, quirks_for, silent_quirks)
from .errors import (EngineUnreachableError, HostHeaderRejectedError,
InvalidLocalSpecError, LocalError, ModelNotAvailableError,
NoLocalEngineError)
from .embed import (
ENV_EMBED_MODEL,
PREFERRED_EMBED_MODELS,
local_embedder_config,
select_embedding_model,
)
from .target import (DEFAULT_API_KEY, ENV_BASE_URL, ENV_ENGINE, ENV_MODEL,
LocalTarget, build_target, litellm_model_for,
parse_ollama_host, parse_spec, select_model)
from .resolve import cache_info, clear_cache, resolve, resolve_or_none
__all__ = [
"ENV_EMBED_MODEL",
"PREFERRED_EMBED_MODELS",
"local_embedder_config",
"select_embedding_model",
"LocalEngine", "ApiStyle", "Cap", "Evidence", "Severity", "Quirk",
"DEFAULT_CAPS", "default_caps", "parse_ollama_capabilities",
"parse_llama_cpp_props", "parse_lm_studio_models", "parse_openai_models",
"Discovery", "HttpReply", "ProbeSpec", "Rule", "RuleKind", "PROBES",
"LOOPBACK_HOST", "DEFAULT_PROBE_TIMEOUT", "DEFAULT_TOTAL_BUDGET",
"discover", "probe_endpoint", "probe_table", "models",
"QuirkNote", "NOTES", "note", "all_notes", "notes_for", "quirks_for",
"silent_quirks",
"LocalTarget", "resolve", "resolve_or_none", "build_target",
"litellm_model_for", "parse_spec", "parse_ollama_host", "select_model",
"clear_cache", "cache_info", "DEFAULT_API_KEY", "ENV_BASE_URL", "ENV_ENGINE",
"ENV_MODEL",
"LocalError", "NoLocalEngineError", "EngineUnreachableError",
"HostHeaderRejectedError", "ModelNotAvailableError", "InvalidLocalSpecError",
]
@@ -1,168 +0,0 @@
"""Local-engine vocabulary and capability resolution.
Layer 0 of ``praisonaiagents.local``: standard library only, no sibling imports
beyond none at all. This module owns the shared vocabulary (``LocalEngine``,
``ApiStyle``, ``Cap``, ``Evidence``) as well as the capability parsers, because
that is the only placement that keeps the package's import graph acyclic.
"""
from __future__ import annotations
from enum import Enum
from typing import Any, Mapping
__all__ = [
"LocalEngine", "ApiStyle", "Cap", "Evidence",
"DEFAULT_CAPS", "default_caps",
"parse_ollama_capabilities", "parse_llama_cpp_props",
"parse_lm_studio_models", "parse_openai_models",
]
class LocalEngine(str, Enum):
"""A local model server. Never called "runtime": that name is taken."""
OLLAMA = "ollama"
LLAMA_CPP = "llama_cpp"
MLX_LM = "mlx_lm"
LM_STUDIO = "lm_studio"
VLLM = "vllm"
TRANSFORMERS_SERVE = "transformers_serve"
LLAMAFILE = "llamafile"
LOCALAI = "localai"
RAMALAMA = "ramalama"
UNKNOWN = "unknown"
class ApiStyle(str, Enum):
OPENAI_CHAT = "openai_chat"
OPENAI_COMPLETIONS = "openai_completions"
OLLAMA_NATIVE = "ollama_native"
class Cap(str, Enum):
CHAT = "chat"
COMPLETION = "completion"
TOOLS = "tools"
VISION = "vision"
AUDIO_IN = "audio_in"
THINKING = "thinking"
JSON_OBJECT = "json_object"
JSON_SCHEMA = "json_schema"
STREAMING = "streaming"
STREAMING_WITH_TOOLS = "streaming_with_tools"
PARALLEL_TOOL_CALLS = "parallel_tool_calls"
EMBEDDINGS_ENDPOINT = "embeddings_endpoint"
class Evidence(str, Enum):
"""Where a field's value came from. Keeps guesses distinguishable from facts."""
SERVER = "server"
TABLE = "table"
ENV = "env"
SPEC = "spec"
UNKNOWN = "unknown"
# Static per-engine floor. Cap.TOOLS and Cap.VISION are deliberately absent from
# every row: claiming them without server evidence is exactly the silent failure
# this package exists to prevent.
DEFAULT_CAPS: Mapping[LocalEngine, frozenset] = {
LocalEngine.OLLAMA: frozenset({
Cap.CHAT, Cap.COMPLETION, Cap.STREAMING, Cap.JSON_OBJECT,
Cap.JSON_SCHEMA, Cap.EMBEDDINGS_ENDPOINT}),
LocalEngine.LLAMA_CPP: frozenset({
Cap.CHAT, Cap.COMPLETION, Cap.STREAMING, Cap.JSON_SCHEMA,
Cap.JSON_OBJECT, Cap.EMBEDDINGS_ENDPOINT}),
LocalEngine.MLX_LM: frozenset({Cap.CHAT, Cap.COMPLETION, Cap.STREAMING}),
LocalEngine.LM_STUDIO: frozenset({
Cap.CHAT, Cap.COMPLETION, Cap.STREAMING, Cap.JSON_SCHEMA,
Cap.JSON_OBJECT, Cap.EMBEDDINGS_ENDPOINT}),
LocalEngine.VLLM: frozenset({
Cap.CHAT, Cap.COMPLETION, Cap.STREAMING, Cap.JSON_SCHEMA, Cap.JSON_OBJECT,
Cap.EMBEDDINGS_ENDPOINT, Cap.PARALLEL_TOOL_CALLS}),
LocalEngine.TRANSFORMERS_SERVE: frozenset({Cap.CHAT, Cap.STREAMING}),
LocalEngine.LLAMAFILE: frozenset({Cap.CHAT, Cap.COMPLETION, Cap.STREAMING}),
LocalEngine.LOCALAI: frozenset({Cap.CHAT, Cap.COMPLETION, Cap.STREAMING}),
LocalEngine.RAMALAMA: frozenset({Cap.CHAT, Cap.COMPLETION, Cap.STREAMING}),
LocalEngine.UNKNOWN: frozenset({Cap.CHAT, Cap.STREAMING}),
}
def default_caps(engine) -> frozenset:
"""Return the static per-engine capability floor (no network, Evidence.TABLE)."""
try:
return DEFAULT_CAPS[LocalEngine(engine)]
except (KeyError, ValueError):
return DEFAULT_CAPS[LocalEngine.UNKNOWN]
_OLLAMA_CAP_MAP = {
"completion": (Cap.COMPLETION, Cap.CHAT),
"tools": (Cap.TOOLS,),
"vision": (Cap.VISION,),
"thinking": (Cap.THINKING,),
"embedding": (Cap.EMBEDDINGS_ENDPOINT,),
"audio": (Cap.AUDIO_IN,),
}
def parse_ollama_capabilities(payload: Mapping[str, Any]) -> frozenset:
"""Map an Ollama /api/show or /api/tags entry's ``capabilities`` list onto Cap.
Unknown strings ("insert", anything new) are ignored rather than raised on,
so a newer Ollama cannot break resolution.
"""
out = set()
try:
for name in payload.get("capabilities") or ():
out.update(_OLLAMA_CAP_MAP.get(str(name).lower(), ()))
except AttributeError:
return frozenset()
return frozenset(out)
def parse_llama_cpp_props(payload: Mapping[str, Any]) -> frozenset:
"""Map a llama.cpp GET /props reply onto Cap."""
out = {Cap.CHAT, Cap.COMPLETION, Cap.STREAMING}
try:
modalities = payload.get("modalities") or {}
if modalities.get("vision"):
out.add(Cap.VISION)
if modalities.get("audio"):
out.add(Cap.AUDIO_IN)
template = str(payload.get("chat_template") or "")
if "tool" in template.lower():
out.add(Cap.TOOLS)
except AttributeError:
return frozenset()
return frozenset(out)
def parse_lm_studio_models(payload: Mapping[str, Any], model_id) -> frozenset:
"""Map an LM Studio GET /api/v0/models entry onto Cap."""
out = {Cap.CHAT, Cap.COMPLETION, Cap.STREAMING}
try:
for entry in payload.get("data") or ():
if model_id and entry.get("id") != model_id:
continue
if entry.get("type") == "embeddings":
out.add(Cap.EMBEDDINGS_ENDPOINT)
if entry.get("vision"):
out.add(Cap.VISION)
if entry.get("tool_use") or entry.get("function_calling"):
out.add(Cap.TOOLS)
except AttributeError:
return frozenset()
return frozenset(out)
def parse_openai_models(payload: Mapping[str, Any]) -> tuple:
"""Extract model ids from an OpenAI-shaped {"data":[{"id":...}]} reply."""
try:
return tuple(
str(e["id"]) for e in (payload.get("data") or ()) if isinstance(e, dict) and "id" in e
)
except AttributeError:
return ()
@@ -1,409 +0,0 @@
"""Identify which local model server is listening, without inferring from the port.
Port collisions are real: 8080 is claimed by llama-server, mlx_lm, llamafile,
LocalAI and RamaLama; 8000 by vLLM and `transformers serve`. Identity is therefore
never inferred from a port -- a ProbeSpec matches only when every Rule holds, and
RuleKind.ABSENT rules exist specifically to separate co-tenants of one port.
Standard library only. urllib.request rather than httpx: httpx is present only as
an undeclared transitive dependency of `openai`, and a package whose whole point
is that anything can import it must not rest on that.
"""
from __future__ import annotations
import concurrent.futures
import http.client
import json
import socket
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from enum import Enum
from typing import Optional, Sequence
from .capabilities import ApiStyle, Evidence, LocalEngine, parse_openai_models
from .errors import InvalidLocalSpecError
__all__ = [
"HttpReply", "RuleKind", "Rule", "ProbeSpec", "Discovery",
"PROBES", "LOOPBACK_HOST", "DEFAULT_PROBE_TIMEOUT", "DEFAULT_TOTAL_BUDGET",
"probe_table", "probe_endpoint", "discover", "models", "default_transport",
]
LOOPBACK_HOST = "127.0.0.1"
DEFAULT_PROBE_TIMEOUT = 0.4
DEFAULT_TOTAL_BUDGET = 1.5
_MAX_BODY = 1 << 20 # 1 MiB
@dataclass(frozen=True)
class HttpReply:
status: int # 0 == transport failure
body: bytes = b""
error: Optional[str] = None # "refused" | "timeout" | "reset" | "http" | None
class RuleKind(str, Enum):
BODY_PREFIX = "body_prefix"
JSON_KEY = "json_key"
STATUS_OK = "status_ok"
ABSENT = "absent"
@dataclass(frozen=True)
class Rule:
method: str
path: str
kind: RuleKind
value: str = ""
@dataclass(frozen=True)
class ProbeSpec:
engine: LocalEngine
default_ports: tuple
identity: tuple
api_style: ApiStyle
verified: bool
note: str
version_path: Optional[str] = None
version_key: Optional[str] = None
models_path: Optional[str] = None
models_key: Optional[str] = None
model_caps: Optional[Rule] = None
@dataclass(frozen=True)
class Discovery:
engine: LocalEngine
base_url: str
api_style: ApiStyle
evidence: Evidence
engine_version: Optional[str] = None
models: tuple = ()
# Per-model metadata when the server offers it, as a tuple of
# (model_id, capabilities_tuple, modified_at) -- enough to choose a model
# without a second request. Empty when the server reports names only.
model_meta: tuple = ()
raw_identity: str = ""
latency_ms: int = 0
blocked: Optional[str] = None
PROBES: tuple = (
ProbeSpec(
engine=LocalEngine.OLLAMA,
default_ports=(11434,),
identity=(Rule("GET", "/", RuleKind.BODY_PREFIX, "Ollama is running"),),
api_style=ApiStyle.OPENAI_CHAT,
verified=True,
note=("verified 0.33.2 @ 2026-09-03: GET / -> 'Ollama is running'; "
"/api/version -> {'version':'0.33.2'}; /api/tags entries carry "
"capabilities and modified_at; POST /api/show carries capabilities."),
version_path="/api/version", version_key="version",
models_path="/api/tags", models_key="models[].model",
model_caps=Rule("POST", "/api/show", RuleKind.JSON_KEY, "capabilities"),
),
ProbeSpec(
engine=LocalEngine.LLAMA_CPP,
default_ports=(8080,),
identity=(Rule("GET", "/health", RuleKind.STATUS_OK),
Rule("GET", "/props", RuleKind.JSON_KEY, "build_info")),
api_style=ApiStyle.OPENAI_CHAT,
verified=False,
note=("binary present (build 7620), --port default 8080 confirmed from "
"--help; /props reply shape NOT re-verified live."),
version_path="/props", version_key="build_info",
models_path="/v1/models", models_key="data[].id",
model_caps=Rule("GET", "/props", RuleKind.JSON_KEY, "modalities"),
),
ProbeSpec(
engine=LocalEngine.MLX_LM,
default_ports=(8080,),
identity=(Rule("GET", "/health", RuleKind.STATUS_OK),
Rule("GET", "/props", RuleKind.ABSENT),
Rule("GET", "/v1/models", RuleKind.JSON_KEY, "data")),
api_style=ApiStyle.OPENAI_CHAT,
verified=False,
note=("mlx_lm.server present on PATH; default port 8080. Identity is "
"'/health answers AND /props does not', to separate it from "
"llama.cpp on the same port."),
models_path="/v1/models", models_key="data[].id",
),
ProbeSpec(
engine=LocalEngine.LM_STUDIO,
default_ports=(1234,),
identity=(Rule("GET", "/api/v0/models", RuleKind.JSON_KEY, "data"),),
api_style=ApiStyle.OPENAI_CHAT,
verified=False,
note=("LM Studio not installed on the machine this was written on. "
"/api/v0/models is richer than /v1/models and is the discriminator. "
"Use HTTP, not the Python SDK (stale since Aug 2025, WebSocket-based)."),
models_path="/api/v0/models", models_key="data[].id",
model_caps=Rule("GET", "/api/v0/models", RuleKind.JSON_KEY, "data"),
),
ProbeSpec(
engine=LocalEngine.VLLM,
default_ports=(8000,),
identity=(Rule("GET", "/version", RuleKind.JSON_KEY, "version"),),
api_style=ApiStyle.OPENAI_CHAT,
verified=False,
note=("Linux only. Tools require BOTH --enable-auto-tool-choice AND "
"--tool-call-parser, else tool calls come back as plain text."),
version_path="/version", version_key="version",
models_path="/v1/models", models_key="data[].id",
),
ProbeSpec(
engine=LocalEngine.TRANSFORMERS_SERVE,
default_ports=(8000,),
identity=(Rule("GET", "/version", RuleKind.ABSENT),
Rule("GET", "/health", RuleKind.STATUS_OK),
Rule("GET", "/v1/models", RuleKind.JSON_KEY, "data")),
api_style=ApiStyle.OPENAI_CHAT,
verified=False,
note=("`transformers serve`, default 8000; /health is undocumented. "
"Identity is '/version absent AND /health ok', to separate it "
"from vLLM on 8000."),
models_path="/v1/models", models_key="data[].id",
),
)
def probe_table() -> tuple:
"""The ordered probe table (identity endpoints and discriminators)."""
return PROBES
def default_transport(method: str, url: str, body, timeout: float) -> HttpReply:
"""The only thing in this package that touches a socket."""
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Accept", "application/json")
req.add_header("User-Agent", "praisonaiagents-local/1")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return HttpReply(resp.status, resp.read(_MAX_BODY))
except urllib.error.HTTPError as exc:
# HTTPError is both an exception and a response: read it, don't leak it.
try:
payload = exc.read(_MAX_BODY)
except Exception:
payload = b""
return HttpReply(exc.code, payload, "http")
except urllib.error.URLError as exc:
reason = getattr(exc, "reason", None)
if isinstance(reason, socket.timeout) or isinstance(reason, TimeoutError):
return HttpReply(0, b"", "timeout")
return HttpReply(0, b"", "refused")
except (socket.timeout, TimeoutError):
return HttpReply(0, b"", "timeout")
except ConnectionResetError:
return HttpReply(0, b"", "reset")
except (OSError, ValueError, http.client.HTTPException):
return HttpReply(0, b"", "refused")
def _json(reply: HttpReply):
try:
return json.loads(reply.body.decode("utf-8", errors="replace"))
except (ValueError, AttributeError):
return None
def _rule_holds(rule: Rule, reply: HttpReply) -> bool:
if rule.kind is RuleKind.ABSENT:
if reply.status in (404, 405):
return True
if reply.status == 0:
return False
return _json(reply) is None
if rule.kind is RuleKind.STATUS_OK:
return 200 <= reply.status < 300
if reply.status != 200:
return False
if rule.kind is RuleKind.BODY_PREFIX:
return reply.body.decode("utf-8", errors="replace").lstrip().startswith(rule.value)
if rule.kind is RuleKind.JSON_KEY:
obj = _json(reply)
return isinstance(obj, dict) and rule.value in obj
return False
def _extract_model_meta(payload, key) -> tuple:
"""Per-model (id, capabilities, modified_at) when the listing carries it."""
if key != "models[].model" or not isinstance(payload, dict):
return ()
out = []
for m in payload.get("models") or ():
if isinstance(m, dict) and "model" in m:
caps = tuple(str(c).lower() for c in (m.get("capabilities") or ()))
out.append((str(m["model"]), caps, str(m.get("modified_at") or "")))
return tuple(out)
def _extract_models(payload, key) -> tuple:
if not isinstance(payload, dict) or not key:
return ()
if key == "data[].id":
return parse_openai_models(payload)
if key == "models[].model":
return tuple(
str(m["model"]) for m in (payload.get("models") or ())
if isinstance(m, dict) and "model" in m
)
return ()
def probe_endpoint(base_url: str, *, expect=None, timeout: float = DEFAULT_PROBE_TIMEOUT,
transport=None) -> Optional[Discovery]:
"""Identify the server at ``base_url``, or None if nothing identifiable answered.
A 200 that matches no discriminator yields engine=UNKNOWN. A bodyless 403
yields blocked="host_header_rejected" rather than being reported as absent.
"""
base = (base_url or "").rstrip("/")
if "://" not in base or not base.split("://", 1)[1]:
raise InvalidLocalSpecError(f"Not a usable base URL: {base_url!r}")
send = transport or default_transport
started = time.monotonic()
candidates = [p for p in PROBES if expect is None or p.engine == LocalEngine(expect)]
cache = {}
def fetch(method, path, body=None):
key = (method, path)
if key not in cache:
cache[key] = send(method, base + path, body, timeout)
return cache[key]
first_reply = None
for spec in candidates:
ok = True
for rule in spec.identity:
reply = fetch(rule.method, rule.path,
b'{"model":""}' if rule.method == "POST" else None)
if first_reply is None:
first_reply = reply
if reply.status in (401, 403) and not reply.body:
return Discovery(
engine=LocalEngine(expect) if expect else LocalEngine.UNKNOWN,
base_url=base, api_style=spec.api_style, evidence=Evidence.TABLE,
blocked="host_header_rejected",
latency_ms=int((time.monotonic() - started) * 1000))
if not _rule_holds(rule, reply):
ok = False
break
if not ok:
continue
version = None
if spec.version_path:
obj = _json(fetch("GET", spec.version_path))
if isinstance(obj, dict) and spec.version_key:
v = obj.get(spec.version_key)
version = str(v) if v is not None else None
found, meta = (), ()
if spec.models_path:
listing = _json(fetch("GET", spec.models_path))
found = _extract_models(listing, spec.models_key)
meta = _extract_model_meta(listing, spec.models_key)
raw = b""
if spec.identity:
raw = fetch(spec.identity[0].method, spec.identity[0].path).body
return Discovery(
engine=spec.engine, base_url=base, api_style=spec.api_style,
evidence=Evidence.SERVER, engine_version=version, models=found,
model_meta=meta,
raw_identity=raw.decode("utf-8", errors="replace")[:200],
latency_ms=int((time.monotonic() - started) * 1000))
# No discriminator matched. Before declaring the host absent, ask the one
# question every OpenAI-compatible server answers. Gating this on the first
# probe's status was wrong: that probe is Ollama's `GET /`, which a generic
# OpenAI-compatible server 404s -- so a healthy llama-server, LM Studio or
# vLLM was reported as "did not answer (refused)" while it was serving.
# It also makes the pinned-engine path reachable: an engine named by
# PRAISONAI_LOCAL_ENGINE that has no ProbeSpec (llamafile, localai,
# ramalama) produced an empty candidate list and could never resolve.
# A base the user pasted from LM Studio or vLLM already ends in /v1; adding
# another produced /v1/v1/models and a 404, so a healthy server looked absent.
models_reply = fetch("GET", "/models" if base.endswith("/v1") else "/v1/models")
if 200 <= models_reply.status < 300:
listing = _json(models_reply)
pinned = LocalEngine(expect) if expect else None
return Discovery(
engine=pinned or LocalEngine.UNKNOWN, base_url=base,
api_style=ApiStyle.OPENAI_CHAT if isinstance(listing, dict) else ApiStyle.OPENAI_COMPLETIONS,
# A pinned engine is asserted by the environment, not observed.
evidence=Evidence.ENV if pinned else Evidence.TABLE,
models=parse_openai_models(listing) if isinstance(listing, dict) else (),
raw_identity=models_reply.body.decode("utf-8", errors="replace")[:200],
latency_ms=int((time.monotonic() - started) * 1000))
# Some servers expose no model listing at all. If anything answered with a
# 2xx we still know a server is there; only a transport failure means absent.
if first_reply is not None and 200 <= first_reply.status < 300:
return Discovery(
engine=LocalEngine(expect) if expect else LocalEngine.UNKNOWN,
base_url=base, api_style=ApiStyle.OPENAI_CHAT,
evidence=Evidence.ENV if expect else Evidence.TABLE,
raw_identity=first_reply.body.decode("utf-8", errors="replace")[:200],
latency_ms=int((time.monotonic() - started) * 1000))
return None
def discover(*, ports: Optional[Sequence] = None, include: Optional[Sequence] = None,
host: str = LOOPBACK_HOST, timeout: float = DEFAULT_PROBE_TIMEOUT,
budget: float = DEFAULT_TOTAL_BUDGET, transport=None) -> tuple:
"""Scan the probe table's default ports; return every server found.
Ports are probed concurrently. Results are ordered by PROBES order then port,
so Ollama on 11434 outranks an unidentified server on 8000. Never raises.
"""
wanted = [p for p in PROBES if include is None or p.engine in {LocalEngine(e) for e in include}]
targets = []
for spec in wanted:
for port in (ports if ports is not None else spec.default_ports):
targets.append((spec.engine, port))
seen_ports = sorted({p for _, p in targets})
results = {}
deadline = time.monotonic() + budget
def work(port):
if time.monotonic() > deadline:
return port, None
try:
return port, probe_endpoint(f"http://{host}:{port}", timeout=timeout,
transport=transport)
except Exception:
return port, None
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
for port, found in pool.map(work, seen_ports):
if found is not None:
results[port] = found
ordered = []
for spec in wanted:
for port in (ports if ports is not None else spec.default_ports):
found = results.get(port)
if found is not None and found.engine == spec.engine and found not in ordered:
ordered.append(found)
for found in results.values():
if found not in ordered:
ordered.append(found)
return tuple(ordered)
def models(discovery: Discovery, *, timeout: float = DEFAULT_PROBE_TIMEOUT,
transport=None) -> tuple:
"""List model ids served at ``discovery.base_url`` (GET only). Never raises."""
spec = next((p for p in PROBES if p.engine == discovery.engine), None)
if spec is None or not spec.models_path:
return discovery.models
send = transport or default_transport
reply = send("GET", discovery.base_url + spec.models_path, None, timeout)
return _extract_models(_json(reply), spec.models_key) or discovery.models
@@ -1,99 +0,0 @@
"""Resolve a local embedding model, so a local agent stays local end to end.
The gap this closes: `Agent(llm="local", knowledge=[...])` routed chat to the
local server and embeddings to OpenAI, sending both the user's documents and
their queries off the machine with no warning. Ollama already serves
`nomic-embed-text` and friends; nothing asked it.
Data only, per the package contract: this returns a model name and an endpoint.
It never embeds anything.
"""
import os
from typing import Optional, Sequence, Tuple
from .capabilities import LocalEngine
__all__ = ["ENV_EMBED_MODEL", "PREFERRED_EMBED_MODELS",
"select_embedding_model", "local_embedder_config"]
ENV_EMBED_MODEL = "PRAISONAI_LOCAL_EMBED_MODEL"
# Preference order when the caller names nothing. These are the embedders people
# actually pull for Ollama; the first one present wins. Order is quality-biased,
# not alphabetical: nomic-embed-text is the common default and outranks the
# smaller all-minilm.
PREFERRED_EMBED_MODELS = (
"nomic-embed-text",
"mxbai-embed-large",
"bge-m3",
"snowflake-arctic-embed",
"all-minilm",
)
def _is_embedding_model(name: str, capabilities: Sequence[str]) -> bool:
"""True when a listed model can serve embeddings.
Prefers the server's own capability list; falls back to the name for
servers that report no capabilities at all.
"""
if capabilities:
return "embedding" in capabilities
lowered = name.lower()
return "embed" in lowered or lowered.startswith("bge-")
def select_embedding_model(
models: Sequence[str],
model_meta: Sequence[Tuple[str, Tuple[str, ...], Optional[str]]] = (),
) -> Optional[str]:
"""Pick an embedding model from what the server actually serves.
Precedence: PRAISONAI_LOCAL_EMBED_MODEL (validated against the list) >
PREFERRED_EMBED_MODELS order > any remaining embedding-capable model.
Returns None when the server serves no embedding model -- callers must
treat that as "cannot embed locally", never as "fall back to a cloud
default".
"""
caps_by_name = {name: caps for name, caps, _ in (model_meta or ())}
embedders = [m for m in models
if _is_embedding_model(m, caps_by_name.get(m, ()))]
if not embedders:
return None
named = (os.environ.get(ENV_EMBED_MODEL) or "").strip()
if named:
# Accept an exact id or a bare name the server tags (":latest").
for candidate in embedders:
if candidate == named or candidate.split(":", 1)[0] == named:
return candidate
return None
for preferred in PREFERRED_EMBED_MODELS:
for candidate in embedders:
if candidate.split(":", 1)[0] == preferred:
return candidate
return embedders[0]
def local_embedder_config(engine: LocalEngine, base_url: str,
model: str) -> dict:
"""Build the embedder config block for a local engine.
Shaped for the knowledge/memory layer (mem0-style provider + config).
"""
if engine is LocalEngine.OLLAMA:
return {
"provider": "ollama",
"config": {"model": model, "ollama_base_url": base_url},
}
# Everything else speaks OpenAI over HTTP.
trimmed = base_url.rstrip("/")
return {
"provider": "openai",
"config": {
"model": model,
"openai_base_url": trimmed if trimmed.endswith("/v1") else trimmed + "/v1",
},
}
@@ -1,48 +0,0 @@
"""Exceptions raised by praisonaiagents.local.
Deliberately NOT subclasses of praisonaiagents.errors: this package imports
nothing from praisonaiagents, and wrapping is the integration layer's job.
"""
from __future__ import annotations
__all__ = ["LocalError", "NoLocalEngineError", "EngineUnreachableError",
"HostHeaderRejectedError", "ModelNotAvailableError",
"InvalidLocalSpecError"]
class LocalError(Exception):
"""Base class for every error raised by praisonaiagents.local.
Deliberately not a subclass of praisonaiagents.errors: this package imports
nothing from praisonaiagents, and wrapping is the integration layer's job.
"""
class NoLocalEngineError(LocalError):
"""No local model server could be found or was named."""
class EngineUnreachableError(LocalError):
"""An explicitly named local server did not answer."""
def __init__(self, message, base_url="", reason="", source=""):
super().__init__(message)
self.base_url = base_url
self.reason = reason
self.source = source
class HostHeaderRejectedError(EngineUnreachableError):
"""The server returned a bodyless 403 because the Host header was not local."""
class ModelNotAvailableError(LocalError):
def __init__(self, message, model_id="", available=()):
super().__init__(message)
self.model_id = model_id
self.available = tuple(available)
class InvalidLocalSpecError(LocalError, ValueError):
"""The spec string could not be parsed."""
@@ -1,180 +0,0 @@
"""Static catalogue of local-engine behaviours that fail without an error.
Pure data. No network, no I/O, no environment reads. Every SILENT entry is a way
a local server returns HTTP 200 and a wrong result.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Iterable, Mapping
from .capabilities import ApiStyle, Cap, LocalEngine
__all__ = ["Severity", "Quirk", "QuirkNote", "NOTES", "note", "all_notes",
"notes_for", "quirks_for", "silent_quirks"]
class Severity(str, Enum):
SILENT = "silent" # wrong result, no error surface -- the dangerous class
HARD = "hard" # the request fails loudly (4xx/5xx)
COST = "cost" # correct result, unexpected latency or resource cost
class Quirk(str, Enum):
FORMAT_AND_TOOLS_MUTUALLY_DESTRUCTIVE = "format_and_tools_mutually_destructive"
TOOL_SCHEMA_KEYWORDS_STRIPPED = "tool_schema_keywords_stripped"
TOOL_PARAM_TYPE_MUST_BE_BARE_STRING = "tool_param_type_must_be_bare_string"
UNKNOWN_OPTIONS_ACCEPTED_AND_IGNORED = "unknown_options_accepted_and_ignored"
THINK_FIELD_IGNORED_ON_OPENAI_ROUTE = "think_field_ignored_on_openai_route"
THINKING_WITH_TOOLS_YIELDS_EMPTY_TURN = "thinking_with_tools_yields_empty_turn"
NATIVE_ROUTE_NEVER_SETS_TOOL_FINISH_REASON = "native_route_never_sets_tool_finish_reason"
TOOL_CALLS_ARRIVE_WHOLE_NOT_INCREMENTAL = "tool_calls_arrive_whole_not_incremental"
STREAM_ERROR_ENDS_WITHOUT_DONE = "stream_error_ends_without_done"
SAMPLING_DEFAULTS_FORCED_TO_ONE = "sampling_defaults_forced_to_one"
CONTEXT_OPTION_CHANGE_RELOADS_MODEL = "context_option_change_reloads_model"
KEEP_ALIVE_IS_GLOBAL_LAST_WRITER_WINS = "keep_alive_is_global_last_writer_wins"
BODYLESS_403_ON_NON_LOCAL_HOST_HEADER = "bodyless_403_on_non_local_host_header"
TOOLS_REQUIRE_SERVER_FLAGS = "tools_require_server_flags"
NO_JSON_SCHEMA_SUPPORT = "no_json_schema_support"
NO_EMBEDDINGS_ENDPOINT = "no_embeddings_endpoint"
SINGLE_MODEL_PER_PROCESS = "single_model_per_process"
CAPS_ASSUMED_ENGINE_UNIDENTIFIED = "caps_assumed_engine_unidentified"
@dataclass(frozen=True)
class QuirkNote:
quirk: Quirk
engines: tuple
severity: Severity
symptom: str
workaround: str
verified_on: str
api_styles: tuple = ()
requires_caps: frozenset = field(default_factory=frozenset)
_OLLAMA = (LocalEngine.OLLAMA,)
_OPENAI_ROUTES = (ApiStyle.OPENAI_CHAT, ApiStyle.OPENAI_COMPLETIONS)
_V = "ollama 0.33.2 @ 2026-09-03"
NOTES: Mapping[Quirk, QuirkNote] = {n.quirk: n for n in (
QuirkNote(
Quirk.FORMAT_AND_TOOLS_MUTUALLY_DESTRUCTIVE, _OLLAMA, Severity.SILENT,
"JSON format plus tools suppresses the tool call; the model fabricates an answer.",
"Refuse the combination at request build. Raise -- a confident fabrication is worse than an error.",
_V),
QuirkNote(
Quirk.TOOL_SCHEMA_KEYWORDS_STRIPPED, _OLLAMA, Severity.SILENT,
"minLength, format, default, additionalProperties, $ref and oneOf never reach the model.",
"Warn once at tool-declaration time, naming the dropped keys.", _V),
QuirkNote(
Quirk.TOOL_PARAM_TYPE_MUST_BE_BARE_STRING, _OLLAMA, Severity.HARD,
'A tool parameter "type" given as a list 400s the whole request.',
"Collapse union types to a single string before sending.", _V),
QuirkNote(
Quirk.UNKNOWN_OPTIONS_ACCEPTED_AND_IGNORED, _OLLAMA, Severity.SILENT,
"Unrecognised options keys return 200 and change nothing.",
"Validate keys against the known set; warn on unknowns.", _V),
QuirkNote(
Quirk.THINK_FIELD_IGNORED_ON_OPENAI_ROUTE, _OLLAMA, Severity.SILENT,
"think is a native-API field; on /v1 it is dropped and reasoning still emits.",
'Translate to reasoning_effort="none" on the OpenAI route.', _V,
api_styles=_OPENAI_ROUTES),
QuirkNote(
Quirk.THINKING_WITH_TOOLS_YIELDS_EMPTY_TURN, _OLLAMA, Severity.SILENT,
'Empty content, zero tool calls, finish_reason "stop".',
"Treat as retryable; retry once with reasoning disabled.", _V,
requires_caps=frozenset({Cap.THINKING, Cap.TOOLS})),
QuirkNote(
Quirk.NATIVE_ROUTE_NEVER_SETS_TOOL_FINISH_REASON, _OLLAMA, Severity.SILENT,
'/api/chat reports done_reason "stop" even when tool_calls is populated.',
"Inspect the message body, never the finish reason.", _V,
api_styles=(ApiStyle.OLLAMA_NATIVE,)),
QuirkNote(
Quirk.TOOL_CALLS_ARRIVE_WHOLE_NOT_INCREMENTAL, _OLLAMA, Severity.SILENT,
"Streamed tool calls arrive in one chunk, not as argument deltas.",
"The accumulator must tolerate both; never assume fragments.", _V),
QuirkNote(
Quirk.STREAM_ERROR_ENDS_WITHOUT_DONE, _OLLAMA, Severity.SILENT,
"A mid-stream failure yields an empty delta and no [DONE] sentinel.",
"Set a read timeout; check every NDJSON line for an error key.", _V,
api_styles=_OPENAI_ROUTES),
QuirkNote(
Quirk.SAMPLING_DEFAULTS_FORCED_TO_ONE, _OLLAMA, Severity.SILENT,
"Omitted temperature/top_p become 1.0, not the model's own defaults.",
"Send explicit values, or use the native route.", _V,
api_styles=_OPENAI_ROUTES),
QuirkNote(
Quirk.CONTEXT_OPTION_CHANGE_RELOADS_MODEL, _OLLAMA, Severity.COST,
"Changing num_ctx/num_batch/num_gpu forces a full model reload.",
"Freeze runner options per target; never vary them per call.", _V),
QuirkNote(
Quirk.KEEP_ALIVE_IS_GLOBAL_LAST_WRITER_WINS, _OLLAMA, Severity.SILENT,
"keep_alive is per-model and process-wide, not per-request.",
"Never send keep_alive=0 from a library path; it evicts another caller's model.", _V),
QuirkNote(
Quirk.BODYLESS_403_ON_NON_LOCAL_HOST_HEADER, _OLLAMA, Severity.HARD,
"A Host header that is not localhost or an IP gets HTTP 403 with a zero-byte body.",
"Send Host as localhost or an IP; set OLLAMA_HOST on the server to allow the origin.", _V),
QuirkNote(
Quirk.TOOLS_REQUIRE_SERVER_FLAGS,
(LocalEngine.VLLM, LocalEngine.LLAMA_CPP, LocalEngine.LLAMAFILE), Severity.SILENT,
"Tool calls come back as assistant text unless the server was launched with the right flags.",
"vLLM needs BOTH --enable-auto-tool-choice AND --tool-call-parser; llama-server needs --jinja.",
"unverified: vendor docs"),
QuirkNote(
Quirk.NO_JSON_SCHEMA_SUPPORT, (LocalEngine.MLX_LM,), Severity.SILENT,
"response_format json_schema is unimplemented.",
"Drop the capability; fall back to prompt-and-parse.", "unverified: source read"),
QuirkNote(
Quirk.NO_EMBEDDINGS_ENDPOINT, (LocalEngine.MLX_LM,), Severity.HARD,
"/v1/embeddings is absent.",
"Resolve embeddings to a different target, and say so.", "unverified: source read"),
QuirkNote(
Quirk.SINGLE_MODEL_PER_PROCESS,
(LocalEngine.LLAMA_CPP, LocalEngine.MLX_LM), Severity.SILENT,
"One model per process; the request's model field is ignored.",
"Asking for model B returns model A's answer. Pin the model at launch.",
"unverified: vendor docs"),
QuirkNote(
Quirk.CAPS_ASSUMED_ENGINE_UNIDENTIFIED, (LocalEngine.UNKNOWN,), Severity.SILENT,
"The engine could not be identified, so every capability is a static guess.",
"Treat capabilities as unproven; keep repair paths enabled.", "n/a"),
)}
def note(quirk) -> QuirkNote:
"""Return the catalogue entry for one quirk."""
return NOTES[Quirk(quirk)]
def all_notes() -> tuple:
"""Every catalogue entry, ordered by (first engine, quirk value)."""
return tuple(sorted(NOTES.values(), key=lambda n: (n.engines[0].value, n.quirk.value)))
def quirks_for(engine, api_style, *, caps=frozenset()) -> frozenset:
"""Quirks that apply to this engine, route and capability set."""
engine = LocalEngine(engine)
style = ApiStyle(api_style)
caps = frozenset(caps)
return frozenset(
n.quirk for n in NOTES.values()
if engine in n.engines
and (not n.api_styles or style in n.api_styles)
and n.requires_caps <= caps
)
def notes_for(quirks: Iterable) -> tuple:
"""Catalogue entries for ``quirks``, ordered by severity then value."""
order = {Severity.SILENT: 0, Severity.HARD: 1, Severity.COST: 2}
got = [NOTES[Quirk(q)] for q in quirks]
return tuple(sorted(got, key=lambda n: (order[n.severity], n.quirk.value)))
def silent_quirks(quirks: Iterable) -> frozenset:
"""Filter to Severity.SILENT -- the set a caller must actively defend against."""
return frozenset(q for q in (Quirk(x) for x in quirks) if NOTES[q].severity is Severity.SILENT)
@@ -1,322 +0,0 @@
"""Resolution precedence and the process-local cache.
Precedence: spec > PRAISONAI_LOCAL_BASE_URL > OLLAMA_HOST >
OPENAI_BASE_URL/OPENAI_API_BASE (only when local) > probe-table scan.
The first three are authoritative: if one names a server that does not answer
this raises, rather than silently talking to a different one. Silently using a
server the caller did not name is the failure mode this package exists to
prevent.
"""
from __future__ import annotations
import ipaddress
import os
import threading
import time
import urllib.parse
from .capabilities import Cap, Evidence, LocalEngine, default_caps
from .capabilities import (parse_llama_cpp_props, parse_lm_studio_models,
parse_ollama_capabilities)
from .discover import (DEFAULT_PROBE_TIMEOUT, DEFAULT_TOTAL_BUDGET, PROBES,
default_transport, discover, probe_endpoint)
from .errors import (EngineUnreachableError, HostHeaderRejectedError,
InvalidLocalSpecError, LocalError, ModelNotAvailableError,
NoLocalEngineError)
from .target import (ENV_BASE_URL, ENV_ENGINE, ENV_MODEL, build_target,
parse_ollama_host, parse_spec, select_model)
ENV_TTL = "PRAISONAI_LOCAL_TTL"
ENV_NEG_TTL = "PRAISONAI_LOCAL_NEG_TTL"
ENV_TIMEOUT = "PRAISONAI_LOCAL_TIMEOUT"
__all__ = ["resolve", "resolve_or_none", "clear_cache", "cache_info",
"ENV_TTL", "ENV_NEG_TTL", "ENV_TIMEOUT"]
def _is_local_host(url: str) -> bool:
try:
host = urllib.parse.urlparse(url).hostname or ""
except ValueError:
return False
if host in ("localhost", "") or host.endswith(".localhost") or host.endswith(".local"):
return True
try:
addr = ipaddress.ip_address(host)
except ValueError:
return False
return addr.is_loopback or addr.is_private or addr.is_link_local
def _model_capabilities(discovery: Discovery, model_id, *, timeout, transport):
"""One request, at most, for per-model capabilities. Degrades to TABLE."""
spec = next((p for p in PROBES if p.engine == discovery.engine), None)
if spec is None or spec.model_caps is None or not model_id:
return default_caps(discovery.engine), Evidence.TABLE, ()
send = transport or default_transport
rule = spec.model_caps
import json as _json
body = _json.dumps({"model": model_id}).encode() if rule.method == "POST" else None
reply = send(rule.method, discovery.base_url + rule.path, body, timeout)
if reply.status != 200:
return default_caps(discovery.engine), Evidence.TABLE, ()
try:
payload = _json.loads(reply.body.decode("utf-8", errors="replace"))
except ValueError:
return default_caps(discovery.engine), Evidence.TABLE, ()
if not isinstance(payload, dict):
return default_caps(discovery.engine), Evidence.TABLE, ()
if discovery.engine == LocalEngine.OLLAMA:
caps = parse_ollama_capabilities(payload)
elif discovery.engine == LocalEngine.LLAMA_CPP:
caps = parse_llama_cpp_props(payload)
elif discovery.engine == LocalEngine.LM_STUDIO:
caps = parse_lm_studio_models(payload, model_id)
else:
caps = frozenset()
if not caps:
return default_caps(discovery.engine), Evidence.TABLE, ()
extra = []
info = payload.get("model_info") or {}
if isinstance(info, dict):
for key, value in info.items():
if key.endswith(".context_length"):
extra.append(("context_length", str(value)))
elif key.endswith(".embedding_length"):
extra.append(("embedding_length", str(value)))
details = payload.get("details") or {}
if isinstance(details, dict):
for k in ("family", "parameter_size", "quantization_level"):
if details.get(k):
extra.append((k, str(details[k])))
# Union with the engine floor so streaming/embeddings survive.
return frozenset(caps) | default_caps(discovery.engine), Evidence.SERVER, tuple(extra)
_CACHE_LOCK = threading.Lock()
_TARGETS = {}
_NEGATIVES = {}
_STATS = {"hits": 0, "misses": 0}
def _float_env(name, default):
try:
return float(os.environ.get(name, default))
except (TypeError, ValueError):
return float(default)
def _cache_key(spec, timeout):
return "\x00".join((
str(spec) if spec is not None else "*",
os.environ.get(ENV_BASE_URL, ""), os.environ.get(ENV_ENGINE, ""),
os.environ.get(ENV_MODEL, ""), os.environ.get("OLLAMA_HOST", ""),
os.environ.get("OPENAI_BASE_URL", ""), os.environ.get("OPENAI_API_BASE", ""),
f"{timeout:.3f}",
))
def clear_cache() -> None:
"""Drop every cached target and negative result (test hook)."""
with _CACHE_LOCK:
_TARGETS.clear()
_NEGATIVES.clear()
_STATS.update(hits=0, misses=0)
def cache_info() -> dict:
with _CACHE_LOCK:
return {"targets": len(_TARGETS), "negatives": len(_NEGATIVES), **_STATS}
_ERRORS = {c.__name__: c for c in (
NoLocalEngineError, EngineUnreachableError, HostHeaderRejectedError,
ModelNotAvailableError, InvalidLocalSpecError, LocalError)}
def resolve(spec=None, *, timeout=None, refresh=False, transport=None) -> LocalTarget:
"""Resolve the local model server and model this machine should use.
Precedence: spec > PRAISONAI_LOCAL_BASE_URL > OLLAMA_HOST >
OPENAI_BASE_URL/OPENAI_API_BASE (only when local) > probe-table scan.
The first three are authoritative: if one names a server that does not
answer, this raises rather than silently talking to a different one.
"""
total = _float_env(ENV_TIMEOUT, 1.5) if timeout is None else float(timeout)
total = min(max(total, 0.05), 30.0)
per_request = min(DEFAULT_PROBE_TIMEOUT, total / 3)
key = _cache_key(spec, total)
pid = os.getpid()
if not refresh:
with _CACHE_LOCK:
hit = _TARGETS.get(key)
if hit and hit[0] > time.monotonic() and hit[1] == pid:
_STATS["hits"] += 1
return hit[2]
neg = _NEGATIVES.get(key)
if neg and neg[0] > time.monotonic() and neg[1] == pid:
_STATS["hits"] += 1
# A fresh instance: a stored exception carries a dead traceback.
raise _ERRORS.get(neg[2], LocalError)(neg[3])
with _CACHE_LOCK:
_STATS["misses"] += 1
try:
target = _resolve_uncached(spec, per_request, total, transport)
except LocalError as exc:
ttl = _float_env(ENV_NEG_TTL, 5.0)
if ttl > 0:
with _CACHE_LOCK:
_NEGATIVES[key] = (time.monotonic() + ttl, pid, type(exc).__name__, str(exc))
raise
ttl = _float_env(ENV_TTL, 30.0)
if ttl > 0:
with _CACHE_LOCK:
_TARGETS[key] = (time.monotonic() + ttl, pid, target)
return target
def _with_url_evidence(discovery, evidence):
"""Record where the base URL came from; the probe only proves it answered."""
import dataclasses
return dataclasses.replace(discovery, evidence=evidence) if evidence else discovery
def _require_model(discovery, model_id):
"""Reject a model-less resolution or a named model the server does not serve."""
available = tuple(discovery.models)
where = f"{discovery.engine.value} at {discovery.base_url}"
if model_id is None:
raise NoLocalEngineError(
f"{where} answered but lists no usable model to chat with. Pull one "
f"(e.g. `ollama pull qwen3:0.6b`) or name it explicitly.")
if available and model_id not in available:
raise ModelNotAvailableError(
f"Model {model_id!r} is not served by {where}. "
f"Available: {', '.join(available)}.",
model_id=model_id, available=available)
def _finish(discovery, model_id, model_evidence, per_request, transport, url_evidence=None):
if model_id is None:
model_id = select_model(discovery, transport=transport, timeout=per_request)
model_evidence = Evidence.SERVER
# An explicitly named model must be served, and a reachable server that
# lists none must not yield a model-less target -- Agent would otherwise
# substitute its own default (e.g. gpt-4o-mini) and send it to the local
# endpoint, failing only on the first completion instead of at resolution.
_require_model(discovery, model_id)
caps, caps_evidence, extra = _model_capabilities(
discovery, model_id, timeout=per_request, transport=transport)
target = build_target(discovery, model_id, caps=caps, caps_evidence=caps_evidence,
model_evidence=model_evidence, extra=extra)
if url_evidence is not None:
import dataclasses
ev = dict(target.evidence)
ev["base_url"] = url_evidence
target = dataclasses.replace(target, evidence=tuple(sorted(ev.items())))
return target
def _authoritative(url, expect, source, per_request, transport, model_id, model_evidence):
found = probe_endpoint(url, expect=expect, timeout=per_request, transport=transport)
if found is None:
hint = ""
if source == "OLLAMA_HOST" and url.rsplit(":", 1)[-1] in ("80", "443"):
hint = (" Note that OLLAMA_HOST with an explicit http:// scheme and no "
"port means port 80, not 11434.")
raise EngineUnreachableError(
f"Local runtime at {url} did not answer (refused). It was named "
f"explicitly by {source}, so no other port was probed.{hint}",
base_url=url, reason="refused", source=source)
if found.blocked:
raise HostHeaderRejectedError(
f"Local runtime at {url} returned HTTP 403 with an empty body. Ollama "
f"rejects any request whose Host header is not localhost or an IP "
f"address; set OLLAMA_HOST on the server to allow this origin.",
base_url=url, reason="http_403", source=source)
return found, model_id, model_evidence
def _resolve_uncached(spec, per_request, total, transport):
engine, spec_url, spec_model = parse_spec(spec)
model_evidence = Evidence.SPEC if spec_model else Evidence.SERVER
if spec_url:
found, m, ev = _authoritative(spec_url, engine, "spec", per_request,
transport, spec_model, model_evidence)
return _finish(found, m, ev, per_request, transport, Evidence.SPEC)
env_url = os.environ.get(ENV_BASE_URL)
if env_url:
env_engine = os.environ.get(ENV_ENGINE)
expect = engine
if env_engine:
try:
expect = LocalEngine(env_engine.lower())
except ValueError:
raise InvalidLocalSpecError(
f"{ENV_ENGINE}={env_engine!r} is not a known local engine. "
f"Expected one of: {', '.join(e.value for e in LocalEngine)}.")
found, m, ev = _authoritative(env_url.rstrip("/"), expect, ENV_BASE_URL,
per_request, transport, spec_model, model_evidence)
return _finish(found, m, ev, per_request, transport, Evidence.ENV)
ollama_host = os.environ.get("OLLAMA_HOST")
if ollama_host:
url = parse_ollama_host(ollama_host)
if url:
found, m, ev = _authoritative(url, engine or LocalEngine.OLLAMA, "OLLAMA_HOST",
per_request, transport, spec_model, model_evidence)
return _finish(found, m, ev, per_request, transport, Evidence.ENV)
skipped = ""
for var in ("OPENAI_BASE_URL", "OPENAI_API_BASE"):
url = os.environ.get(var)
if not url:
continue
if not _is_local_host(url):
skipped = f" Ignored {var}={url!r}: not a local address."
continue
found = probe_endpoint(url.rstrip("/"), expect=engine, timeout=per_request,
transport=transport)
if found is not None and not found.blocked:
return _finish(found, spec_model, model_evidence, per_request, transport)
include = [engine] if engine else None
results = discover(include=include, timeout=per_request, budget=total,
transport=transport)
usable = [d for d in results if not d.blocked]
if usable:
return _finish(usable[0], spec_model, model_evidence, per_request, transport)
if results:
blocked = results[0]
raise HostHeaderRejectedError(
f"Local runtime at {blocked.base_url} returned HTTP 403 with an empty "
f"body. Ollama rejects any request whose Host header is not localhost "
f"or an IP address; set OLLAMA_HOST on the server to allow this origin.",
base_url=blocked.base_url, reason="http_403", source="scan")
ports = {}
for p in PROBES:
for port in p.default_ports:
ports.setdefault(port, []).append(p.engine.value)
clause = ", ".join(f"{port} ({', '.join(names)})" for port, names in sorted(ports.items()))
raise NoLocalEngineError(
f"No local model runtime found. Probed {DEFAULT_TOTAL_BUDGET and '127.0.0.1'} "
f"ports {clause} in {total:.1f}s; nothing answered. Start one "
f"(e.g. `ollama serve`, then `ollama pull qwen3:0.6b`) or set "
f"{ENV_BASE_URL} to its address.{skipped}")
def resolve_or_none(spec=None, **kwargs):
"""resolve() but returns None instead of raising LocalError."""
try:
return resolve(spec, **kwargs)
except LocalError:
return None
@@ -1,233 +0,0 @@
"""The LocalTarget value type and the pure helpers that build one.
Everything here is a pure function of its arguments: no network, no cache, no
environment reads except the single documented model override. Resolution
precedence lives in resolve.py.
"""
from __future__ import annotations
import os
import threading
import time
import urllib.parse
from dataclasses import dataclass
from typing import Optional
from .capabilities import (ApiStyle, Cap, Evidence, LocalEngine, default_caps,
parse_llama_cpp_props, parse_lm_studio_models,
parse_ollama_capabilities)
from .discover import (DEFAULT_PROBE_TIMEOUT, DEFAULT_TOTAL_BUDGET, PROBES,
Discovery, default_transport, discover, probe_endpoint)
from .errors import InvalidLocalSpecError, ModelNotAvailableError
from .quirktable import Quirk, quirks_for
__all__ = ["LocalTarget", "build_target", "litellm_model_for", "parse_spec",
"parse_ollama_host", "select_model", "DEFAULT_API_KEY",
"ENV_BASE_URL", "ENV_ENGINE", "ENV_MODEL"]
DEFAULT_API_KEY = "local"
ENV_BASE_URL = "PRAISONAI_LOCAL_BASE_URL"
ENV_ENGINE = "PRAISONAI_LOCAL_ENGINE"
ENV_MODEL = "PRAISONAI_LOCAL_MODEL"
@dataclass(frozen=True)
class LocalTarget:
engine: LocalEngine
base_url: str
openai_base_url: str
api_style: ApiStyle
model_id: Optional[str]
caps: frozenset
quirks: frozenset
litellm_model: Optional[str]
api_key: str
engine_version: Optional[str]
# Tuples of pairs, not dicts: a dict field would make the generated __hash__
# raise TypeError at runtime, and this type is meant to be hashable.
evidence: tuple
extra: tuple
probed_at: float
def supports(self, cap) -> bool:
return Cap(cap) in self.caps
def has_quirk(self, quirk) -> bool:
return Quirk(quirk) in self.quirks
def evidence_for(self, field: str) -> Evidence:
return dict(self.evidence).get(field, Evidence.UNKNOWN)
def get_extra(self, key: str, default=None):
return dict(self.extra).get(key, default)
def as_dict(self) -> dict:
return {
"engine": self.engine.value,
"base_url": self.base_url,
"openai_base_url": self.openai_base_url,
"api_style": self.api_style.value,
"model_id": self.model_id,
"caps": sorted(c.value for c in self.caps),
"quirks": sorted(q.value for q in self.quirks),
"litellm_model": self.litellm_model,
"engine_version": self.engine_version,
"evidence": {k: v.value for k, v in self.evidence},
"extra": dict(self.extra),
}
_LITELLM_PREFIX = {
LocalEngine.OLLAMA: "ollama",
LocalEngine.LM_STUDIO: "lm_studio",
LocalEngine.VLLM: "hosted_vllm",
}
def litellm_model_for(engine, model_id) -> Optional[str]:
"""Format a litellm model string for this engine. Pure.
OLLAMA maps to "ollama/", never "ollama_chat/": llm.py's _detect_provider
resolves ollama_chat to "openai" and would silently lose OllamaAdapter.
"""
if not model_id:
return None
return f"{_LITELLM_PREFIX.get(LocalEngine(engine), 'openai')}/{model_id}"
def parse_ollama_host(value: str) -> Optional[str]:
"""Apply Ollama's own OLLAMA_HOST rules and return a base_url.
The asymmetry that catches people: a bare host defaults to port 11434, but an
explicit http:// with no port means port 80 -- not 11434.
"""
if not value or not value.strip():
return None
raw = value.strip()
if "://" in raw:
parsed = urllib.parse.urlparse(raw)
if not parsed.hostname:
return None
port = parsed.port or (443 if parsed.scheme == "https" else 80)
# Keep any path prefix. A reverse proxy commonly mounts the server under
# one (http://gateway:8000/ollama); rebuilding from host+port alone
# silently rewrote the URL and sent every request to the proxy root.
path = parsed.path.rstrip("/")
return f"{parsed.scheme}://{parsed.hostname}:{port}{path}"
if raw.isdigit():
return f"http://127.0.0.1:{raw}"
if raw.startswith(":") and raw[1:].isdigit():
return f"http://127.0.0.1:{raw[1:]}"
if raw.count(":") > 1 and not raw.startswith("["):
return None # unbracketed IPv6
host, _, port = raw.partition(":")
return f"http://{host}:{port or 11434}"
def parse_spec(spec):
"""Split a spec into (engine, base_url, model_id); any element may be None."""
if spec is None:
return None, None, None
text = str(spec).strip()
if text.lower() in ("", "local", "auto"):
return None, None, None
if "://" in text:
url, _, fragment = text.partition("#")
return None, url.rstrip("/"), (fragment or None)
if "/" in text:
head, _, tail = text.partition("/")
try:
return LocalEngine(head.lower()), None, (tail or None)
except ValueError:
pass
raise InvalidLocalSpecError(
f"Cannot parse local spec {spec!r}. Expected None, 'local', "
f"'<engine>/<model>', a base URL, or '<url>#<model>'."
)
def select_model(discovery: Discovery, *, transport=None,
timeout: float = DEFAULT_PROBE_TIMEOUT) -> Optional[str]:
"""Pick a model deterministically when the caller named none.
Never guesses a model that is not in the server's list.
"""
named = os.environ.get(ENV_MODEL)
available = tuple(discovery.models)
if named:
if available and named not in available:
raise ModelNotAvailableError(
f"Model {named!r} is not served by {discovery.engine.value} at "
f"{discovery.base_url}. Available: {', '.join(available) or '(none)'}.",
model_id=named, available=available)
return named
if not available:
return None
meta = {m[0]: (m[1], m[2]) for m in (discovery.model_meta or ())}
if len(available) == 1 and not meta:
return available[0]
def rank(model):
caps, modified = meta.get(model, ((), ""))
# An embedding-only model cannot hold a conversation. Picking one because
# it sorted first is the kind of silent nonsense this package exists to
# prevent, so it is ranked last rather than merely unpreferred.
embed_only = bool(caps) and "embedding" in caps and "completion" not in caps
return (embed_only, "tools" not in caps, _negate_iso(modified), model)
chat_capable = [m for m in available if not rank(m)[0]]
pool = chat_capable or list(available)
return sorted(pool, key=rank)[0]
def _negate_iso(value: str):
"""Sort ISO-8601 timestamps newest-first inside an ascending sort key."""
return tuple(-ord(c) for c in value) if value else ()
def _openai_base(base_url: str) -> str:
"""Return the OpenAI-compatible root for a base URL, without doubling /v1.
Users routinely paste the URL their server prints, which for LM Studio and
vLLM already ends in /v1. Appending unconditionally produced /v1/v1.
"""
trimmed = base_url.rstrip("/")
return trimmed if trimmed.endswith("/v1") else trimmed + "/v1"
def build_target(discovery: Discovery, model_id, *, caps=None,
caps_evidence=Evidence.TABLE, model_evidence=Evidence.SERVER,
extra=()) -> LocalTarget:
"""Assemble a LocalTarget from a Discovery. Pure: no network, no cache."""
engine = discovery.engine
resolved_caps = frozenset(caps) if caps is not None else default_caps(engine)
quirks = quirks_for(engine, discovery.api_style, caps=resolved_caps)
if Cap.TOOLS in resolved_caps and Quirk.TOOL_CALLS_ARRIVE_WHOLE_NOT_INCREMENTAL not in quirks:
resolved_caps = resolved_caps | {Cap.STREAMING_WITH_TOOLS}
evidence = {
"engine": discovery.evidence,
"base_url": discovery.evidence,
"api_style": Evidence.TABLE,
"caps": caps_evidence,
"quirks": Evidence.TABLE,
"model_id": model_evidence if model_id else Evidence.UNKNOWN,
"engine_version": Evidence.SERVER if discovery.engine_version else Evidence.UNKNOWN,
}
return LocalTarget(
engine=engine,
base_url=discovery.base_url,
openai_base_url=_openai_base(discovery.base_url),
api_style=discovery.api_style,
model_id=model_id,
caps=resolved_caps,
quirks=quirks,
litellm_model=litellm_model_for(engine, model_id),
api_key=DEFAULT_API_KEY,
engine_version=discovery.engine_version,
evidence=tuple(sorted(evidence.items())),
extra=tuple(sorted(extra)),
probed_at=time.time(),
)
@@ -46,7 +46,6 @@ __all__ = [
"detect_root_uri",
"probe",
"DEFAULT_SERVERS",
"resolve_servers",
]
@@ -65,7 +64,7 @@ def __getattr__(name: str):
return locals()[name]
if name in ("LSPConfig", "detect_language", "detect_root_uri", "probe",
"DEFAULT_SERVERS", "resolve_servers"):
"DEFAULT_SERVERS"):
from . import config
return getattr(config, name)
@@ -50,8 +50,7 @@ class LSPClient:
command: Optional[str] = None,
args: Optional[List[str]] = None,
root_uri: Optional[str] = None,
workspace_file: Optional[str] = None,
servers: Optional[Dict[str, Dict]] = None
workspace_file: Optional[str] = None
):
"""
Initialize the LSP client.
@@ -65,18 +64,12 @@ class LSPClient:
from the nearest root marker (e.g. ``pyproject.toml``,
``go.mod``) when ``root_uri`` is not given, so multi-root and
monorepo setups initialise against the correct project root.
servers: Optional user-supplied server registry (from project/global
config) deep-merged over the built-in defaults, so a language
that ships no default — or one whose default should be
overridden — resolves from config instead of raising.
"""
self._servers = servers
self.config = LSPConfig(
language=language,
command=command,
args=args or [],
root_uri=root_uri,
servers=servers
root_uri=root_uri
)
self._workspace_file = workspace_file
@@ -114,9 +107,7 @@ class LSPClient:
# user-supplied custom server), and only surface the registry's install
# hint when it matches the default server for the language.
if not shutil.which(self.config.command):
_, default_command, install_hint = probe(
self.config.language, self._servers
)
_, default_command, install_hint = probe(self.config.language)
hint = (
f"; install with `{install_hint}`"
if install_hint and self.config.command == default_command
@@ -147,9 +138,7 @@ class LSPClient:
# root), falling back to the CWD only when neither is available.
root_uri = self.config.root_uri
if root_uri is None and self._workspace_file is not None:
root_uri = detect_root_uri(
self._workspace_file, self.config.language, self._servers
)
root_uri = detect_root_uri(self._workspace_file, self.config.language)
if root_uri is None:
root_uri = path_to_uri(os.getcwd())
@@ -56,54 +56,18 @@ DEFAULT_SERVERS = {
}
def resolve_servers(user_servers: Optional[Dict[str, Dict]] = None) -> Dict[str, Dict]:
"""Return the effective server registry: ``DEFAULT_SERVERS`` merged with
user-supplied entries.
User entries override built-ins per language key and add new languages, so
shipped defaults keep working and a project can register a sixth language or
pin a different server without editing package source. With no user config
the result is byte-for-byte ``DEFAULT_SERVERS``. The merge is shallow at the
per-language level (a user language entry replaces the built-in entry for
that key) and keeps the helper lazy and dependency-free.
Command/args are treated as a unit: when a user swaps ``command`` for a
different executable without supplying ``args``, the built-in ``args`` are
dropped rather than inherited, so arguments belonging to the replaced server
(e.g. ``--stdio`` for ``typescript-language-server``) are never passed to an
unrelated replacement binary.
"""
if not user_servers:
return DEFAULT_SERVERS
merged = dict(DEFAULT_SERVERS)
for language, server in user_servers.items():
if not isinstance(server, dict):
continue
base = dict(merged.get(language, {}))
overrides_command = (
"command" in server and server["command"] != base.get("command")
)
base.update(server)
if overrides_command and "args" not in server:
base["args"] = []
merged[language] = base
return merged
def _extension_map(servers: Optional[Dict[str, Dict]] = None) -> Dict[str, str]:
def _extension_map() -> Dict[str, str]:
"""Return a ``{extension: language}`` map derived from the registry."""
mapping: Dict[str, str] = {}
for language, server in resolve_servers(servers).items():
for language, server in DEFAULT_SERVERS.items():
for ext in server.get("extensions", []):
mapping.setdefault(ext.lower(), language)
return mapping
def detect_language(
file_path: str, servers: Optional[Dict[str, Dict]] = None
) -> Optional[str]:
def detect_language(file_path: str) -> Optional[str]:
"""Resolve the LSP language id for ``file_path`` by extension, else ``None``."""
return _extension_map(servers).get(os.path.splitext(file_path)[1].lower())
return _extension_map().get(os.path.splitext(file_path)[1].lower())
def path_to_uri(path: str) -> str:
@@ -116,11 +80,7 @@ def path_to_uri(path: str) -> str:
return Path(os.path.abspath(path)).as_uri()
def detect_root_uri(
file_path: str,
language: Optional[str] = None,
servers: Optional[Dict[str, Dict]] = None,
) -> Optional[str]:
def detect_root_uri(file_path: str, language: Optional[str] = None) -> Optional[str]:
"""Discover the workspace root for ``file_path`` from the nearest root marker.
Walks up from the file's directory looking for a language-appropriate root
@@ -128,9 +88,8 @@ def detect_root_uri(
``.git`` …). Returns a ``file://`` URI for the nearest matching directory,
or ``None`` when no marker is found so the caller can fall back to the CWD.
"""
registry = resolve_servers(servers)
language = language or detect_language(file_path, servers)
server = registry.get(language) if language else None
language = language or detect_language(file_path)
server = DEFAULT_SERVERS.get(language) if language else None
markers = server.get("root_markers", []) if server else [".git"]
start = os.path.dirname(os.path.abspath(file_path))
current = start
@@ -144,9 +103,7 @@ def detect_root_uri(
current = parent
def probe(
language: str, servers: Optional[Dict[str, Dict]] = None
) -> Tuple[bool, Optional[str], Optional[str]]:
def probe(language: str) -> Tuple[bool, Optional[str], Optional[str]]:
"""Check whether the language server for ``language`` is on ``PATH``.
Returns ``(available, command, install_hint)``. ``available`` is ``True``
@@ -154,7 +111,7 @@ def probe(
``command`` and ``install_hint`` describe what is missing and how to install
it so the degradation can be surfaced instead of silently swallowed.
"""
server = resolve_servers(servers).get(language)
server = DEFAULT_SERVERS.get(language)
if not server:
return False, None, None
command = server["command"]
@@ -172,23 +129,13 @@ class LSPConfig:
root_uri: Optional[str] = None
initialization_options: Dict = field(default_factory=dict)
timeout: float = 30.0
servers: Optional[Dict[str, Dict]] = None
def __post_init__(self):
# Use default server if not specified, consulting the effective registry
# (built-ins deep-merged with any user-supplied ``servers``) so a
# configured sixth language resolves instead of raising.
# Use default server if not specified
if self.command is None:
registry = resolve_servers(self.servers)
if self.language in registry:
server = registry[self.language]
if self.language in DEFAULT_SERVERS:
server = DEFAULT_SERVERS[self.language]
self.command = server["command"]
self.args = list(server.get("args", []))
if not self.initialization_options and server.get(
"initialization_options"
):
self.initialization_options = dict(
server["initialization_options"]
)
self.args = list(server["args"])
else:
raise ValueError(f"No default server for language: {self.language}")
@@ -29,7 +29,7 @@ Memory Providers:
- RulesManager: Rules/instructions file management
- AutoMemory: Automatic memory extraction from conversations
- WorkflowManager: Multi-step workflow execution
- HooksManager: Standalone, opt-in pre/post operation hooks (call .execute() yourself; not auto-wired into the agent pipeline)
- HooksManager: Pre/post operation hooks
- DocsManager: Documentation context management
- MCPConfigManager: MCP server configuration management
"""
@@ -424,35 +424,6 @@ class MongoDBMemoryAdapter:
self.pymongo = pymongo
config = kwargs.get("config", {})
# Resolve the embedding model once, at construction. Previously
# _get_embedding hardcoded "text-embedding-3-small" and __init__ stored
# nothing about embeddings, so an agent configured entirely against a
# local runtime still embedded against OpenAI with no way to say
# otherwise. Accepts both the flat key and the `embedder` block used
# elsewhere in the memory layer.
#
# The `embedder` block carries the provider separately from the model
# (e.g. {"provider": "ollama", "config": {"model": "mxbai-embed-large"}}),
# so recombine them into the litellm-style "<provider>/<model>" litellm
# needs to route the call. Dropping the provider would send a bare local
# model name to the OpenAI default endpoint, the embedding would fail,
# and _get_embedding swallows that into "no vector" — documents stored
# without embeddings and vector search silently degraded to text search.
# Mirrors knowledge/adapters/mongodb_adapter._init_embedding_model.
_embedder = config.get("embedder") or {}
_embedder_model = (_embedder.get("config") or {}).get("model")
if _embedder_model:
_provider = _embedder.get("provider")
if _provider and _provider != "openai":
_embedder_model = f"{_provider}/{_embedder_model}"
self.embedding_model = (
kwargs.get("embedding_model")
or config.get("embedding_model")
or _embedder_model
or "text-embedding-3-small"
)
connection_string = config.get("connection_string", "mongodb://localhost:27017/")
database_name = config.get("database", "praisonai")
self.use_vector_search = config.get("use_vector_search", False)
@@ -613,7 +584,7 @@ class MongoDBMemoryAdapter:
"""Get embedding for text."""
try:
from praisonaiagents.embedding import embedding
result = embedding(text, model=self.embedding_model)
result = embedding(text, model="text-embedding-3-small")
return result.embeddings[0] if result.embeddings else None
except Exception:
return None
@@ -4,17 +4,6 @@ Cascade Hooks System for PraisonAI Agents.
Provides event hooks similar to Windsurf's Cascade Hooks,
allowing custom actions before/after agent operations.
.. note::
``HooksManager`` is a **standalone, opt-in utility**: it is NOT auto-wired
into the agent/tool execution pipeline. You must construct it and call
:meth:`HooksManager.execute` yourself at the point where you want a hook to
run (e.g. in a custom tool or wrapper). For hooks that fire automatically
around agent tool/LLM calls, use the live ``praisonaiagents.hooks`` package
instead: register handlers on a ``HookRegistry`` (e.g. via
``@registry.on(HookEvent.BEFORE_TOOL)``) and pass that registry to the
``Agent`` through its ``hooks`` argument (the ``Agent`` builds the
``HookRunner`` internally).
Features:
- Pre/post hooks for read, write, command, prompt operations
- JSON configuration (.praisonai/hooks.json)
@@ -98,12 +87,6 @@ class HooksManager:
Hooks are configured in .praisonai/hooks.json and can execute
scripts before/after agent operations.
This is a standalone, opt-in utility. It does not automatically
intercept agent file/command/prompt operations; the caller must
invoke :meth:`execute` at the desired point. For hooks that fire
automatically around agent tool/LLM calls, use the
``praisonaiagents.hooks`` package instead.
Example:
```python
hooks = HooksManager(workspace_path="/path/to/project")
@@ -19,7 +19,6 @@ from .stores import (
ImprovementStore,
LearnEntry,
)
from ...llm.model_providers import default_auxiliary_model
class LearnManager:
@@ -556,7 +555,7 @@ Return empty arrays if nothing is found for a category."""
from ...llm import LLM
import json
model = default_auxiliary_model(llm)
model = llm or "gpt-4o-mini"
llm_instance = LLM(model=model)
response = llm_instance.get_response(
prompt=extraction_prompt,
@@ -592,7 +591,7 @@ Return empty arrays if nothing is found for a category."""
for improvement in extracted.get("improvements", []):
if improvement and "improvements" in self._stores:
self.capture_improvement(improvement, area="auto_extraction")
self.capture_improvement(improvement, source="auto_extraction")
stored["improvements"] += 1
return {
@@ -624,7 +623,7 @@ Return empty arrays if nothing is found for a category."""
Dictionary with extracted learnings
"""
if not messages:
return {"persona": [], "insights": [], "patterns": [], "improvements": [], "stored": {}}
return {"persona": [], "insights": [], "patterns": [], "stored": {}}
# Build conversation text
conversation_text = "\n".join([
@@ -641,14 +640,12 @@ Extract the following (if present):
1. USER PREFERENCES: Things the user likes, dislikes, prefers, or their style
2. INSIGHTS: Observations about the user's domain, work, or context
3. PATTERNS: Recurring behaviors or request patterns
4. IMPROVEMENTS: Concrete proposals to improve future responses
Return JSON:
{{
"persona": ["preference 1", "preference 2"],
"insights": ["insight 1", "insight 2"],
"patterns": ["pattern 1", "pattern 2"],
"improvements": ["improvement 1", "improvement 2"]
"patterns": ["pattern 1", "pattern 2"]
}}
Only include items that are clearly evident from the conversation.
@@ -660,7 +657,7 @@ Return empty arrays if nothing is found for a category."""
import json
import asyncio
model = default_auxiliary_model(llm)
model = llm or "gpt-4o-mini"
llm_instance = LLM(model=model)
# Run sync LLM call in executor for async compatibility
@@ -679,9 +676,9 @@ Return empty arrays if nothing is found for a category."""
elif isinstance(response, dict):
extracted = response
else:
extracted = {"persona": [], "insights": [], "patterns": [], "improvements": []}
extracted = {"persona": [], "insights": [], "patterns": []}
stored = {"persona": 0, "insights": 0, "patterns": 0, "improvements": 0}
stored = {"persona": 0, "insights": 0, "patterns": 0}
for preference in extracted.get("persona", []):
if preference and "persona" in self._stores:
@@ -698,16 +695,10 @@ Return empty arrays if nothing is found for a category."""
self.capture_pattern(pattern, pattern_type="auto_extracted")
stored["patterns"] += 1
for improvement in extracted.get("improvements", []):
if improvement and "improvements" in self._stores:
self.capture_improvement(improvement, area="auto_extraction")
stored["improvements"] += 1
return {
"persona": extracted.get("persona", []),
"insights": extracted.get("insights", []),
"patterns": extracted.get("patterns", []),
"improvements": extracted.get("improvements", []),
"stored": stored,
}
@@ -19,7 +19,6 @@ from .protocols import MemoryProtocol
from .adapters import get_memory_adapter, get_first_available_memory_adapter
from .adapters.sqlite_adapter import SqliteMemoryAdapter
from .adapters.factories import sanitize_chroma_metadata
from ..llm.model_providers import default_auxiliary_model
# Disable litellm telemetry before any imports
os.environ["LITELLM_TELEMETRY"] = "False"
@@ -2172,7 +2171,7 @@ class Memory(SearchMixin, MemoryCoreMixin):
import litellm
# Convert model name if it's in litellm format
model_name = default_auxiliary_model(llm)
model_name = llm or "gpt-4o-mini"
response = litellm.completion(
model=model_name,
@@ -2186,24 +2185,10 @@ class Memory(SearchMixin, MemoryCoreMixin):
elif openai_available:
# Fallback to OpenAI client
from openai import OpenAI
# Pass the endpoint explicitly. A bare OpenAI() happens to work
# when OPENAI_BASE_URL is exported, but silently ignores a
# base_url supplied through config -- which is the shape the rest
# of the memory layer uses.
_client_kwargs = {}
_cfg = getattr(self, "cfg", None) or {}
_base_url = _cfg.get("base_url")
if not _base_url and isinstance(_cfg.get("config"), dict):
_base_url = _cfg["config"].get("base_url")
_base_url = _base_url or os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE")
if _base_url:
_client_kwargs["base_url"] = _base_url
# Local servers reject an empty key; any non-empty value works.
_client_kwargs["api_key"] = os.getenv("OPENAI_API_KEY") or "local"
client = OpenAI(**_client_kwargs)
client = OpenAI()
response = client.chat.completions.create(
model=default_auxiliary_model(llm),
model=llm or "gpt-4o-mini",
messages=[{
"role": "user",
"content": custom_prompt or default_prompt
@@ -65,20 +65,12 @@ class SearchMixin:
if min_quality is not None:
results = [r for r in results if r.get("metadata", {}).get("quality", 0.0) >= min_quality]
# Apply metadata filtering (defense-in-depth). Auto-derive a user_id
# filter so per-user isolation holds even for adapters that accept
# user_id but ignore it in their own query (Chroma/RAG, SQLite, in-memory).
# An explicit user_id always wins so a conflicting
# metadata_filter["user_id"] can never widen the scope to another
# tenant's data (mirrors Memory._build_metadata_filter).
effective_filter = dict(metadata_filter or {})
if user_id:
effective_filter["user_id"] = user_id
if effective_filter:
# Apply metadata filtering if specified
if metadata_filter:
filtered_results = []
for result in results:
metadata = result.get("metadata", {})
if all(metadata.get(k) == v for k, v in effective_filter.items()):
if all(metadata.get(k) == v for k, v in metadata_filter.items()):
filtered_results.append(result)
results = filtered_results
@@ -132,20 +124,12 @@ class SearchMixin:
if min_quality is not None:
results = [r for r in results if r.get("metadata", {}).get("quality", 0.0) >= min_quality]
# Apply metadata filtering (defense-in-depth). Auto-derive a user_id
# filter so per-user isolation holds even for adapters that accept
# user_id but ignore it in their own query (Chroma/RAG, SQLite, in-memory).
# An explicit user_id always wins so a conflicting
# metadata_filter["user_id"] can never widen the scope to another
# tenant's data (mirrors Memory._build_metadata_filter).
effective_filter = dict(metadata_filter or {})
if user_id:
effective_filter["user_id"] = user_id
if effective_filter:
# Apply metadata filtering if specified
if metadata_filter:
filtered_results = []
for result in results:
metadata = result.get("metadata", {})
if all(metadata.get(k) == v for k, v in effective_filter.items()):
if all(metadata.get(k) == v for k, v in metadata_filter.items()):
filtered_results.append(result)
results = filtered_results
@@ -1,4 +1,4 @@
"""Model-family-aware agent harness, plus the test doubles for model calls.
"""Model-family-aware agent harness.
Selects, by model id/family, a base/harness system-prompt fragment and a
preferred file-edit format (string-replace ``edit_file`` vs ``apply_patch``).
@@ -14,23 +14,6 @@ Usage::
profile = resolve_harness("claude-opus-4")
profile.base_prompt # optional prompt fragment (str or None)
profile.preferred_edit_format # "apply_patch" | "edit_file" | None
Testing::
from praisonaiagents import Agent
from praisonaiagents.model_harness import ScriptedModel, allow_model_requests
allow_model_requests(False) # nothing in this suite may reach a provider
model = ScriptedModel(["Paris."])
agent = Agent(instructions="You are a geography bot.", llm=model)
assert agent.start("Capital of France?") == "Paris."
assert model.requests[0].system_prompt.startswith("You are a geography bot.")
:class:`ScriptedModel` returns scripted replies in order, can script a tool call
followed by a final answer, records every request the agent sent, and raises
:class:`ScriptExhausted` when the script runs out. :func:`allow_model_requests`
is the global "no real model calls" switch.
"""
from praisonaiagents._lazy import create_lazy_getattr
@@ -50,41 +33,6 @@ _LAZY_IMPORTS = {
"praisonaiagents.model_harness.profiles",
"DEFAULT_PROFILE",
),
# Test doubles and the real-request guard.
"ScriptedModel": ("praisonaiagents.model_harness.scripted", "ScriptedModel"),
"ScriptedReply": ("praisonaiagents.model_harness.scripted", "ScriptedReply"),
"ScriptedToolCall": (
"praisonaiagents.model_harness.scripted",
"ScriptedToolCall",
),
"RecordedRequest": (
"praisonaiagents.model_harness.scripted",
"RecordedRequest",
),
"ScriptExhausted": (
"praisonaiagents.model_harness.scripted",
"ScriptExhausted",
),
"ScriptedModelError": (
"praisonaiagents.model_harness.scripted",
"ScriptedModelError",
),
"allow_model_requests": (
"praisonaiagents.model_harness.guard",
"allow_model_requests",
),
"model_requests_allowed": (
"praisonaiagents.model_harness.guard",
"model_requests_allowed",
),
"no_model_requests": (
"praisonaiagents.model_harness.guard",
"no_model_requests",
),
"ModelRequestBlocked": (
"praisonaiagents.model_harness.guard",
"ModelRequestBlocked",
),
}
__all__ = list(_LAZY_IMPORTS.keys())
Loaded 100 of 946 files, more files were not shown because too many files have changed in this diff. Show more