mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-10 17:29:39 -10:00
Compare commits
116
Commits
No files matched your search
@@ -94,3 +94,17 @@ runs:
|
||||
cd "$WRAPPER_PKG"
|
||||
pip install -e "${WRAPPER_SUFFIX}"
|
||||
fi
|
||||
|
||||
# crawl4ai depends on unclecode-litellm, a fork that writes into the same
|
||||
# litellm/ directory. Installed together, the two overwrite each other's
|
||||
# files in whatever order they land, so imports of litellm fail at random.
|
||||
# Reinstall the resolved litellm last so its own files win.
|
||||
if python -m pip show unclecode-litellm >/dev/null 2>&1; then
|
||||
LITELLM_VERSION="$(python -m pip show litellm | awk '/^Version:/{print $2}')"
|
||||
if [ "${{ inputs.use-uv }}" = "true" ]; then
|
||||
uv pip install --system --reinstall --no-deps "litellm==${LITELLM_VERSION}"
|
||||
else
|
||||
pip install --force-reinstall --no-deps "litellm==${LITELLM_VERSION}"
|
||||
fi
|
||||
python -c "import litellm"
|
||||
fi
|
||||
@@ -178,13 +178,84 @@ assert('no source issue', o.sourceIssueNumbers({ head: { ref: 'feat/x' }, body:
|
||||
await o.touchedByRun(runGh([{ event: 'committed', committer: { date: '2026-10-17T10:07:00Z' } }]), 'o', 'r', item(), SINCE, APP));
|
||||
assert('App editing the body during the run is touched (GraphQL login has no [bot])',
|
||||
await o.touchedByRun(runGh([], [{ editedAt: '2026-10-17T10:08:00Z', editor: { login: 'praisonai-triage-agent' } }]), 'o', 'r', item(), SINCE, APP));
|
||||
assert('routine label sync by the App during the run is not touched (shared App identity)',
|
||||
!(await o.touchedByRun(runGh([{ event: 'unlabeled', actor: { login: 'praisonai-triage-agent[bot]' }, label: { name: 'pipeline/blocked:ci' }, created_at: '2026-10-17T10:06:00Z' }]), 'o', 'r', item(), SINCE, APP)));
|
||||
assert('the App adding the claude label during the run is touched (it starts a Claude run)',
|
||||
await o.touchedByRun(runGh([{ event: 'labeled', actor: { login: 'praisonai-triage-agent[bot]' }, label: { name: 'claude' }, created_at: '2026-10-17T10:06:00Z' }]), 'o', 'r', item(), SINCE, APP));
|
||||
const appLabel = (event, name) => runGh([{ event, actor: { login: 'praisonai-triage-agent[bot]' }, label: { name }, created_at: '2026-10-17T10:06:00Z' }]);
|
||||
assert('the App removing no-auto-merge during the run is touched (it loosens the gate)',
|
||||
await o.touchedByRun(appLabel('unlabeled', 'no-auto-merge'), 'o', 'r', item(), SINCE, APP));
|
||||
assert('the App removing a manual-only label during the run is touched',
|
||||
await o.touchedByRun(appLabel('unlabeled', 'needs-manual-review'), 'o', 'r', item(), SINCE, APP));
|
||||
assert('the App adding a maintainer-accept override during the run is touched',
|
||||
await o.touchedByRun(appLabel('labeled', 'maintainer-accept-core-tools'), 'o', 'r', item(), SINCE, APP));
|
||||
assert('the App adding no-auto-merge (tightening) is not touched',
|
||||
!(await o.touchedByRun(appLabel('labeled', 'no-auto-merge'), 'o', 'r', item(), SINCE, APP)));
|
||||
assert('the gate releasing its own lock label is not touched',
|
||||
!(await o.touchedByRun(appLabel('unlabeled', 'claude-merge-gate-active'), 'o', 'r', item(), SINCE, APP)));
|
||||
assert('the App force-pushing during the run is touched',
|
||||
await o.touchedByRun(runGh([{ event: 'head_ref_force_pushed', actor: { login: 'praisonai-triage-agent[bot]' }, created_at: '2026-10-17T10:06:00Z' }]), 'o', 'r', item(), SINCE, APP));
|
||||
assert('a maintainer comment during the run is not touched',
|
||||
!(await o.touchedByRun(runGh([{ event: 'commented', user: { login: 'MervinPraison' }, created_at: '2026-10-17T10:06:00Z' }]), 'o', 'r', item(), SINCE, APP)));
|
||||
assert('App activity after the run ended is not touched',
|
||||
!(await o.touchedByRun(runGh([{ event: 'commented', user: { login: 'praisonai-triage-agent[bot]' }, created_at: '2026-10-20T10:00:00Z' }]), 'o', 'r', item(), SINCE, APP, '2026-10-17T10:30:00Z')));
|
||||
assert('App activity before the run is not touched',
|
||||
!(await o.touchedByRun(runGh([{ event: 'labeled', actor: { login: 'praisonai-triage-agent[bot]' }, created_at: '2026-10-17T09:00:00Z' }]), 'o', 'r', item(), SINCE, APP)));
|
||||
const editFail = await (async () => { const g = runGh([]); g.graphql = async () => { throw new Error('rate'); }; return o.touchedByRun(g, 'o', 'r', item(), SINCE, APP).then(() => null, (e) => e); })();
|
||||
assert('an edit-history lookup failure fails closed (throws)', editFail instanceof Error);
|
||||
|
||||
{
|
||||
const jobs = [{ name: 'issue-triage', completed_at: '2026-10-17T10:00:01Z' }, { name: 'claude-response', completed_at: '2026-10-17T10:20:00Z' }];
|
||||
const g = { rest: { actions: { listJobsForWorkflowRun: 'jobs' } }, paginate: async () => jobs };
|
||||
assert('the Claude job end time bounds the run window', (await o.jobCompletedAt(g, 'o', 'r', 1, 'claude-response')) === '2026-10-17T10:20:00Z');
|
||||
const hub = { rest: g.rest, paginate: async () => [{ name: 'claude / claude-response', completed_at: '2026-10-17T10:21:00Z' }] };
|
||||
assert('a reusable workflow job (caller / job) is found', (await o.jobCompletedAt(hub, 'o', 'r', 1, 'claude-response')) === '2026-10-17T10:21:00Z');
|
||||
assert('an unfinished or missing job gives no bound', (await o.jobCompletedAt(g, 'o', 'r', 1, 'nope')) === null);
|
||||
const bad = { rest: { actions: { listJobsForWorkflowRun: 'jobs' } }, paginate: async () => { throw new Error('403'); } };
|
||||
assert('a jobs lookup failure gives no bound (caller counts up to now)', (await o.jobCompletedAt(bad, 'o', 'r', 1, 'claude-response')) === null);
|
||||
}
|
||||
|
||||
// A request mentioning another repo's numbers as bare #N (Docs issues cite PraisonAI PRs this way)
|
||||
// must not make the run outsider: a number that names nothing here carries no content.
|
||||
r = await o.referencedIssueReasons(fakeGithub({ issueError: 404 }), 'o', 'r', 'see upstream #5987 and #6366');
|
||||
assert('a referenced number that does not exist here (404) is not outsider influence', r.length === 0);
|
||||
{
|
||||
// The issue exists (writer-owned) but its comments call 404: that must not silently skip the comment check.
|
||||
const g = fakeGithub({ issues: { 12: maintainerIssue } });
|
||||
g.paginate = async (method) => { if (method === 'issues.listComments') throw Object.assign(new Error('nf'), { status: 404 }); return []; };
|
||||
r = await o.referencedIssueReasons(g, 'o', 'r', 'see #12');
|
||||
assert('a 404 after the issue was found still fails closed (only a missing issue is ignored)', r.some((x) => x.includes('#12 is unavailable')));
|
||||
}
|
||||
r = await o.referencedIssueReasons(fakeGithub({ issueError: 410 }), 'o', 'r', 'see #12');
|
||||
assert('a referenced issue that was deleted (410) still fails closed', r.some((x) => x.includes('unavailable (HTTP 410)')));
|
||||
r = await o.referencedIssueReasons(
|
||||
fakeGithub({ issues: { 500: maintainerIssue }, timeline: [{ event: 'labeled', label: { name: o.OUTSIDER_ORIGIN_LABEL } }] }), 'o', 'r', 'implement #500'
|
||||
);
|
||||
assert('a referenced issue flagged earlier stays outsider (outsider comment since deleted)', r.some((x) => x.includes('#500 was flagged')));
|
||||
r = await o.referencedIssueReasons(fakeGithub({ issues: { 500: { user: { login: 'stranger' } } } }), 'o', 'r', 'implement #500');
|
||||
assert('a referenced outsider issue still counts', r.some((x) => x.includes('referenced issue #500 opened by @stranger')));
|
||||
r = await o.outsiderIssueReasons(
|
||||
fakeGithub({ issues: { 600: maintainerIssue } }), 'o', 'r', 600, 'Docs for upstream #5987 (PraisonAI)'
|
||||
);
|
||||
assert('maintainer issue citing missing numbers is not outsider', r.length === 0);
|
||||
r = await o.outsiderOriginReasons(fakeGithub({ issueError: 404 }), 'o', 'r', internalPr(), []);
|
||||
assert('a missing SOURCE issue (404) still fails closed', r.some((x) => x.includes('unavailable (HTTP 404)')));
|
||||
|
||||
// A person with write access can clear a wrong flag; bots cannot.
|
||||
const lab = (event, login, t) => ({ event, label: { name: o.OUTSIDER_ORIGIN_LABEL }, actor: { login }, created_at: t });
|
||||
r = await o.outsiderOriginReasons(fakeGithub({ issue: maintainerIssue, timeline: [
|
||||
lab('labeled', 'praisonai-triage-agent[bot]', '2026-10-19T09:30:00Z'), lab('unlabeled', 'MervinPraison', '2026-10-26T10:00:00Z')] }), 'o', 'r', internalPr(), []);
|
||||
assert('a writer removing the flag clears it', !r.some((x) => x.includes('flagged')));
|
||||
r = await o.outsiderOriginReasons(fakeGithub({ issue: maintainerIssue, timeline: [
|
||||
lab('labeled', 'praisonai-triage-agent[bot]', '2026-10-19T09:30:00Z'), lab('unlabeled', 'praisonai-triage-agent[bot]', '2026-10-26T10:00:00Z')] }), 'o', 'r', internalPr(), []);
|
||||
assert('a bot removing the flag does not clear it', r.some((x) => x.includes('flagged')));
|
||||
r = await o.outsiderOriginReasons(fakeGithub({ issue: maintainerIssue, timeline: [
|
||||
lab('labeled', 'praisonai-triage-agent[bot]', '2026-10-19T09:30:00Z'), lab('unlabeled', 'stranger', '2026-10-26T10:00:00Z')] }), 'o', 'r', internalPr(), []);
|
||||
assert('a non-writer removing the flag does not clear it', r.some((x) => x.includes('flagged')));
|
||||
r = await o.outsiderOriginReasons(fakeGithub({ issue: maintainerIssue, timeline: [
|
||||
lab('labeled', 'x[bot]', '2026-10-19T09:30:00Z'), lab('unlabeled', 'MervinPraison', '2026-10-20T10:00:00Z'), lab('labeled', 'x[bot]', '2026-10-21T10:00:00Z')] }), 'o', 'r', internalPr(), []);
|
||||
assert('flagged again after a clear is flagged', r.some((x) => x.includes('flagged')));
|
||||
|
||||
r = await o.outsiderOriginReasons(fakeGithub({ issueError: 410 }), 'o', 'r', internalPr(), []);
|
||||
assert('a deleted or transferred source issue (410) counts as unknown, so outsider', r.some((x) => x.includes('unavailable (HTTP 410)')));
|
||||
|
||||
|
||||
@@ -26,6 +26,15 @@ const TRUSTED_AUTOMATION = new Set(['Copilot']);
|
||||
const SECURITY_STATUS_CONTEXT = 'security/outsider-review';
|
||||
const SECURITY_STATUS_CREATOR = 'github-actions[bot]';
|
||||
const OUTSIDER_ORIGIN_LABEL = 'origin/outsider';
|
||||
// Timeline events that change content Claude or the merge gate reads.
|
||||
const CONTENT_EVENTS = new Set(['commented', 'reviewed', 'line-commented', 'commit-commented', 'head_ref_force_pushed', 'renamed']);
|
||||
// Label changes that loosen what Claude or the merge gate does (see merge-gate.js):
|
||||
// adding a trigger or an override, or removing a stop or manual-only label.
|
||||
// Pipeline status labels and the gate's own lock are routine and do not count.
|
||||
const LOOSENING_LABELS = {
|
||||
labeled: new Set(['claude', 'merge-gate-ci-only', 'maintainer-accept-example', 'maintainer-accept-core-tools']),
|
||||
unlabeled: new Set(['no-auto-merge', 'claude-conflict-pending', 'auto-merged-by-gate', 'security', 'breaking-change', 'needs-manual-review', 'release']),
|
||||
};
|
||||
|
||||
// Build, dependency, CI and install-time surface. Code here runs on developer
|
||||
// machines, in CI with secrets, or at install time, so an outsider-origin
|
||||
@@ -164,12 +173,24 @@ async function outsiderOriginReasons(github, owner, repo, pr, comments = []) {
|
||||
return reasons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Was this ever flagged? Timeline events cannot be edited, so deleting the
|
||||
* outsider content does not clear it. Only a person with write access can clear
|
||||
* a wrong flag, by removing the label after it was last added (bots cannot:
|
||||
* every Claude run acts as the App).
|
||||
*/
|
||||
async function wasLabelled(github, owner, repo, number, label) {
|
||||
try {
|
||||
const events = await github.paginate(github.rest.issues.listEventsForTimeline, {
|
||||
const events = (await github.paginate(github.rest.issues.listEventsForTimeline, {
|
||||
owner, repo, issue_number: number, per_page: 100,
|
||||
});
|
||||
return events.some((e) => e.event === 'labeled' && e.label?.name === label);
|
||||
})).filter((e) => (e.event === 'labeled' || e.event === 'unlabeled') && e.label?.name === label);
|
||||
const lastAdded = events.map((e) => e.event).lastIndexOf('labeled');
|
||||
if (lastAdded < 0) return false;
|
||||
const isWriter = makePermissionLookup(github, owner, repo);
|
||||
for (const e of events.slice(lastAdded + 1)) {
|
||||
if (e.event === 'unlabeled' && !isAutomation(e.actor?.login) && (await isWriter(e.actor.login))) return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err.status === 404 || err.status === 410) return false;
|
||||
throw err;
|
||||
@@ -195,10 +216,12 @@ async function maintainerApprovedHead(github, owner, repo, prNumber, headSha) {
|
||||
}
|
||||
|
||||
/** Outsider authorship of an issue and its comments ([] for a PR number). */
|
||||
async function issueReasons(github, owner, repo, issueNumber, isWriter, label = 'issue') {
|
||||
async function issueReasons(github, owner, repo, issueNumber, isWriter, label = 'issue', { missingOk = false } = {}) {
|
||||
const reasons = [];
|
||||
let found = false;
|
||||
try {
|
||||
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: issueNumber });
|
||||
found = true;
|
||||
if (issue.pull_request) {
|
||||
// A PR as the source: its head and author matter too.
|
||||
const { data: src } = await github.rest.pulls.get({ owner, repo, pull_number: issueNumber });
|
||||
@@ -214,6 +237,11 @@ async function issueReasons(github, owner, repo, issueNumber, isWriter, label =
|
||||
if (outsiders.length) reasons.push(`${label} #${issueNumber} has comments from ${outsiders.map((l) => '@' + l).join(', ')}`);
|
||||
} catch (err) {
|
||||
if (err.status !== 404 && err.status !== 410) throw err;
|
||||
// A number merely mentioned that names nothing here (e.g. another repo's #N) has no
|
||||
// content to inject; a deleted issue (410) did, so it still fails closed.
|
||||
// Only the issue lookup itself may come back missing; a 404 from a later call
|
||||
// (comments, the PR behind it) would otherwise skip checks and fail open.
|
||||
if (missingOk && err.status === 404 && !found) return reasons;
|
||||
// Deleted, transferred or never existed: who wrote it is unknown, so fail closed.
|
||||
reasons.push(`${label} #${issueNumber} is unavailable (HTTP ${err.status}); provenance unknown`);
|
||||
}
|
||||
@@ -226,16 +254,20 @@ async function issueReasons(github, owner, repo, issueNumber, isWriter, label =
|
||||
* commits to it, or edited its text. Unrelated activity by people does not count,
|
||||
* so flagging what an outsider run touched does not sweep up other work.
|
||||
*/
|
||||
async function touchedByRun(github, owner, repo, item, since, appLogins) {
|
||||
async function touchedByRun(github, owner, repo, item, since, appLogins, until = new Date().toISOString()) {
|
||||
const t = new Date(since).getTime();
|
||||
const after = (d) => !!d && new Date(d).getTime() >= t;
|
||||
const end = new Date(until).getTime();
|
||||
const after = (d) => !!d && new Date(d).getTime() >= t && new Date(d).getTime() <= end;
|
||||
const isApp = (login) => !!login && (appLogins.has(login) || appLogins.has(`${login}[bot]`));
|
||||
if (isApp(item.user?.login) && after(item.created_at)) return true;
|
||||
const events = await github.paginate(github.rest.issues.listEventsForTimeline, {
|
||||
owner, repo, issue_number: item.number, per_page: 100,
|
||||
});
|
||||
for (const e of events) {
|
||||
if (isApp(e.actor?.login || e.user?.login) && after(e.created_at || e.submitted_at || e.updated_at)) return true;
|
||||
// Only actions that change what Claude or the gate reads. Label syncing by the
|
||||
// pipeline also runs as the App and must not count as the outsider run.
|
||||
const content = CONTENT_EVENTS.has(e.event) || !!LOOSENING_LABELS[e.event]?.has(e.label?.name);
|
||||
if (content && isApp(e.actor?.login || e.user?.login) && after(e.created_at || e.submitted_at || e.updated_at)) return true;
|
||||
if (e.event === 'committed' && after(e.committer?.date)) return true;
|
||||
}
|
||||
let data;
|
||||
@@ -258,6 +290,22 @@ async function touchedByRun(github, owner, repo, item, since, appLogins) {
|
||||
return edits.some((n) => after(n.editedAt) && isApp(n.editor?.login));
|
||||
}
|
||||
|
||||
/**
|
||||
* When a job of this workflow run finished (null if unknown). It bounds what counts
|
||||
* as that job's activity, so work by later runs is not attributed to it.
|
||||
*/
|
||||
async function jobCompletedAt(github, owner, repo, runId, jobName) {
|
||||
try {
|
||||
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
|
||||
owner, repo, run_id: runId, per_page: 100,
|
||||
});
|
||||
// A reusable workflow's jobs are named '<caller job> / <job>'.
|
||||
return jobs.find((j) => j.name === jobName || j.name.endsWith(` / ${jobName}`))?.completed_at || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an issue Claude is asked to work on is outsider-influenced ([] when not):
|
||||
* the issue itself, anything it was flagged as before, and every issue the
|
||||
@@ -267,7 +315,7 @@ async function outsiderIssueReasons(github, owner, repo, issueNumber, requestTex
|
||||
const isWriter = makePermissionLookup(github, owner, repo);
|
||||
const reasons = await issueReasons(github, owner, repo, issueNumber, isWriter);
|
||||
for (const n of referencedIssueNumbers(requestText, owner, repo)) {
|
||||
if (n !== issueNumber) reasons.push(...(await issueReasons(github, owner, repo, n, isWriter, 'referenced issue')));
|
||||
if (n !== issueNumber) reasons.push(...(await issueReasons(github, owner, repo, n, isWriter, 'referenced issue', { missingOk: true })));
|
||||
}
|
||||
for (const n of [issueNumber, ...referencedIssueNumbers(requestText, owner, repo)]) {
|
||||
if (await wasLabelled(github, owner, repo, n, OUTSIDER_ORIGIN_LABEL)) {
|
||||
@@ -278,6 +326,20 @@ async function outsiderIssueReasons(github, owner, repo, issueNumber, requestTex
|
||||
return reasons;
|
||||
}
|
||||
|
||||
/** Outsider influence from issues a request points at ("implement #500"); missing numbers are ignored. */
|
||||
async function referencedIssueReasons(github, owner, repo, text) {
|
||||
const isWriter = makePermissionLookup(github, owner, repo);
|
||||
const reasons = [];
|
||||
for (const n of referencedIssueNumbers(text, owner, repo)) {
|
||||
reasons.push(...(await issueReasons(github, owner, repo, n, isWriter, 'referenced issue', { missingOk: true })));
|
||||
// Sticky like everywhere else: a deleted outsider comment must not clear the flag.
|
||||
if (await wasLabelled(github, owner, repo, n, OUTSIDER_ORIGIN_LABEL)) {
|
||||
reasons.push(`referenced issue #${n} was flagged ${OUTSIDER_ORIGIN_LABEL} earlier`);
|
||||
}
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
/** Every #N, owner/repo#N (this repo) and issue/PR URL in this repo mentioned in text. */
|
||||
function referencedIssueNumbers(text, owner, repo) {
|
||||
const here = `${owner}/${repo}`.toLowerCase();
|
||||
@@ -418,7 +480,9 @@ module.exports = {
|
||||
outsiderOriginReasons,
|
||||
outsiderIssueReasons,
|
||||
referencedIssueNumbers,
|
||||
referencedIssueReasons,
|
||||
touchedByRun,
|
||||
jobCompletedAt,
|
||||
outsiderSensitivePaths,
|
||||
riskFlags,
|
||||
formatRiskFlags,
|
||||
|
||||
@@ -11,9 +11,52 @@ on:
|
||||
description: 'Fail if performance regresses'
|
||||
required: false
|
||||
default: 'true'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/praisonai-agents/praisonaiagents/agent/**'
|
||||
- 'src/praisonai-agents/praisonaiagents/llm/**'
|
||||
- 'src/praisonai-agents/benchmarks/simple_benchmark.py'
|
||||
- 'src/praisonai-agents/benchmarks/thresholds.json'
|
||||
- '.github/workflows/benchmark.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
instantiation-gate:
|
||||
# Fails the PR when Agent instantiation exceeds benchmarks/thresholds.json.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install praisonaiagents
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e src/praisonai-agents
|
||||
|
||||
- name: Check instantiation threshold
|
||||
run: |
|
||||
cd src/praisonai-agents
|
||||
python benchmarks/simple_benchmark.py --skip-crewai --json --fail-over-threshold > simple.json
|
||||
python - <<'PY' >> "$GITHUB_STEP_SUMMARY"
|
||||
import json
|
||||
data = json.load(open('simple.json'))
|
||||
budget = json.load(open('benchmarks/thresholds.json'))['praisonai_instantiation_us_max']
|
||||
print(f"PraisonAI instantiation: {data['results']['PraisonAI']:.2f} us (budget {budget} us)")
|
||||
PY
|
||||
|
||||
benchmark:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
@@ -44,7 +87,8 @@ jobs:
|
||||
python benchmarks/import_time.py 2>&1 | tee import_time_output.txt
|
||||
|
||||
# Extract import time
|
||||
IMPORT_TIME=$(grep -oP 'Median:\s+\K[\d.]+' import_time_output.txt || echo "0")
|
||||
IMPORT_TIME=$(grep -oP 'Median:\s+\K[\d.]+' import_time_output.txt | head -1)
|
||||
[ -n "$IMPORT_TIME" ] || { echo "::error::Could not parse import time"; exit 1; }
|
||||
echo "import_time=$IMPORT_TIME" >> $GITHUB_OUTPUT
|
||||
|
||||
# Check threshold (200ms)
|
||||
@@ -66,7 +110,8 @@ jobs:
|
||||
python benchmarks/memory_usage.py 2>&1 | tee memory_output.txt
|
||||
|
||||
# Extract peak memory
|
||||
PEAK_MEMORY=$(grep -oP 'Peak:\s+\K[\d.]+' memory_output.txt || echo "0")
|
||||
PEAK_MEMORY=$(grep -oP 'Peak:\s+\K[\d.]+' memory_output.txt | head -1)
|
||||
[ -n "$PEAK_MEMORY" ] || { echo "::error::Could not parse peak memory"; exit 1; }
|
||||
echo "peak_memory=$PEAK_MEMORY" >> $GITHUB_OUTPUT
|
||||
|
||||
# Check threshold (30MB)
|
||||
@@ -83,10 +128,8 @@ jobs:
|
||||
id: simple
|
||||
run: |
|
||||
cd src/praisonai-agents
|
||||
python benchmarks/simple_benchmark.py 2>&1 | tee simple_output.txt
|
||||
|
||||
# Extract PraisonAI time
|
||||
PRAISON_TIME=$(grep -oP 'PraisonAI\s+\K[\d.]+' simple_output.txt | head -1 || echo "0")
|
||||
python benchmarks/simple_benchmark.py --json > simple.json
|
||||
PRAISON_TIME=$(python -c "import json; print(f\"{json.load(open('simple.json'))['results']['PraisonAI']:.2f}\")")
|
||||
echo "praison_time=$PRAISON_TIME" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run Tools Benchmark
|
||||
@@ -96,7 +139,8 @@ jobs:
|
||||
python benchmarks/tools_benchmark.py 2>&1 | tee tools_output.txt
|
||||
|
||||
# Extract PraisonAI time with tools
|
||||
PRAISON_TOOLS_TIME=$(grep -oP 'PraisonAI\s+\K[\d.]+' tools_output.txt | head -1 || echo "0")
|
||||
PRAISON_TOOLS_TIME=$(grep -oP 'PraisonAI\s+\K[\d.]+' tools_output.txt | head -1)
|
||||
[ -n "$PRAISON_TOOLS_TIME" ] || { echo "::error::Could not parse tools benchmark"; exit 1; }
|
||||
echo "praison_tools_time=$PRAISON_TOOLS_TIME" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run Parameter Impact Benchmark
|
||||
@@ -133,7 +177,7 @@ jobs:
|
||||
echo "|--------|-------|-----------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Import Time | ${{ steps.import_time.outputs.import_time }}ms | <200ms | ${{ steps.import_time.outputs.import_passed == 'true' && '✅' || '❌' }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Peak Memory | ${{ steps.memory.outputs.peak_memory }}MB | <30MB | ${{ steps.memory.outputs.memory_passed == 'true' && '✅' || '❌' }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Agent Instantiation | ${{ steps.simple.outputs.praison_time }}μs | - | ℹ️ |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Agent Instantiation | ${{ steps.simple.outputs.praison_time }}μs | see thresholds.json | ℹ️ |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Agent with Tools | ${{ steps.tools.outputs.praison_tools_time }}μs | - | ℹ️ |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Lazy Import Check" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -353,9 +353,7 @@ jobs:
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number });
|
||||
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: number, per_page: 100 });
|
||||
origin = await o.outsiderOriginReasons(github, owner, repo, pr, comments);
|
||||
for (const n of o.referencedIssueNumbers(request, owner, repo)) {
|
||||
origin.push(...(await o.outsiderIssueReasons(github, owner, repo, n)));
|
||||
}
|
||||
origin.push(...(await o.referencedIssueReasons(github, owner, repo, request)));
|
||||
} else {
|
||||
origin = await o.outsiderIssueReasons(github, owner, repo, number, request);
|
||||
}
|
||||
@@ -560,13 +558,17 @@ jobs:
|
||||
const o = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/outsider-review.js'));
|
||||
const { owner, repo } = context.repo;
|
||||
const since = process.env.STARTED_AT || new Date(0).toISOString();
|
||||
// Claude's job is over (its token revoked, its processes killed); later
|
||||
// activity belongs to other runs. Unknown: count everything up to now.
|
||||
const until = (await o.jobCompletedAt(github, owner, repo, context.runId, 'claude-response')) || new Date().toISOString();
|
||||
core.info(`Outsider run window: ${since} .. ${until}`);
|
||||
// Issues and PRs created, edited, commented on or pushed to since then.
|
||||
const touched = await github.paginate(github.rest.issues.listForRepo, { owner, repo, state: 'all', since, per_page: 100 });
|
||||
const app = new Set(['praisonai-triage-agent[bot]']);
|
||||
for (const item of touched) {
|
||||
let flag = true;
|
||||
try {
|
||||
flag = await o.touchedByRun(github, owner, repo, item, since, app);
|
||||
flag = await o.touchedByRun(github, owner, repo, item, since, app, until);
|
||||
} catch (err) {
|
||||
core.warning(`#${item.number}: ${err.message}; flagging it to be safe`);
|
||||
}
|
||||
|
||||
@@ -96,11 +96,11 @@ jobs:
|
||||
--head "$branch" \
|
||||
--title "$msg" \
|
||||
--body "$(cat <<EOF
|
||||
Automated sync from [\`MervinPraison/PraisonAI\`](https://github.com/MervinPraison/PraisonAI) \`${SOURCE_PATH}/\` into the npm mirror repo.
|
||||
Automated sync from [\`MervinPraison/PraisonAI\`](https://github.com/MervinPraison/PraisonAI) \`${SOURCE_PATH}/\` into the npm mirror repo.
|
||||
|
||||
Canonical development is in the monorepo; this PR updates the public \`praisonai-js\` checkout.
|
||||
Canonical development is in the monorepo; this PR updates the public \`praisonai-js\` checkout.
|
||||
|
||||
Excludes from rsync source: \`node_modules/\`, \`dist/\`, \`coverage/\`. \`.github/\` on praisonai-js is unchanged.
|
||||
EOF
|
||||
)"
|
||||
Excludes from rsync source: \`node_modules/\`, \`dist/\`, \`coverage/\`. \`.github/\` on praisonai-js is unchanged.
|
||||
EOF
|
||||
)"
|
||||
echo "Opened PR on ${TARGET_REPO} (${branch} → ${TARGET_BRANCH})."
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
"
|
||||
/tmp/praisonai-browser-standalone/bin/praisonai-browser --help
|
||||
bash "$GITHUB_WORKSPACE/scripts/check_c11_browser_imports.sh"
|
||||
python -m pytest src/praisonai-browser/tests/browser/test_protocol_compliance.py -q -m "not network"
|
||||
python -m pytest src/praisonai-browser/tests/browser/test_protocol_compliance.py src/praisonai-browser/tests/browser/test_server.py -q -m "not network"
|
||||
|
||||
- name: Standalone praisonai-mcp smoke (C12)
|
||||
run: |
|
||||
|
||||
@@ -1002,11 +1002,13 @@ agent.start('Write a movie script about a robot in Mars');
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
PraisonAI is built for speed, with agent instantiation in around 14μs. This reduces overhead, improves responsiveness, and helps multi-agent systems scale efficiently in real-world production workloads.
|
||||
PraisonAI keeps framework overhead microsecond-class: creating an `Agent` (one tool, no LLM call) averaged **46 μs** in our 2026-10-15 run, so per-agent setup is negligible next to model latency in multi-agent workloads.
|
||||
|
||||
| Performance Metric | PraisonAI |
|
||||
|--------------------|-----------|
|
||||
| Avg Instantiation Time | **14 μs** |
|
||||
| Performance Metric | PraisonAI | Measured |
|
||||
|--------------------|-----------|----------|
|
||||
| Avg Agent instantiation (1 tool, 1000 iterations) | **46 μs** | 2026-10-15 · praisonaiagents 1.7.12 |
|
||||
|
||||
Methodology, comparisons and how to reproduce: [benchmarks/BENCHMARK_RESULTS.md](https://github.com/MervinPraison/PraisonAI/blob/main/src/praisonai-agents/benchmarks/BENCHMARK_RESULTS.md) · [docs](https://docs.praison.ai/docs/developers/benchmarks)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.16" \
|
||||
"praisonai>=4.7.18" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.7.16" \
|
||||
"praisonai>=4.7.18" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.7.16" \
|
||||
"praisonai>=4.7.18" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ FastContext provides rapid code search capabilities.
|
||||
Use it directly from the context module for code search.
|
||||
|
||||
FastContext provides:
|
||||
- 10-20x faster code search than traditional methods
|
||||
- Parallel execution (8 concurrent searches)
|
||||
- Automatic caching for repeated queries
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Example 1: Basic Fast Context Usage
|
||||
|
||||
This example demonstrates the basic usage of Fast Context for rapid code search.
|
||||
Fast Context is 10-20x faster than traditional sequential search because it:
|
||||
Fast Context speeds up code search because it:
|
||||
- Executes up to 8 tool calls in parallel
|
||||
- Limits search to 4 turns maximum
|
||||
- Uses a lightweight model optimized for search
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
# Performance Benchmark CI
|
||||
# Runs import time and instantiation benchmarks on push/PR
|
||||
# Fails if import time exceeds 200ms threshold
|
||||
|
||||
name: Performance Benchmark
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'praisonaiagents/**'
|
||||
- 'benchmarks/**'
|
||||
- '.github/workflows/benchmark.yml'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'praisonaiagents/**'
|
||||
- 'benchmarks/**'
|
||||
- '.github/workflows/benchmark.yml'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
benchmark:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install package
|
||||
run: |
|
||||
pip install -e .
|
||||
pip install pytest
|
||||
|
||||
- name: Measure Import Time
|
||||
id: import_time
|
||||
run: |
|
||||
# Measure import time (full Agent import)
|
||||
IMPORT_TIME=$(python -c "
|
||||
import time
|
||||
import sys
|
||||
|
||||
# Clear any cached modules
|
||||
for key in list(sys.modules.keys()):
|
||||
if 'praisonai' in key:
|
||||
del sys.modules[key]
|
||||
|
||||
start = time.perf_counter()
|
||||
from praisonaiagents import Agent
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
print(f'{elapsed:.1f}')
|
||||
")
|
||||
|
||||
echo "import_time=$IMPORT_TIME" >> $GITHUB_OUTPUT
|
||||
echo "Import time: ${IMPORT_TIME}ms"
|
||||
|
||||
- name: Check Import Time Threshold
|
||||
run: |
|
||||
IMPORT_TIME=${{ steps.import_time.outputs.import_time }}
|
||||
THRESHOLD=200
|
||||
|
||||
echo "Import time: ${IMPORT_TIME}ms"
|
||||
echo "Threshold: ${THRESHOLD}ms"
|
||||
|
||||
# Compare using bc for floating point
|
||||
if (( $(echo "$IMPORT_TIME > $THRESHOLD" | bc -l) )); then
|
||||
echo "❌ FAIL: Import time ${IMPORT_TIME}ms exceeds threshold ${THRESHOLD}ms"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ PASS: Import time ${IMPORT_TIME}ms is within threshold ${THRESHOLD}ms"
|
||||
fi
|
||||
|
||||
- name: Measure Instantiation Time
|
||||
run: |
|
||||
python -c "
|
||||
import time
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Warmup
|
||||
for _ in range(5):
|
||||
Agent(name='Test', output='silent')
|
||||
|
||||
# Measure
|
||||
times = []
|
||||
for _ in range(50):
|
||||
start = time.perf_counter()
|
||||
Agent(name='Test', output='silent')
|
||||
times.append((time.perf_counter() - start) * 1e6)
|
||||
|
||||
avg = sum(times) / len(times)
|
||||
min_t = min(times)
|
||||
max_t = max(times)
|
||||
|
||||
print(f'Instantiation time:')
|
||||
print(f' Average: {avg:.2f}μs')
|
||||
print(f' Min: {min_t:.2f}μs')
|
||||
print(f' Max: {max_t:.2f}μs')
|
||||
"
|
||||
|
||||
- name: Verify Lazy Imports
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
|
||||
# Clear any cached modules
|
||||
for key in list(sys.modules.keys()):
|
||||
if 'praisonai' in key or 'litellm' in key:
|
||||
del sys.modules[key]
|
||||
|
||||
# Import package
|
||||
import praisonaiagents
|
||||
|
||||
# Check heavy deps are NOT loaded
|
||||
heavy_deps = ['litellm', 'chromadb', 'mem0', 'rich']
|
||||
loaded = []
|
||||
for dep in heavy_deps:
|
||||
if dep in sys.modules:
|
||||
loaded.append(dep)
|
||||
|
||||
if loaded:
|
||||
print(f'⚠️ WARNING: Heavy deps loaded eagerly: {loaded}')
|
||||
# Don't fail, just warn - some deps may be needed
|
||||
else:
|
||||
print('✅ All heavy deps are lazy loaded')
|
||||
"
|
||||
|
||||
- name: Run Quick Benchmark (if available)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if [ -f "benchmarks/quick_benchmark.py" ]; then
|
||||
python benchmarks/quick_benchmark.py --fast || true
|
||||
else
|
||||
echo "Quick benchmark not found, skipping"
|
||||
fi
|
||||
|
||||
# Real API benchmark - only runs when API keys are available
|
||||
# Triggered manually or on main branch with secrets
|
||||
real-api-benchmark:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
if: github.event_name == 'workflow_dispatch' || (github.ref == 'refs/heads/main' && github.event_name == 'push')
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install package
|
||||
run: |
|
||||
pip install -e .
|
||||
pip install pytest
|
||||
|
||||
- name: Check API Key Availability
|
||||
id: check_keys
|
||||
run: |
|
||||
if [ -n "${{ secrets.OPENAI_API_KEY }}" ]; then
|
||||
echo "has_openai=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ OpenAI API key available"
|
||||
else
|
||||
echo "has_openai=false" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ OpenAI API key not available, skipping real API tests"
|
||||
fi
|
||||
|
||||
- name: Run Real API Benchmark
|
||||
if: steps.check_keys.outputs.has_openai == 'true'
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
run: |
|
||||
python -c "
|
||||
import time
|
||||
import os
|
||||
|
||||
# Verify API key is set
|
||||
if not os.environ.get('OPENAI_API_KEY'):
|
||||
print('❌ OPENAI_API_KEY not set')
|
||||
exit(1)
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Create a simple agent
|
||||
agent = Agent(
|
||||
name='BenchmarkAgent',
|
||||
instructions='You are a helpful assistant. Be concise.',
|
||||
llm='gpt-4o-mini',
|
||||
output='silent'
|
||||
)
|
||||
|
||||
# Warmup run
|
||||
print('Running warmup...')
|
||||
agent.chat('Say hello in one word')
|
||||
|
||||
# Benchmark runs
|
||||
print('Running benchmark (3 iterations)...')
|
||||
times = []
|
||||
for i in range(3):
|
||||
start = time.perf_counter()
|
||||
response = agent.chat('What is 2+2? Answer with just the number.')
|
||||
elapsed = time.perf_counter() - start
|
||||
times.append(elapsed)
|
||||
print(f' Run {i+1}: {elapsed:.2f}s')
|
||||
|
||||
avg = sum(times) / len(times)
|
||||
print(f'')
|
||||
print(f'Real API Benchmark Results:')
|
||||
print(f' Average response time: {avg:.2f}s')
|
||||
print(f' Min: {min(times):.2f}s')
|
||||
print(f' Max: {max(times):.2f}s')
|
||||
|
||||
# Verify response quality
|
||||
if '4' in response:
|
||||
print('✅ Response quality check passed')
|
||||
else:
|
||||
print(f'⚠️ Unexpected response: {response}')
|
||||
"
|
||||
|
||||
- name: Run Multi-Agent Benchmark
|
||||
if: steps.check_keys.outputs.has_openai == 'true'
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
run: |
|
||||
python -c "
|
||||
import time
|
||||
import os
|
||||
|
||||
from praisonaiagents import Agent, Agents, Task
|
||||
|
||||
# Create agents
|
||||
researcher = Agent(
|
||||
name='Researcher',
|
||||
instructions='You research topics. Be brief.',
|
||||
llm='gpt-4o-mini',
|
||||
output='silent'
|
||||
)
|
||||
|
||||
writer = Agent(
|
||||
name='Writer',
|
||||
instructions='You write summaries. Be brief.',
|
||||
llm='gpt-4o-mini',
|
||||
output='silent'
|
||||
)
|
||||
|
||||
# Create tasks
|
||||
task1 = Task(
|
||||
description='List 2 facts about Python programming',
|
||||
agent=researcher,
|
||||
expected_output='2 brief facts'
|
||||
)
|
||||
|
||||
task2 = Task(
|
||||
description='Summarize the facts in one sentence',
|
||||
agent=writer,
|
||||
expected_output='One sentence summary'
|
||||
)
|
||||
|
||||
# Run workflow
|
||||
print('Running multi-agent workflow...')
|
||||
start = time.perf_counter()
|
||||
|
||||
agents = Agents(agents=[researcher, writer], tasks=[task1, task2])
|
||||
result = agents.start()
|
||||
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
print(f'')
|
||||
print(f'Multi-Agent Benchmark Results:')
|
||||
print(f' Total workflow time: {elapsed:.2f}s')
|
||||
print(f' Agents: 2')
|
||||
print(f' Tasks: 2')
|
||||
print('✅ Multi-agent workflow completed successfully')
|
||||
"
|
||||
@@ -1,70 +1,47 @@
|
||||
# PraisonAI Agents - Benchmark Results
|
||||
|
||||
**Generated:** 2026-06-17
|
||||
**Methodology:** 10 warmup runs + 1000 measured iterations (`performance_benchmark.py` config)
|
||||
**Config:** `Agent(name=..., instructions=..., model="gpt-4o-mini", tools=[get_weather], output="silent")`
|
||||
**Generated:** 2026-10-15 (single run of `performance_benchmark.py`)
|
||||
**Methodology:** 10 warmup runs + 1000 measured iterations per framework
|
||||
**Setup (every framework):** one agent, model `gpt-4o-mini`, one `get_weather` tool, no LLM call. Tool wrapping (`function_tool`, `@tool`, ...) happens inside the timed call. Frameworks that were not installed are omitted.
|
||||
|
||||
## Agent Instantiation Time
|
||||
|
||||
| Framework | Avg Time (μs) | Min (μs) | Median (μs) | Relative |
|
||||
|-----------|---------------|----------|-------------|----------|
|
||||
| OpenAI Agents SDK | 5.05 | — | — | 1.00x (fastest) |
|
||||
| Agno | 5.27 | — | — | 1.04x |
|
||||
| **PraisonAI** | **13.62** | **12.58** | **12.96** | **2.69x** |
|
||||
| PraisonAI (LiteLLM) | 14.49 | 13.37 | 13.71 | 2.86x |
|
||||
|
||||
### PraisonAI detail (1000 iter)
|
||||
|
||||
| Variant | Avg (μs) | Min (μs) | Median (μs) | Std (μs) |
|
||||
|---------|----------|----------|-------------|----------|
|
||||
| With tools (`gpt-4o-mini`) | 13.62 | 12.58 | 12.96 | 2.73 |
|
||||
| With tools (`openai/gpt-4o-mini`) | 14.49 | 13.37 | 13.71 | 4.14 |
|
||||
| Minimal, no tools | 13.40 | — | — | — |
|
||||
|
||||
## Historical baseline
|
||||
|
||||
| Source | Config | Avg (μs) | Notes |
|
||||
|--------|--------|----------|-------|
|
||||
| `performance_benchmark.py` (Dec 2025) | tools + 1000 iter | **3.20** | Pre-init-bloat baseline |
|
||||
| `simple_benchmark.py` (Dec 2025) | no tools, 100 iter | **3.77** | Saved in prior `BENCHMARK_RESULTS.md` |
|
||||
| Current (Jun 2026) | tools + 1000 iter | **13.62** | After lazy-init optimisations (was ~31 μs pre-fix) |
|
||||
| Framework | Avg (μs) | Min (μs) | Median (μs) | Std (μs) | Avg memory (KiB) | Relative |
|
||||
|-----------|----------|----------|-------------|----------|------------------|----------|
|
||||
| Agno | 9.51 | 9.16 | 9.33 | 1.00 | 6.83 | 1.00x (fastest) |
|
||||
| PraisonAI | 45.55 | 42.63 | 44.26 | 4.11 | 20.55 | 4.79x |
|
||||
| PraisonAI (LiteLLM) | 46.94 | 44.65 | 45.82 | 3.26 | 20.81 | 4.94x |
|
||||
| OpenAI Agents SDK | 591.02 | 529.26 | 566.69 | 60.62 | 60.19 | 62.18x |
|
||||
| PydanticAI | 1,021.99 | 882.02 | 935.63 | 1,674.03 | 51.22 | 108x |
|
||||
| LangGraph | 3,232.91 | 2,836.27 | 3,183.02 | 233.32 | 89.66 | 340x |
|
||||
|
||||
## Package Versions
|
||||
|
||||
| Package | Version |
|
||||
|---------|---------|
|
||||
| PraisonAI | 1.6.60 |
|
||||
| Agno | 2.4.2 |
|
||||
| OpenAI Agents SDK | 0.7.0 |
|
||||
| praisonaiagents | 1.7.12 |
|
||||
| agno | 3.1.3 |
|
||||
| pydantic-ai-slim | 2.56.0 |
|
||||
| openai-agents | 0.23.1 |
|
||||
| langgraph | 1.2.15 |
|
||||
|
||||
## Machine
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Platform | Linux-6.17.0-1022-azure-x86_64-with-glibc2.39 |
|
||||
| Processor | x86_64 |
|
||||
| CPUs | 4 |
|
||||
| Python | 3.12.3 |
|
||||
| Load avg (1 min) at start | 0.85 |
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
PraisonAI-only (fast; avoids CrewAI/LangGraph import hang):
|
||||
|
||||
```bash
|
||||
cd praisonai-agents
|
||||
PYTHONWARNINGS='ignore::DeprecationWarning' python -c "
|
||||
import time, statistics
|
||||
from typing import Literal
|
||||
from praisonaiagents import Agent
|
||||
|
||||
def get_weather(city: Literal['nyc', 'sf']):
|
||||
return 'cloudy' if city == 'nyc' else 'sunny'
|
||||
|
||||
def bench(fn, n=1000, w=10):
|
||||
for _ in range(w): fn()
|
||||
times = []
|
||||
for _ in range(n):
|
||||
s = time.perf_counter(); fn(); times.append(time.perf_counter()-s)
|
||||
print(f'avg={statistics.mean(times)*1e6:.2f} μs')
|
||||
|
||||
bench(lambda: Agent(name='Test', instructions='Hi', model='gpt-4o-mini', tools=[get_weather], output='silent'))
|
||||
"
|
||||
cd src/praisonai-agents
|
||||
pip install -e . agno openai-agents "pydantic-ai-slim[openai]" langgraph langchain-openai crewai
|
||||
# OpenAI-backed constructors need a key to exist; no request is made
|
||||
OPENAI_API_KEY=sk-dummy PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/performance_benchmark.py --save --iterations 1000 --warmup 10 --only praisonai praisonai_litellm agno pydantic openai_agents langgraph
|
||||
```
|
||||
|
||||
Full multi-framework benchmark:
|
||||
|
||||
```bash
|
||||
cd praisonai-agents
|
||||
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/performance_benchmark.py --save
|
||||
```
|
||||
Older, superseded result files live in [`archive/`](archive/).
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## Summary
|
||||
|
||||
Instantiation regressed from **~3.2 μs** (Dec 2025) to **~31 μs** (~10×). After fixes, steady-state is **~16 μs** (~2× historical). PraisonAI is competitive with Agno (~5 μs) on warmed benchmarks using `model=`.
|
||||
Instantiation regressed from **~3.2 μs** (Dec 2025) to **~31 μs** (~10×). After fixes, steady-state is **~16 μs** (~2× historical). Agno measured ~5 μs on the same machine at the time, so PraisonAI was still ~3× slower; see `BENCHMARK_RESULTS.md` for current numbers.
|
||||
|
||||
## Before vs After Fixes
|
||||
|
||||
@@ -53,10 +53,45 @@ Instantiation regressed from **~3.2 μs** (Dec 2025) to **~31 μs** (~10×). Aft
|
||||
|
||||
The residual ~13 μs is primarily **`Agent.__init__` size** (~1200 lines of consolidated config resolution added since v0.1.5). Further optimisation requires a dedicated slim fast-path for `Agent(name=..., output="silent")` without full config object churn.
|
||||
|
||||
## Re-measure (2026-10-15)
|
||||
|
||||
Quiet machine (GitHub-hosted `ubuntu` x86_64 runner, 4 vCPU, load 0.77, Python 3.12.3). Same config as the
|
||||
June run (`performance_benchmark.py`: one tool, 10–20 warmup + 1000 iterations). Both versions were run on the
|
||||
same machine, so the two columns are directly comparable. The June numbers in `BENCHMARK_RESULTS.md` came from a
|
||||
faster machine.
|
||||
|
||||
| Code | Avg (μs) | Min (μs) | Median (μs) |
|
||||
|------|----------|----------|-------------|
|
||||
| praisonaiagents 1.6.60 (`709a03878`) | 20.65 | 19.85 | 20.30 |
|
||||
| `main` @ `a84e42aa9` (1.7.12 dev) | 47.92 | 45.71 | 47.11 |
|
||||
|
||||
The slowdown is real: **~2.3×** on identical hardware. Median, sampled along `main` first-parent history:
|
||||
|
||||
| Date | Commit | Median (μs) |
|
||||
|------|--------|-------------|
|
||||
| 2026-06-17 | `709a03878` (1.6.60) | 20.3 |
|
||||
| 2026-06-24 | `bb2d35ba6` | 24.2 |
|
||||
| 2026-07-01 | `f45c49b32` | 26.7 |
|
||||
| 2026-07-15 | `b1086c275` | 30.5 |
|
||||
| 2026-08-05 | `aadb62acf` | 29.7 |
|
||||
| 2026-08-19 | `d32fe29b4` | 34.0 |
|
||||
| 2026-08-26 | `7746520df` | **3,063** (transient ms-scale regression, since fixed) |
|
||||
| 2026-09-03 | `17d8726c3` | **3,012** |
|
||||
| 2026-09-10 | `8d2b4cdbc` | 48.3 |
|
||||
| 2026-10-02 | `a2b52f669` | 60.2 |
|
||||
| 2026-10-14 | `a84e42aa9` | 47.2 |
|
||||
|
||||
`git bisect` with a 33 μs cut-off has no single culprit: it lands on a wrapper-only commit, and neighbouring
|
||||
commits differ by less than the noise. The cost is gradual creep. cProfile on `main` puts ~43% of construction
|
||||
time in the body of `Agent.__init__`, and no callee costs more than ~10%. The fix is the slim construction path
|
||||
described above, tracked in #5835. A ~3 ms regression shipped for two weeks unnoticed; the PR gate
|
||||
in `.github/workflows/benchmark.yml` (`simple_benchmark.py --json --fail-over-threshold`, 150 μs budget) now
|
||||
catches regressions of that size.
|
||||
|
||||
## How to reproduce
|
||||
|
||||
```bash
|
||||
cd praisonai-agents
|
||||
cd src/praisonai-agents
|
||||
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/simple_benchmark.py
|
||||
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/performance_benchmark.py
|
||||
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/performance_benchmark.py --only praisonai
|
||||
```
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
> **Superseded.** Historical result from 2026-01-13, kept for reference only. Package versions and methodology differ from current runs; do not cite. Current results: [../BENCHMARK_RESULTS.md](../BENCHMARK_RESULTS.md).
|
||||
|
||||
# PraisonAI Agents - Real Execution Benchmark
|
||||
|
||||
**Generated:** 2026-01-13 06:07:28
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
> **Superseded.** Historical result from 2026-01-13, kept for reference only. Package versions and methodology differ from current runs; do not cite. Current results: [../BENCHMARK_RESULTS.md](../BENCHMARK_RESULTS.md).
|
||||
|
||||
# PraisonAI Agents - Real-World Execution Benchmark
|
||||
|
||||
**Generated:** 2026-01-13 14:42:59
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
> **Superseded.** Historical result from 2025-12-18, kept for reference only. Package versions and methodology differ from current runs; do not cite. Current results: [../BENCHMARK_RESULTS.md](../BENCHMARK_RESULTS.md).
|
||||
|
||||
# PraisonAI Agents - Benchmark Results
|
||||
|
||||
**Generated:** 2025-12-18 13:23:43
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
> **Superseded.** Historical result from 2026-01-23, kept for reference only. Package versions and methodology differ from current runs; do not cite. Current results: [../BENCHMARK_RESULTS.md](../BENCHMARK_RESULTS.md).
|
||||
|
||||
# PraisonAI Agents - Tools Benchmark Results
|
||||
|
||||
**Generated:** 2026-01-23 11:50:18
|
||||
@@ -6,6 +6,8 @@ multiple AI agent frameworks using industry-standard methodology.
|
||||
|
||||
Usage:
|
||||
python benchmarks/performance_benchmark.py
|
||||
python benchmarks/performance_benchmark.py --save # rewrite BENCHMARK_RESULTS.md
|
||||
python benchmarks/performance_benchmark.py --only praisonai
|
||||
"""
|
||||
|
||||
import gc
|
||||
@@ -134,39 +136,14 @@ def run_benchmark(func: Callable, name: str, iterations: int = 1000, warmup: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Framework Imports
|
||||
# Framework factories
|
||||
#
|
||||
# Each builder imports its framework lazily and returns a zero-arg callable
|
||||
# that creates one agent with one tool. A missing framework raises ImportError
|
||||
# and is skipped, so the script runs with only praisonaiagents installed.
|
||||
# ============================================================================
|
||||
from praisonaiagents import Agent as PraisonAgent
|
||||
from agno.agent import Agent as AgnoAgent
|
||||
from agno.models.openai import OpenAIChat
|
||||
|
||||
# Additional frameworks for comparison
|
||||
try:
|
||||
from pydantic_ai import Agent as PydanticAgent
|
||||
PYDANTIC_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYDANTIC_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from crewai.agent import Agent as CrewAgent
|
||||
from crewai.tools import tool as crewai_tool
|
||||
CREWAI_AVAILABLE = True
|
||||
except ImportError:
|
||||
CREWAI_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from langchain_core.tools import tool as langchain_tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
LANGGRAPH_AVAILABLE = True
|
||||
except ImportError:
|
||||
LANGGRAPH_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from agents import Agent as OpenAIAgent, function_tool
|
||||
OPENAI_AGENTS_AVAILABLE = True
|
||||
except ImportError:
|
||||
OPENAI_AGENTS_AVAILABLE = False
|
||||
INSTRUCTIONS = "Be concise, reply with one sentence."
|
||||
|
||||
|
||||
def get_weather(city: Literal["nyc", "sf"]):
|
||||
@@ -180,195 +157,181 @@ def get_weather(city: Literal["nyc", "sf"]):
|
||||
tools = [get_weather]
|
||||
|
||||
|
||||
def instantiate_praisonai_agent():
|
||||
"""Instantiate a PraisonAI agent with one tool."""
|
||||
return PraisonAgent(
|
||||
name="Test Agent",
|
||||
instructions="Be concise, reply with one sentence.",
|
||||
model="gpt-4o-mini",
|
||||
tools=tools,
|
||||
output="silent"
|
||||
def build_praisonai():
|
||||
from praisonaiagents import Agent as PraisonAgent
|
||||
return lambda: PraisonAgent(
|
||||
name="Test Agent", instructions=INSTRUCTIONS, model="gpt-4o-mini",
|
||||
tools=tools, output="silent",
|
||||
)
|
||||
|
||||
|
||||
def instantiate_praisonai_litellm_agent():
|
||||
"""Instantiate a PraisonAI agent with LiteLLM backend."""
|
||||
return PraisonAgent(
|
||||
name="Test Agent",
|
||||
instructions="Be concise, reply with one sentence.",
|
||||
model="openai/gpt-4o-mini",
|
||||
tools=tools,
|
||||
output="silent"
|
||||
def build_praisonai_litellm():
|
||||
from praisonaiagents import Agent as PraisonAgent
|
||||
return lambda: PraisonAgent(
|
||||
name="Test Agent", instructions=INSTRUCTIONS, model="openai/gpt-4o-mini",
|
||||
tools=tools, output="silent",
|
||||
)
|
||||
|
||||
|
||||
def instantiate_agno_agent():
|
||||
"""Instantiate an Agno agent with one tool."""
|
||||
return AgnoAgent(model=OpenAIChat(id="gpt-4o-mini"), tools=tools)
|
||||
def build_agno():
|
||||
from agno.agent import Agent as AgnoAgent
|
||||
from agno.models.openai import OpenAIChat
|
||||
return lambda: AgnoAgent(model=OpenAIChat(id="gpt-4o-mini"), instructions=INSTRUCTIONS, tools=tools)
|
||||
|
||||
|
||||
def instantiate_pydantic_agent():
|
||||
"""Instantiate a PydanticAI agent with one tool."""
|
||||
agent = PydanticAgent("openai:gpt-4o-mini", system_prompt="Be concise, reply with one sentence.")
|
||||
@agent.tool_plain
|
||||
def get_weather_pydantic(city: Literal["nyc", "sf"]):
|
||||
"""Use this to get weather information."""
|
||||
if city == "nyc":
|
||||
return "It might be cloudy in nyc"
|
||||
elif city == "sf":
|
||||
return "It's always sunny in sf"
|
||||
return agent
|
||||
def build_pydantic():
|
||||
from pydantic_ai import Agent as PydanticAgent
|
||||
|
||||
def create():
|
||||
agent = PydanticAgent("openai:gpt-4o-mini", system_prompt=INSTRUCTIONS)
|
||||
agent.tool_plain(get_weather)
|
||||
return agent
|
||||
return create
|
||||
|
||||
|
||||
def instantiate_openai_agents():
|
||||
"""Instantiate an OpenAI Agents SDK agent with one tool."""
|
||||
return OpenAIAgent(
|
||||
name="Test agent",
|
||||
instructions="Be concise, reply with one sentence.",
|
||||
model="gpt-4o-mini",
|
||||
def build_openai_agents():
|
||||
from agents import Agent as OpenAIAgent, function_tool
|
||||
return lambda: OpenAIAgent(
|
||||
name="Test agent", instructions=INSTRUCTIONS, model="gpt-4o-mini",
|
||||
tools=[function_tool(get_weather)],
|
||||
)
|
||||
|
||||
|
||||
def instantiate_langgraph_agent():
|
||||
"""Instantiate a LangGraph agent with one tool."""
|
||||
@langchain_tool
|
||||
def get_weather_lg(city: Literal["nyc", "sf"]):
|
||||
"""Use this to get weather information."""
|
||||
if city == "nyc":
|
||||
return "It might be cloudy in nyc"
|
||||
elif city == "sf":
|
||||
return "It's always sunny in sf"
|
||||
return create_react_agent(model=ChatOpenAI(model="gpt-4o-mini"), tools=[get_weather_lg])
|
||||
|
||||
|
||||
def instantiate_crewai_agent():
|
||||
"""Instantiate a CrewAI agent with one tool."""
|
||||
@crewai_tool("Weather Tool")
|
||||
def get_weather_crew(city: Literal["nyc", "sf"]):
|
||||
"""Use this to get weather information."""
|
||||
if city == "nyc":
|
||||
return "It might be cloudy in nyc"
|
||||
elif city == "sf":
|
||||
return "It's always sunny in sf"
|
||||
return CrewAgent(
|
||||
llm="gpt-4o-mini",
|
||||
role="Test Agent",
|
||||
goal="Be concise, reply with one sentence.",
|
||||
tools=[get_weather_crew],
|
||||
backstory="Test",
|
||||
def build_langgraph():
|
||||
from langchain_core.tools import tool as langchain_tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
return lambda: create_react_agent(
|
||||
model=ChatOpenAI(model="gpt-4o-mini"), tools=[langchain_tool(get_weather)],
|
||||
)
|
||||
|
||||
|
||||
def get_package_versions():
|
||||
"""Get version numbers for all benchmarked packages."""
|
||||
def build_crewai():
|
||||
from crewai.agent import Agent as CrewAgent
|
||||
from crewai.tools import tool as crewai_tool
|
||||
|
||||
def create():
|
||||
return CrewAgent(
|
||||
llm="gpt-4o-mini", role="Test Agent", goal=INSTRUCTIONS,
|
||||
tools=[crewai_tool("Weather Tool")(get_weather)], backstory="Test",
|
||||
)
|
||||
return create
|
||||
|
||||
|
||||
# key -> (display name, PyPI package, builder)
|
||||
FRAMEWORKS = {
|
||||
'praisonai': ('PraisonAI', 'praisonaiagents', build_praisonai),
|
||||
'praisonai_litellm': ('PraisonAI (LiteLLM)', 'praisonaiagents', build_praisonai_litellm),
|
||||
'agno': ('Agno', 'agno', build_agno),
|
||||
'pydantic': ('PydanticAI', 'pydantic-ai-slim', build_pydantic),
|
||||
'openai_agents': ('OpenAI Agents SDK', 'openai-agents', build_openai_agents),
|
||||
'langgraph': ('LangGraph', 'langgraph', build_langgraph),
|
||||
'crewai': ('CrewAI', 'crewai', build_crewai),
|
||||
}
|
||||
|
||||
|
||||
def get_package_versions(keys=None):
|
||||
"""Get version numbers for benchmarked packages."""
|
||||
from importlib.metadata import version as get_version
|
||||
|
||||
packages = {
|
||||
'PraisonAI': 'praisonaiagents',
|
||||
'Agno': 'agno',
|
||||
'PydanticAI': 'pydantic-ai',
|
||||
'OpenAI Agents SDK': 'openai-agents',
|
||||
'LangGraph': 'langgraph',
|
||||
'CrewAI': 'crewai'
|
||||
}
|
||||
|
||||
|
||||
versions = {}
|
||||
for display_name, pkg_name in packages.items():
|
||||
for key in keys or FRAMEWORKS:
|
||||
display_name, pkg_name, _ = FRAMEWORKS[key]
|
||||
if pkg_name in versions:
|
||||
continue
|
||||
try:
|
||||
versions[display_name] = get_version(pkg_name)
|
||||
versions[pkg_name] = get_version(pkg_name)
|
||||
except Exception:
|
||||
versions[display_name] = 'not installed'
|
||||
|
||||
versions[pkg_name] = 'not installed'
|
||||
return versions
|
||||
|
||||
|
||||
def save_benchmark_results(results: dict, baseline):
|
||||
"""Save benchmark results to BENCHMARK_RESULTS.md and update README.md"""
|
||||
def get_machine_info() -> dict:
|
||||
"""Describe the machine so results are traceable."""
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
benchmarks_dir = os.path.dirname(__file__)
|
||||
filepath = os.path.join(benchmarks_dir, 'BENCHMARK_RESULTS.md')
|
||||
|
||||
# Get package versions
|
||||
versions = get_package_versions()
|
||||
|
||||
display_names = {
|
||||
'agno': 'Agno',
|
||||
'praisonai': 'PraisonAI',
|
||||
'praisonai_litellm': 'PraisonAI (LiteLLM)',
|
||||
'pydantic': 'PydanticAI',
|
||||
'openai_agents': 'OpenAI Agents SDK',
|
||||
'langgraph': 'LangGraph',
|
||||
'crewai': 'CrewAI'
|
||||
import platform
|
||||
|
||||
load = os.getloadavg()[0] if hasattr(os, 'getloadavg') else None
|
||||
return {
|
||||
'Platform': platform.platform(),
|
||||
'Processor': platform.processor() or platform.machine(),
|
||||
'CPUs': os.cpu_count(),
|
||||
'Python': platform.python_version(),
|
||||
'Load avg (1 min) at start': f'{load:.2f}' if load is not None else 'n/a',
|
||||
}
|
||||
|
||||
# Build table rows
|
||||
table_rows = []
|
||||
sorted_results = sorted(results.items(), key=lambda x: x[1].avg_run_time)
|
||||
|
||||
for name, result in sorted_results:
|
||||
display_name = display_names.get(name, name)
|
||||
avg_us = result.avg_run_time * 1e6
|
||||
ratio = result.avg_run_time / baseline.avg_run_time if baseline.avg_run_time > 0 else 0
|
||||
|
||||
if name == 'praisonai':
|
||||
table_rows.append(f'| **{display_name}** | **{avg_us:.2f}** | **1.00x (fastest)** |')
|
||||
else:
|
||||
avg_str = f'{avg_us:,.2f}' if avg_us >= 1000 else f'{avg_us:.2f}'
|
||||
ratio_str = f'{ratio:,.0f}x' if ratio >= 100 else f'{ratio:.2f}x'
|
||||
table_rows.append(f'| {display_name} | {avg_str} | {ratio_str} |')
|
||||
|
||||
# Save to BENCHMARK_RESULTS.md
|
||||
|
||||
|
||||
def rank_results(results: dict) -> list:
|
||||
"""Return ``(key, result, ratio)`` sorted fastest first, ratio vs the fastest."""
|
||||
ranked = sorted(results.items(), key=lambda x: x[1].avg_run_time)
|
||||
fastest = ranked[0][1].avg_run_time if ranked else 0
|
||||
return [(k, r, r.avg_run_time / fastest if fastest > 0 else 0) for k, r in ranked]
|
||||
|
||||
|
||||
def save_benchmark_results(results: dict, machine: dict, iterations: int, warmup: int,
|
||||
filename: str = 'BENCHMARK_RESULTS.md') -> str:
|
||||
"""Write every framework's results from this single run to ``BENCHMARK_RESULTS.md``."""
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
filepath = os.path.join(os.path.dirname(__file__), filename)
|
||||
versions = get_package_versions(results.keys())
|
||||
|
||||
def us(seconds):
|
||||
value = seconds * 1e6
|
||||
return f'{value:,.2f}' if value >= 1000 else f'{value:.2f}'
|
||||
|
||||
lines = [
|
||||
'# PraisonAI Agents - Benchmark Results', '',
|
||||
f'**Generated:** {datetime.now().strftime("%Y-%m-%d")} (single run of `performance_benchmark.py`)',
|
||||
f'**Methodology:** {warmup} warmup runs + {iterations} measured iterations per framework',
|
||||
f'**Setup (every framework):** one agent, model `gpt-4o-mini`, one `get_weather` tool, '
|
||||
'no LLM call. Tool wrapping (`function_tool`, `@tool`, ...) happens inside the timed call. '
|
||||
'Frameworks that were not installed are omitted.',
|
||||
'', '## Agent Instantiation Time', '',
|
||||
'| Framework | Avg (μs) | Min (μs) | Median (μs) | Std (μs) | Avg memory (KiB) | Relative |',
|
||||
'|-----------|----------|----------|-------------|----------|------------------|----------|',
|
||||
]
|
||||
for rank, (key, r, ratio) in enumerate(rank_results(results)):
|
||||
relative = '1.00x (fastest)' if rank == 0 else (f'{ratio:,.0f}x' if ratio >= 100 else f'{ratio:.2f}x')
|
||||
lines.append(
|
||||
f'| {FRAMEWORKS[key][0]} | {us(r.avg_run_time)} | {us(r.min_run_time)} | '
|
||||
f'{us(r.median_run_time)} | {us(r.std_dev_run_time)} | '
|
||||
f'{r.avg_memory_usage * 1024:.2f} | {relative} |'
|
||||
)
|
||||
lines += ['', '## Package Versions', '', '| Package | Version |', '|---------|---------|']
|
||||
lines += [f'| {pkg} | {ver} |' for pkg, ver in versions.items()]
|
||||
lines += ['', '## Machine', '', '| Field | Value |', '|-------|-------|']
|
||||
lines += [f'| {k} | {v} |' for k, v in machine.items()]
|
||||
lines += [
|
||||
'', '## How to Reproduce', '', '```bash', 'cd src/praisonai-agents',
|
||||
'pip install -e . agno openai-agents "pydantic-ai-slim[openai]" langgraph langchain-openai crewai',
|
||||
"# OpenAI-backed constructors need a key to exist; no request is made",
|
||||
"OPENAI_API_KEY=sk-dummy PYTHONWARNINGS='ignore::DeprecationWarning' "
|
||||
f"python benchmarks/performance_benchmark.py --save --iterations {iterations} "
|
||||
f"--warmup {warmup} --only {' '.join(results)}",
|
||||
'```', '',
|
||||
'Older, superseded result files live in [`archive/`](archive/).', '',
|
||||
]
|
||||
with open(filepath, 'w') as f:
|
||||
f.write('# PraisonAI Agents - Benchmark Results\n\n')
|
||||
f.write(f'**Generated:** {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\n')
|
||||
f.write('**Methodology:** 10 warmup runs + 1000 measured iterations\n\n')
|
||||
f.write('## Agent Instantiation Time\n\n')
|
||||
f.write('| Framework | Avg Time (μs) | Relative |\n')
|
||||
f.write('|-----------|---------------|----------|\n')
|
||||
f.write('\n'.join(table_rows) + '\n')
|
||||
f.write('\n## Package Versions\n\n')
|
||||
f.write('| Package | Version |\n')
|
||||
f.write('|---------|--------|\n')
|
||||
for pkg, ver in versions.items():
|
||||
f.write(f'| {pkg} | {ver} |\n')
|
||||
f.write('\n## How to Reproduce\n\n')
|
||||
f.write('```bash\n')
|
||||
f.write('cd praisonai-agents\n')
|
||||
f.write('python benchmarks/performance_benchmark.py\n')
|
||||
f.write('```\n')
|
||||
|
||||
f.write('\n'.join(lines))
|
||||
|
||||
print(f'\nResults saved to: {filepath}')
|
||||
|
||||
# Update README.md
|
||||
readme_path = os.path.join(benchmarks_dir, '..', '..', '..', 'README.md')
|
||||
if os.path.exists(readme_path):
|
||||
with open(readme_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
new_table = '''| Framework | Avg Time (μs) | Relative |
|
||||
|-----------|---------------|----------|
|
||||
''' + '\n'.join(table_rows)
|
||||
|
||||
pattern = r'(\| Framework \| Avg Time \(μs\) \| Relative \|\n\|[-|]+\|\n)(\|[^\n]+\|\n)+'
|
||||
|
||||
if re.search(pattern, content):
|
||||
content = re.sub(pattern, new_table + '\n', content)
|
||||
|
||||
with open(readme_path, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
print(f'README.md updated: {readme_path}')
|
||||
return filepath
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='PraisonAI Agents - Comprehensive Performance Benchmark')
|
||||
parser.add_argument('--save', action='store_true', help='Save results to file')
|
||||
parser.add_argument('--save', action='store_true', help='Save results to BENCHMARK_RESULTS.md')
|
||||
parser.add_argument('--iterations', type=int, default=1000, help='Measured iterations (default: 1000)')
|
||||
parser.add_argument('--warmup', type=int, default=10, help='Warmup runs (default: 10)')
|
||||
parser.add_argument('--only', nargs='+', choices=list(FRAMEWORKS), help='Benchmark only these frameworks')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.iterations <= 0 or args.warmup < 0:
|
||||
parser.error('--iterations must be > 0 and --warmup must be >= 0')
|
||||
|
||||
machine = get_machine_info()
|
||||
|
||||
print("="*70)
|
||||
print("PraisonAI Agents - Comprehensive Performance Benchmark")
|
||||
print("="*70)
|
||||
@@ -376,156 +339,48 @@ if __name__ == "__main__":
|
||||
print(" 1. Agent instantiation time (how fast an agent object is created)")
|
||||
print(" 2. Memory footprint (how much memory an agent uses)")
|
||||
print("\nMethodology:")
|
||||
print(" - 10 warmup runs (not measured)")
|
||||
print(" - 1000 measured iterations")
|
||||
print(f" - {args.warmup} warmup runs (not measured)")
|
||||
print(f" - {args.iterations} measured iterations")
|
||||
print(" - tracemalloc for memory measurement")
|
||||
print(" - Baseline subtraction for accuracy")
|
||||
print("\nFrameworks available:")
|
||||
print(" - PraisonAI: ✅")
|
||||
print(f" - Agno: ✅")
|
||||
print(f" - PydanticAI: {'✅' if PYDANTIC_AVAILABLE else '❌'}")
|
||||
print(f" - OpenAI Agents SDK: {'✅' if OPENAI_AGENTS_AVAILABLE else '❌'}")
|
||||
print(f" - LangGraph: {'✅' if LANGGRAPH_AVAILABLE else '❌'}")
|
||||
print(f" - CrewAI: {'✅' if CREWAI_AVAILABLE else '❌'}")
|
||||
|
||||
print("\nMachine:")
|
||||
for k, v in machine.items():
|
||||
print(f" - {k}: {v}")
|
||||
|
||||
results = {}
|
||||
|
||||
# Test PraisonAI first
|
||||
try:
|
||||
print("\n" + "="*70)
|
||||
print("Testing PraisonAI Agents")
|
||||
print("="*70)
|
||||
results['praisonai'] = run_benchmark(
|
||||
instantiate_praisonai_agent,
|
||||
"PraisonAI",
|
||||
iterations=1000,
|
||||
warmup=10
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error testing PraisonAI: {e}")
|
||||
|
||||
try:
|
||||
print("\n" + "="*70)
|
||||
print("Testing PraisonAI Agents (LiteLLM)")
|
||||
print("="*70)
|
||||
results['praisonai_litellm'] = run_benchmark(
|
||||
instantiate_praisonai_litellm_agent,
|
||||
"PraisonAI (LiteLLM)",
|
||||
iterations=1000,
|
||||
warmup=10
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error testing PraisonAI (LiteLLM): {e}")
|
||||
|
||||
# Test Agno
|
||||
try:
|
||||
print("\n" + "="*70)
|
||||
print("Testing Agno")
|
||||
print("="*70)
|
||||
results['agno'] = run_benchmark(
|
||||
instantiate_agno_agent,
|
||||
"Agno",
|
||||
iterations=1000,
|
||||
warmup=10
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error testing Agno: {e}")
|
||||
|
||||
# Test PydanticAI
|
||||
if PYDANTIC_AVAILABLE:
|
||||
for key in args.only or FRAMEWORKS:
|
||||
display_name, _, builder = FRAMEWORKS[key]
|
||||
try:
|
||||
print("\n" + "="*70)
|
||||
print("Testing PydanticAI")
|
||||
print("="*70)
|
||||
results['pydantic'] = run_benchmark(
|
||||
instantiate_pydantic_agent,
|
||||
"PydanticAI Agent Instantiation",
|
||||
iterations=1000,
|
||||
warmup=10
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error testing PydanticAI: {e}")
|
||||
|
||||
# Test OpenAI Agents SDK
|
||||
if OPENAI_AGENTS_AVAILABLE:
|
||||
create = builder()
|
||||
except ImportError:
|
||||
print(f"\nSkipping {display_name}: not installed")
|
||||
continue
|
||||
try:
|
||||
print("\n" + "="*70)
|
||||
print("Testing OpenAI Agents SDK")
|
||||
print("="*70)
|
||||
results['openai_agents'] = run_benchmark(
|
||||
instantiate_openai_agents,
|
||||
"OpenAI Agents SDK Instantiation",
|
||||
iterations=1000,
|
||||
warmup=10
|
||||
)
|
||||
results[key] = run_benchmark(create, display_name, iterations=args.iterations, warmup=args.warmup)
|
||||
except Exception as e:
|
||||
print(f"Error testing OpenAI Agents SDK: {e}")
|
||||
|
||||
# Test LangGraph
|
||||
if LANGGRAPH_AVAILABLE:
|
||||
try:
|
||||
print("\n" + "="*70)
|
||||
print("Testing LangGraph")
|
||||
print("="*70)
|
||||
results['langgraph'] = run_benchmark(
|
||||
instantiate_langgraph_agent,
|
||||
"LangGraph Agent Instantiation",
|
||||
iterations=1000,
|
||||
warmup=10
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error testing LangGraph: {e}")
|
||||
|
||||
# Test CrewAI
|
||||
if CREWAI_AVAILABLE:
|
||||
try:
|
||||
print("\n" + "="*70)
|
||||
print("Testing CrewAI")
|
||||
print("="*70)
|
||||
results['crewai'] = run_benchmark(
|
||||
instantiate_crewai_agent,
|
||||
"CrewAI Agent Instantiation",
|
||||
iterations=1000,
|
||||
warmup=10
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error testing CrewAI: {e}")
|
||||
|
||||
print(f"Error testing {display_name}: {e}")
|
||||
|
||||
if not results:
|
||||
print("\nNo framework could be benchmarked.")
|
||||
return 1
|
||||
|
||||
# Final Comparison Table
|
||||
print("\n" + "="*70)
|
||||
print("RESULTS SUMMARY")
|
||||
print("="*70)
|
||||
|
||||
if 'praisonai' in results:
|
||||
baseline = results['praisonai']
|
||||
|
||||
print(f"\n{'Framework':<25} {'Avg Time (μs)':<15} {'Avg Memory (KiB)':<18} {'Relative':<15}")
|
||||
print("-" * 73)
|
||||
|
||||
# Sort by time
|
||||
sorted_results = sorted(results.items(), key=lambda x: x[1].avg_run_time)
|
||||
|
||||
for name, result in sorted_results:
|
||||
time_ratio = result.avg_run_time / baseline.avg_run_time if baseline.avg_run_time > 0 else 0
|
||||
|
||||
time_str = f"{time_ratio:.2f}x"
|
||||
|
||||
display_name = {
|
||||
'agno': 'Agno',
|
||||
'praisonai': 'PraisonAI',
|
||||
'praisonai_litellm': 'PraisonAI (LiteLLM)',
|
||||
'pydantic': 'PydanticAI',
|
||||
'openai_agents': 'OpenAI Agents SDK',
|
||||
'langgraph': 'LangGraph',
|
||||
'crewai': 'CrewAI'
|
||||
}.get(name, name)
|
||||
|
||||
print(f"{display_name:<25} {result.avg_run_time*1e6:<15.2f} {result.avg_memory_usage*1024:<18.2f} {time_str:<15}")
|
||||
|
||||
print("\n" + "="*70)
|
||||
|
||||
# Save results to files
|
||||
if args.save:
|
||||
save_benchmark_results(results, baseline)
|
||||
else:
|
||||
print('\nResults not saved (use --save flag to save results to file)')
|
||||
print(f"\n{'Framework':<25} {'Avg Time (μs)':<15} {'Median (μs)':<13} {'Avg Memory (KiB)':<18} {'Relative':<15}")
|
||||
print("-" * 86)
|
||||
for key, result, ratio in rank_results(results):
|
||||
print(f"{FRAMEWORKS[key][0]:<25} {result.avg_run_time*1e6:<15.2f} {result.median_run_time*1e6:<13.2f} "
|
||||
f"{result.avg_memory_usage*1024:<18.2f} {ratio:.2f}x")
|
||||
print("\n" + "="*70)
|
||||
|
||||
if args.save:
|
||||
save_benchmark_results(results, machine, args.iterations, args.warmup)
|
||||
else:
|
||||
print('\nResults not saved (use --save flag to save results to file)')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -188,15 +188,17 @@ def get_package_versions():
|
||||
return versions
|
||||
|
||||
|
||||
def save_results(results: dict, filename: str = 'BENCHMARK_RESULTS.md', *,
|
||||
def save_results(results: dict, filename: str = 'SIMPLE_BENCHMARK_RESULTS.md', *,
|
||||
iterations: int = ITERATIONS, log=print):
|
||||
"""Save benchmark results to a markdown file and update README.
|
||||
"""Save benchmark results to a markdown file.
|
||||
|
||||
Writes ``SIMPLE_BENCHMARK_RESULTS.md`` by default so the curated
|
||||
``BENCHMARK_RESULTS.md`` (from ``performance_benchmark.py``) is never
|
||||
overwritten.
|
||||
|
||||
Status messages are emitted via ``log`` (defaults to ``print``). Pass a
|
||||
stderr logger when stdout must stay machine-readable (e.g. ``--json``).
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
filepath = os.path.join(os.path.dirname(__file__), filename)
|
||||
@@ -217,7 +219,6 @@ def save_results(results: dict, filename: str = 'BENCHMARK_RESULTS.md', *,
|
||||
ratio_str = f'{ratio:,.0f}x' if ratio >= 100 else f'{ratio:.2f}x'
|
||||
table_rows.append(f'| {name} | {avg_str} | {ratio_str} |')
|
||||
|
||||
# Save to BENCHMARK_RESULTS.md
|
||||
with open(filepath, 'w') as f:
|
||||
f.write('# PraisonAI Agents - Benchmark Results\n\n')
|
||||
f.write(f'**Generated:** {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\n')
|
||||
@@ -239,39 +240,9 @@ def save_results(results: dict, filename: str = 'BENCHMARK_RESULTS.md', *,
|
||||
f.write('```\n')
|
||||
|
||||
log(f'\nResults saved to: {filepath}')
|
||||
|
||||
# Also update the main README.md
|
||||
readme_path = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'README.md')
|
||||
if os.path.exists(readme_path):
|
||||
update_readme(readme_path, table_rows, log=log)
|
||||
|
||||
return filepath
|
||||
|
||||
|
||||
def update_readme(readme_path: str, table_rows: list, *, log=print):
|
||||
"""Update the performance section in README.md with latest results."""
|
||||
import re
|
||||
|
||||
with open(readme_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Build the new table
|
||||
new_table = '''| Framework | Avg Time (μs) | Relative |
|
||||
|-----------|---------------|----------|
|
||||
''' + '\n'.join(table_rows)
|
||||
|
||||
# Pattern to match the performance table
|
||||
pattern = r'(\| Framework \| Avg Time \(μs\) \| Relative \|\n\|[-|]+\|\n)(\|[^\n]+\|\n)+'
|
||||
|
||||
if re.search(pattern, content):
|
||||
content = re.sub(pattern, new_table + '\n', content)
|
||||
|
||||
with open(readme_path, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
log(f'README.md updated: {readme_path}')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='PraisonAI Agents - Performance Benchmark')
|
||||
parser.add_argument('--save', action='store_true', help='Save results to file')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"praisonai_instantiation_us_max": 450,
|
||||
"praisonai_instantiation_us_max": 150,
|
||||
"iterations": 100,
|
||||
"warmup": 10
|
||||
}
|
||||
@@ -457,11 +457,16 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
self._wire_deferred_history_callback()
|
||||
|
||||
def _ensure_loop_guard(self):
|
||||
"""Lazy-create loop guard on first tool execution."""
|
||||
"""Lazy-create loop guard on first tool execution.
|
||||
|
||||
Inside a chat turn, returns that turn's own guard (same config) so
|
||||
concurrent turns on a shared agent do not share per-turn counters."""
|
||||
if self._loop_guard is None:
|
||||
from ..escalation.loop_guard import LoopGuard, LoopGuardConfig
|
||||
self._loop_guard = LoopGuard(LoopGuardConfig(enabled=True))
|
||||
return self._loop_guard
|
||||
from .chat_mixin import _current_turn_loop_guard
|
||||
turn_guard = _current_turn_loop_guard(self)
|
||||
return turn_guard if turn_guard is not None else self._loop_guard
|
||||
|
||||
def _ensure_loop_detector(self):
|
||||
"""Lazy-create the result-aware tool-loop detector on first tool call.
|
||||
@@ -6524,7 +6529,8 @@ Summary:"""
|
||||
# In async context - prefer async hook if available
|
||||
if hasattr(self._memory_instance, 'aon_memory_write'):
|
||||
# Schedule async hook without blocking (fire and forget)
|
||||
asyncio.create_task(self._memory_instance.aon_memory_write(action, memory_type, content, metadata))
|
||||
from ..utils.async_bridge import spawn_background
|
||||
spawn_background(self._memory_instance.aon_memory_write(action, memory_type, content, metadata), "aon_memory_write")
|
||||
elif hasattr(self._memory_instance, 'on_memory_write'):
|
||||
self._memory_instance.on_memory_write(action, memory_type, content, metadata)
|
||||
except RuntimeError:
|
||||
|
||||
@@ -6,6 +6,7 @@ and response processing. Extracted from agent.py for maintainability.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
@@ -24,12 +25,17 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_turns_lock = threading.Lock()
|
||||
|
||||
# Per-turn loop guard: ``[agent, LoopGuard | None]`` bound by ``_enter_turn``. Copied
|
||||
# into tool worker threads with the context, so each turn has its own counters and
|
||||
# overlapping turns on a shared agent never exhaust each other's limits.
|
||||
_turn_loop_guard = contextvars.ContextVar("praisonai_turn_loop_guard", default=None)
|
||||
|
||||
|
||||
def _enter_turn(agent, cancel):
|
||||
"""Mark a ``chat``/``achat`` turn in flight and bind its cancel token to this context."""
|
||||
with _turns_lock:
|
||||
agent._turns_in_flight = getattr(agent, "_turns_in_flight", 0) + 1
|
||||
return _active_turn_cancel.set((agent, cancel))
|
||||
return _active_turn_cancel.set((agent, cancel)), _turn_loop_guard.set([agent, None])
|
||||
|
||||
|
||||
def _exit_turn(agent, ctx_token):
|
||||
@@ -38,18 +44,36 @@ def _exit_turn(agent, ctx_token):
|
||||
return
|
||||
with _turns_lock:
|
||||
agent._turns_in_flight = max(getattr(agent, "_turns_in_flight", 1) - 1, 0)
|
||||
try:
|
||||
_active_turn_cancel.reset(ctx_token)
|
||||
except (ValueError, LookupError):
|
||||
_active_turn_cancel.set(None)
|
||||
cancel_token, guard_token = ctx_token
|
||||
for var, token in ((_active_turn_cancel, cancel_token), (_turn_loop_guard, guard_token)):
|
||||
try:
|
||||
var.reset(token)
|
||||
except (ValueError, LookupError):
|
||||
var.set(None)
|
||||
|
||||
|
||||
def _current_turn_loop_guard(agent):
|
||||
"""Return this turn's loop guard for ``agent`` (lazily created, sharing the agent
|
||||
guard's config), or ``None`` when not inside one of ``agent``'s chat turns."""
|
||||
slot = _turn_loop_guard.get()
|
||||
if slot is None or slot[0] is not agent:
|
||||
return None
|
||||
with _turns_lock:
|
||||
if slot[1] is None:
|
||||
from ..escalation.loop_guard import LoopGuard
|
||||
slot[1] = LoopGuard(agent._loop_guard.config)
|
||||
return slot[1]
|
||||
|
||||
|
||||
def _reset_loop_guard_for_turn(agent):
|
||||
"""Reset per-turn loop-guard limits unless another turn on this agent is still
|
||||
running (the guard is shared; resetting it would restart that turn's limits).
|
||||
Never force-creates the guard."""
|
||||
"""Start fresh per-turn loop-guard limits. Inside a chat turn this drops the turn's
|
||||
own guard; outside one it resets the agent-level guard. Never force-creates a guard."""
|
||||
slot = _turn_loop_guard.get()
|
||||
if slot is not None and slot[0] is agent:
|
||||
slot[1] = None
|
||||
return
|
||||
guard = getattr(agent, "_loop_guard", None)
|
||||
if guard is not None and getattr(agent, "_turns_in_flight", 0) <= 1:
|
||||
if guard is not None:
|
||||
guard.reset_turn()
|
||||
|
||||
|
||||
@@ -5291,6 +5315,19 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
def _start_stream(self, prompt: str, **kwargs) -> Generator[str, None, None]:
|
||||
"""Own the durable context for the complete streaming generator."""
|
||||
# A stream is a turn too: give it its own loop guard (as chat/achat get
|
||||
# via _enter_turn) so overlapping streams on a shared agent neither
|
||||
# exhaust nor inherit each other's per-turn limits.
|
||||
guard_token = _turn_loop_guard.set([self, None])
|
||||
try:
|
||||
yield from self._start_stream_turn(prompt, **kwargs)
|
||||
finally:
|
||||
try:
|
||||
_turn_loop_guard.reset(guard_token)
|
||||
except (ValueError, LookupError):
|
||||
_turn_loop_guard.set(None)
|
||||
|
||||
def _start_stream_turn(self, prompt: str, **kwargs) -> Generator[str, None, None]:
|
||||
# Tools go to their sandbox before the first model call, once per
|
||||
# agent. Placed on chat/achat/_start_stream rather than run/start
|
||||
# because AgentTeam and AgentFlow call these three directly -- an
|
||||
|
||||
@@ -1777,7 +1777,7 @@ Focus on creating clear, structured metadata that will help with intelligent fil
|
||||
|
||||
request = urllib.request.Request(api_url, headers=headers)
|
||||
|
||||
with urllib.request.urlopen(request) as response:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
file_data = json.loads(response.read())
|
||||
|
||||
if file_data.get('content') and file_data.get('encoding') == 'base64':
|
||||
@@ -2350,7 +2350,7 @@ This PRP provides:
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
with urllib.request.urlopen(api_url) as response:
|
||||
with urllib.request.urlopen(api_url, timeout=15) as response:
|
||||
data = json.loads(response.read())
|
||||
|
||||
file_structure = []
|
||||
|
||||
@@ -1970,11 +1970,13 @@ Write the complete compiled report:"""
|
||||
# (a never-raised local class never matches a real exception).
|
||||
class _CircuitBreakerException(Exception):
|
||||
pass
|
||||
_NeutralOutcome = _CircuitBreakerException
|
||||
try:
|
||||
from ..tools.circuit_breaker import (
|
||||
get_circuit_breaker,
|
||||
CircuitBreakerConfig,
|
||||
CircuitBreakerException as _CircuitBreakerException,
|
||||
_NeutralOutcome,
|
||||
)
|
||||
breaker_name = f"tool_{id(self)}_{function_name}"
|
||||
breaker = get_circuit_breaker(
|
||||
@@ -2002,7 +2004,17 @@ Write the complete compiled report:"""
|
||||
super().__init__(error_dict.get("error", "Tool execution failed"))
|
||||
|
||||
async def _invoke_for_breaker():
|
||||
r = await _invoke()
|
||||
# Caller errors (bad input) say nothing about the tool's health:
|
||||
# recorded as neither failure nor success (sync parity), so they
|
||||
# can neither open the breaker for other callers nor close a
|
||||
# half-open one. Unwrapped in _invoke_guarded.
|
||||
try:
|
||||
r = await _invoke()
|
||||
except (ValueError, TypeError, AttributeError) as e:
|
||||
raise _NeutralOutcome(e)
|
||||
if isinstance(r, dict) and r.get("error") and \
|
||||
r.get("_praison_retryable") is False:
|
||||
raise _NeutralOutcome(r)
|
||||
if isinstance(r, dict) and r.get("error") and \
|
||||
not r.get("approval_denied") and \
|
||||
not r.get("permission_denied") and \
|
||||
@@ -2026,6 +2038,11 @@ Write the complete compiled report:"""
|
||||
except _ToolFailure as tf:
|
||||
# Failure was counted by the breaker; return the original dict.
|
||||
return tf.error_dict
|
||||
except _NeutralOutcome as n:
|
||||
neutral = n.value
|
||||
if isinstance(neutral, BaseException):
|
||||
raise neutral
|
||||
return neutral
|
||||
|
||||
def _circuit_open_result():
|
||||
# Record the rejection as a failure so repeated open-circuit
|
||||
|
||||
@@ -70,6 +70,9 @@ class ImageAgent(Agent):
|
||||
api_version=api_version
|
||||
)
|
||||
|
||||
# Only an explicit llm=/model= names an image model; the base Agent's chat default must not leak into image calls
|
||||
self._image_llm_set = llm is not None or kwargs.get("model") is not None
|
||||
|
||||
# Lazy load litellm
|
||||
self._litellm = None
|
||||
# Configure logging based on verbose level
|
||||
@@ -124,15 +127,21 @@ class ImageAgent(Agent):
|
||||
"Please install it with: pip install litellm"
|
||||
)
|
||||
return self._litellm
|
||||
|
||||
def _image_model(self, default: str) -> str:
|
||||
"""Return the explicit ``llm=`` model name, else an image-capable ``default``."""
|
||||
if not self._image_llm_set:
|
||||
return default
|
||||
model_info = self.llm_model
|
||||
return model_info.model if hasattr(model_info, 'model') else str(model_info)
|
||||
|
||||
def generate_image(self, prompt: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Generate an image based on the provided prompt."""
|
||||
# Merge default config with any provided kwargs
|
||||
config = self.image_config.dict(exclude_none=True)
|
||||
config.update(kwargs)
|
||||
|
||||
# Get the model name robustly from the parent Agent's property
|
||||
model_info = self.llm_model
|
||||
model_name = model_info.model if hasattr(model_info, 'model') else str(model_info)
|
||||
model_name = self._image_model("dall-e-3")
|
||||
|
||||
# Use the model name in config
|
||||
config['model'] = model_name
|
||||
@@ -154,6 +163,10 @@ class ImageAgent(Agent):
|
||||
# Gemini provider doesn't support response_format parameter
|
||||
# Apply this filter if provider is explicitly 'gemini' or as fallback for gemini models
|
||||
config.pop('response_format', None)
|
||||
elif custom_llm_provider == "openai" and config.get('response_format') == "url":
|
||||
# "url" is OpenAI's default for DALL-E and is rejected by models that
|
||||
# don't accept response_format (e.g. gpt-image), so don't send it
|
||||
config.pop('response_format', None)
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
@@ -260,7 +273,7 @@ class ImageAgent(Agent):
|
||||
import litellm
|
||||
litellm.telemetry = False
|
||||
|
||||
model_name = self.llm or self.model or "dall-e-2"
|
||||
model_name = self._image_model("dall-e-2")
|
||||
|
||||
config = {
|
||||
"model": model_name,
|
||||
@@ -335,7 +348,7 @@ class ImageAgent(Agent):
|
||||
import litellm
|
||||
litellm.telemetry = False
|
||||
|
||||
model_name = self.llm or self.model or "dall-e-2"
|
||||
model_name = self._image_model("dall-e-2")
|
||||
|
||||
config = {
|
||||
"model": model_name,
|
||||
|
||||
@@ -301,10 +301,9 @@ class RouterAgent(Agent):
|
||||
Returns:
|
||||
Task execution result
|
||||
"""
|
||||
# Estimate context size in tokens (rough estimate: ~4 chars per token)
|
||||
# This is a simplified heuristic; actual tokenization varies by model
|
||||
text_length = len(task_description) + (len(context) if context else 0)
|
||||
context_size = text_length // 4 # Approximate token count
|
||||
# Estimate context size in tokens (canonical heuristic, CJK-aware)
|
||||
from ..context.tokens import estimate_tokens_heuristic
|
||||
context_size = estimate_tokens_heuristic(task_description + (context or ""))
|
||||
|
||||
# Select the best model for this task
|
||||
selected_model = self._select_model_for_task(
|
||||
|
||||
@@ -2174,22 +2174,25 @@ class ToolExecutionMixin:
|
||||
Returns:
|
||||
Estimated minimum cost in USD for the call.
|
||||
"""
|
||||
prompt_chars = 0
|
||||
texts = []
|
||||
if messages:
|
||||
for _msg in messages:
|
||||
_content = _msg.get("content") if isinstance(_msg, dict) else None
|
||||
if isinstance(_content, str):
|
||||
prompt_chars += len(_content)
|
||||
texts.append(_content)
|
||||
elif isinstance(_content, list):
|
||||
for _part in _content:
|
||||
if isinstance(_part, dict):
|
||||
_text = _part.get("text")
|
||||
if isinstance(_text, str):
|
||||
prompt_chars += len(_text)
|
||||
texts.append(_text)
|
||||
elif _content is not None:
|
||||
prompt_chars += len(str(_content))
|
||||
texts.append(str(_content))
|
||||
|
||||
prompt_tokens = prompt_chars // 4
|
||||
from ..context.tokens import estimate_tokens_heuristic
|
||||
prompt_text = "".join(texts)
|
||||
prompt_chars = len(prompt_text)
|
||||
prompt_tokens = estimate_tokens_heuristic(prompt_text)
|
||||
if max_tokens:
|
||||
completion_tokens = int(max_tokens)
|
||||
elif prompt_chars > 0:
|
||||
@@ -3298,7 +3301,7 @@ class ToolExecutionMixin:
|
||||
"""
|
||||
# Import circuit breaker components first (lazy import for performance)
|
||||
try:
|
||||
from ..tools.circuit_breaker import get_circuit_breaker, CircuitBreakerConfig, CircuitBreakerException
|
||||
from ..tools.circuit_breaker import get_circuit_breaker, CircuitBreakerConfig, CircuitBreakerException, _NeutralOutcome
|
||||
except ImportError:
|
||||
# Circuit breaker not available - fallback to direct execution
|
||||
logging.debug("Circuit breaker not available, falling back to direct tool execution")
|
||||
@@ -3327,6 +3330,13 @@ class ToolExecutionMixin:
|
||||
# Execute tool through circuit breaker with failure detection wrapper
|
||||
def _tool_wrapper():
|
||||
result = self._execute_tool_impl(function_name, arguments)
|
||||
# Caller errors (bad input: ValueError/TypeError/AttributeError,
|
||||
# tagged non-retryable) say nothing about the tool's health: record
|
||||
# them as neither failure nor success, so they can neither open the
|
||||
# breaker for other callers nor close a half-open one.
|
||||
if isinstance(result, dict) and result.get("error") and \
|
||||
result.get("_praison_retryable") is False:
|
||||
raise _NeutralOutcome(result)
|
||||
# Convert error dicts to exceptions so circuit breaker can detect failures
|
||||
# Don't treat approval/permission denials as circuit breaker failures
|
||||
if isinstance(result, dict) and result.get("error") and \
|
||||
@@ -3345,6 +3355,8 @@ class ToolExecutionMixin:
|
||||
|
||||
try:
|
||||
return breaker.call(_tool_wrapper)
|
||||
except _NeutralOutcome as n:
|
||||
return n.value
|
||||
except Exception as e:
|
||||
# Check if this is our sentinel exception
|
||||
if hasattr(e, 'error_dict'):
|
||||
|
||||
@@ -336,17 +336,27 @@ class PermissionAllowlist:
|
||||
if not perm.session_only
|
||||
}
|
||||
}
|
||||
from ..utils.atomic_io import atomic_write_json
|
||||
os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True)
|
||||
with open(filepath, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
atomic_write_json(filepath, data, indent=2)
|
||||
|
||||
@classmethod
|
||||
def load(cls, filepath: str) -> "PermissionAllowlist":
|
||||
allowlist = cls()
|
||||
if not os.path.exists(filepath):
|
||||
return allowlist
|
||||
with open(filepath, "r") as f:
|
||||
data = json.load(f)
|
||||
try:
|
||||
with open(filepath, "r") as f:
|
||||
data = json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
# Fail closed: a corrupt allowlist grants nothing.
|
||||
from ..utils.atomic_io import quarantine_corrupt_file
|
||||
try:
|
||||
dest = quarantine_corrupt_file(filepath)
|
||||
logger.warning("Corrupt permission allowlist %s moved to %s; starting empty", filepath, dest)
|
||||
except OSError:
|
||||
logger.warning("Corrupt permission allowlist %s; ignoring it", filepath)
|
||||
return allowlist
|
||||
for tool_name, tool_data in data.get("tools", {}).items():
|
||||
allowlist.add_tool(tool_name, paths=tool_data.get("allowed_paths", []), session_only=tool_data.get("session_only", False))
|
||||
return allowlist
|
||||
|
||||
@@ -91,6 +91,24 @@ PERMISSION_PRESETS = {
|
||||
"off": frozenset(),
|
||||
}
|
||||
|
||||
# Optional host hook (Issue #5912): a runner that bounds concurrent work (e.g.
|
||||
# the gateway's admission gate) binds a zero-arg factory here for the duration
|
||||
# of an admitted turn. The registry enters ``factory()`` — a sync *and* async
|
||||
# context manager — around the backend's human wait so a turn parked on a
|
||||
# person does not occupy a run slot. Unbound (the default) is a no-op.
|
||||
HUMAN_WAIT_SCOPE: contextvars.ContextVar[Optional[Callable[[], object]]] = contextvars.ContextVar(
|
||||
"praisonai_approval_human_wait_scope", default=None
|
||||
)
|
||||
|
||||
|
||||
def _human_wait_scope() -> object:
|
||||
factory = HUMAN_WAIT_SCOPE.get()
|
||||
if factory is None:
|
||||
import contextlib
|
||||
return contextlib.nullcontext()
|
||||
return factory()
|
||||
|
||||
|
||||
class ApprovalRegistry:
|
||||
"""Per-agent approval configuration.
|
||||
|
||||
@@ -671,16 +689,17 @@ class ApprovalRegistry:
|
||||
liveness=liveness,
|
||||
)
|
||||
|
||||
# Prefer sync method if available
|
||||
if hasattr(backend, "request_approval_sync"):
|
||||
decision = backend.request_approval_sync(request)
|
||||
else:
|
||||
# Use shared utility for consistent async-to-sync bridging
|
||||
from .utils import run_coroutine_safely
|
||||
decision = run_coroutine_safely(
|
||||
backend.request_approval(request),
|
||||
timeout=self.timeout
|
||||
)
|
||||
with _human_wait_scope():
|
||||
# Prefer sync method if available
|
||||
if hasattr(backend, "request_approval_sync"):
|
||||
decision = backend.request_approval_sync(request)
|
||||
else:
|
||||
# Use shared utility for consistent async-to-sync bridging
|
||||
from .utils import run_coroutine_safely
|
||||
decision = run_coroutine_safely(
|
||||
backend.request_approval(request),
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
# Live-authority gate: a resolution that lands after the originating
|
||||
# turn was stopped/superseded is dropped fail-closed — the tool does not
|
||||
@@ -746,10 +765,11 @@ class ApprovalRegistry:
|
||||
)
|
||||
|
||||
try:
|
||||
decision = await asyncio.wait_for(
|
||||
backend.request_approval(request),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
async with _human_wait_scope():
|
||||
decision = await asyncio.wait_for(
|
||||
backend.request_approval(request),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
decision = ApprovalDecision(approved=False, reason="Approval timed out")
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from enum import Enum
|
||||
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
|
||||
from dataclasses import dataclass, field
|
||||
from concurrent.futures import CancelledError, ThreadPoolExecutor, Future
|
||||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||||
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
@@ -148,12 +149,18 @@ class BackgroundJobManager:
|
||||
"""
|
||||
if self._store is None:
|
||||
return
|
||||
try:
|
||||
self._store.upsert(job)
|
||||
except Exception as e: # noqa: BLE001 — persistence must never crash worker
|
||||
logger.debug(
|
||||
"Persisting job %s failed (non-fatal): %s", job.job_id, e,
|
||||
)
|
||||
# Check-and-write under the lock: a finished job whose id was reused
|
||||
# must not overwrite the newer job's persisted record.
|
||||
with self._lock:
|
||||
current = self._jobs.get(job.job_id)
|
||||
if current is not None and current is not job:
|
||||
return
|
||||
try:
|
||||
self._store.upsert(job)
|
||||
except Exception as e: # noqa: BLE001 — persistence must never crash worker
|
||||
logger.debug(
|
||||
"Persisting job %s failed (non-fatal): %s", job.job_id, e,
|
||||
)
|
||||
|
||||
@property
|
||||
def active_jobs(self) -> int:
|
||||
@@ -192,14 +199,21 @@ class BackgroundJobManager:
|
||||
|
||||
Returns:
|
||||
The job ID
|
||||
|
||||
Raises:
|
||||
ValueError: If ``job_id`` belongs to a job that is still pending
|
||||
or running. Ids of terminal jobs may be reused.
|
||||
"""
|
||||
job_id = job_id or str(uuid.uuid4())[:8]
|
||||
job_id = job_id or uuid.uuid4().hex[:12]
|
||||
|
||||
# Opportunistically evict old terminal jobs so long-lived hosts
|
||||
# (gateways, bots, servers) don't retain every finished result.
|
||||
now = time.time()
|
||||
if now - self._last_gc > self._GC_INTERVAL:
|
||||
self._last_gc = now
|
||||
with self._lock:
|
||||
run_gc = now - self._last_gc > self._GC_INTERVAL
|
||||
if run_gc:
|
||||
self._last_gc = now
|
||||
if run_gc:
|
||||
self.cleanup_completed()
|
||||
|
||||
job_info = JobInfo(
|
||||
@@ -208,8 +222,19 @@ class BackgroundJobManager:
|
||||
origin=dict(origin) if origin else {},
|
||||
)
|
||||
|
||||
# Result handle owned by the manager (not the executor) so cancel_job
|
||||
# can wake get_result waiters even while ``func`` is still running.
|
||||
done: Future = Future()
|
||||
with self._lock:
|
||||
existing = self._jobs.get(job_id)
|
||||
if existing is not None and existing.status in (
|
||||
JobStatus.PENDING, JobStatus.RUNNING,
|
||||
):
|
||||
raise ValueError(f"Job '{job_id}' is already active")
|
||||
# Publish job and future together so a concurrent cancel_job never
|
||||
# sees the new job paired with the old future.
|
||||
self._jobs[job_id] = job_info
|
||||
self._futures[job_id] = done
|
||||
# Persist the PENDING record before it starts so a crash between spawn
|
||||
# and first run still leaves a recoverable trace (reconciled as LOST).
|
||||
self._persist(job_info)
|
||||
@@ -224,10 +249,7 @@ class BackgroundJobManager:
|
||||
a missing hooks package or a raising subscriber never affects the
|
||||
job's own result.
|
||||
"""
|
||||
with self._lock:
|
||||
info = self._jobs.get(job_id)
|
||||
if info is None:
|
||||
return
|
||||
info = job_info
|
||||
try:
|
||||
from praisonaiagents.hooks import fire_hook
|
||||
|
||||
@@ -256,10 +278,7 @@ class BackgroundJobManager:
|
||||
_emit_job_completed()
|
||||
if on_complete is None:
|
||||
return
|
||||
with self._lock:
|
||||
info = self._jobs.get(job_id)
|
||||
if info is None:
|
||||
return
|
||||
info = job_info
|
||||
try:
|
||||
on_complete(info)
|
||||
# Record successful deliver-back so restart reconciliation does
|
||||
@@ -273,37 +292,88 @@ class BackgroundJobManager:
|
||||
job_id, e,
|
||||
)
|
||||
|
||||
def _run_job():
|
||||
# The worker writes only to its own ``job_info`` (never re-looked-up by
|
||||
# id) so a later job reusing the id, or eviction, cannot be corrupted.
|
||||
job = job_info
|
||||
|
||||
def _abort(error: BaseException, cancelled: bool = False) -> None:
|
||||
"""Move a still-active job to a terminal state and resolve ``done``."""
|
||||
with self._lock:
|
||||
job = self._jobs[job_id]
|
||||
if job.status not in (JobStatus.PENDING, JobStatus.RUNNING):
|
||||
return
|
||||
job.status = JobStatus.CANCELLED if cancelled else JobStatus.FAILED
|
||||
job.completed_at = time.time()
|
||||
if not cancelled:
|
||||
# Bare SystemExit()/KeyboardInterrupt() stringify to "".
|
||||
job.error = str(error) or type(error).__name__
|
||||
if cancelled:
|
||||
done.cancel()
|
||||
else:
|
||||
done.set_exception(error)
|
||||
self._persist(job)
|
||||
# Failures must reach deliver-back/hook subscribers like any other
|
||||
# failure; cancellations stay silent, matching cancel_job().
|
||||
if not cancelled:
|
||||
_fire_complete()
|
||||
|
||||
def _execute():
|
||||
with self._lock:
|
||||
if job.status == JobStatus.CANCELLED: # cancelled while pending
|
||||
return
|
||||
job.status = JobStatus.RUNNING
|
||||
job.started_at = time.time()
|
||||
self._persist(job)
|
||||
|
||||
|
||||
try:
|
||||
result = func()
|
||||
with self._lock:
|
||||
job = self._jobs[job_id]
|
||||
job.status = JobStatus.COMPLETED
|
||||
job.completed_at = time.time()
|
||||
job.result = result
|
||||
self._persist(job)
|
||||
_fire_complete()
|
||||
return result
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
job = self._jobs[job_id]
|
||||
if job.status == JobStatus.CANCELLED:
|
||||
return
|
||||
job.status = JobStatus.FAILED
|
||||
job.completed_at = time.time()
|
||||
job.error = str(e)
|
||||
self._persist(job)
|
||||
try:
|
||||
_fire_complete()
|
||||
finally:
|
||||
done.set_exception(e)
|
||||
return
|
||||
with self._lock:
|
||||
# Cancelled while running: discard the late outcome.
|
||||
if job.status == JobStatus.CANCELLED:
|
||||
return
|
||||
job.status = JobStatus.COMPLETED
|
||||
job.completed_at = time.time()
|
||||
job.result = result
|
||||
self._persist(job)
|
||||
try:
|
||||
_fire_complete()
|
||||
finally:
|
||||
# Resolve even if on_complete raised a BaseException.
|
||||
done.set_result(result)
|
||||
|
||||
def _run_job():
|
||||
try:
|
||||
_execute()
|
||||
except BaseException as e:
|
||||
# ``func`` raised a non-Exception (e.g. SystemExit); never
|
||||
# strand get_result callers.
|
||||
_abort(e)
|
||||
raise
|
||||
|
||||
future = self._executor.submit(_run_job)
|
||||
with self._lock:
|
||||
self._futures[job_id] = future
|
||||
|
||||
|
||||
try:
|
||||
executor_future = self._executor.submit(_run_job)
|
||||
except BaseException as e:
|
||||
# e.g. submit after shutdown(): fail the job so its id is reusable
|
||||
# and get_result raises instead of blocking forever.
|
||||
_abort(e)
|
||||
raise
|
||||
# A queued work item dropped by the executor never runs _run_job.
|
||||
executor_future.add_done_callback(
|
||||
lambda f: _abort(CancelledError(), cancelled=True) if f.cancelled() else None
|
||||
)
|
||||
|
||||
return job_id
|
||||
|
||||
def get_status(self, job_id: str) -> JobStatus:
|
||||
@@ -362,7 +432,10 @@ class BackgroundJobManager:
|
||||
Raises:
|
||||
KeyError: If job_id is not found
|
||||
TimeoutError: If timeout is reached before completion
|
||||
RuntimeError: If job failed
|
||||
CancelledError: If the job was cancelled
|
||||
Exception: If the job failed, the original exception is re-raised
|
||||
(``RuntimeError`` with the stored error once evicted from
|
||||
memory)
|
||||
"""
|
||||
with self._lock:
|
||||
future = self._futures.get(job_id)
|
||||
@@ -379,12 +452,18 @@ class BackgroundJobManager:
|
||||
|
||||
try:
|
||||
return future.result(timeout=timeout)
|
||||
except TimeoutError:
|
||||
except FuturesTimeoutError:
|
||||
# Distinct from builtin TimeoutError on Python 3.10.
|
||||
raise TimeoutError(f"Job {job_id} did not complete within {timeout} seconds")
|
||||
|
||||
def cancel_job(self, job_id: str) -> bool:
|
||||
"""
|
||||
Attempt to cancel a job.
|
||||
|
||||
A pending job never runs. A running job is marked ``CANCELLED``
|
||||
immediately and its eventual result is discarded (no ``on_complete``
|
||||
delivery); Python threads cannot be killed, so the worker thread is
|
||||
only released once ``func`` returns.
|
||||
|
||||
Args:
|
||||
job_id: The job ID to cancel
|
||||
@@ -393,19 +472,18 @@ class BackgroundJobManager:
|
||||
True if cancellation was successful, False otherwise
|
||||
"""
|
||||
with self._lock:
|
||||
if job_id not in self._futures:
|
||||
job = self._jobs.get(job_id)
|
||||
future = self._futures.get(job_id)
|
||||
if future is None or job is None or job.status not in (
|
||||
JobStatus.PENDING, JobStatus.RUNNING,
|
||||
):
|
||||
return False
|
||||
future = self._futures[job_id]
|
||||
cancelled = future.cancel()
|
||||
if cancelled:
|
||||
self._jobs[job_id].status = JobStatus.CANCELLED
|
||||
self._jobs[job_id].completed_at = time.time()
|
||||
job = self._jobs[job_id]
|
||||
else:
|
||||
job = None
|
||||
if job is not None:
|
||||
self._persist(job)
|
||||
return cancelled
|
||||
job.status = JobStatus.CANCELLED
|
||||
job.completed_at = time.time()
|
||||
# Wakes any get_result waiter with CancelledError right away.
|
||||
future.cancel()
|
||||
self._persist(job)
|
||||
return True
|
||||
|
||||
def reconcile_on_start(
|
||||
self,
|
||||
@@ -560,6 +638,14 @@ class BackgroundJobManager:
|
||||
if job_id in self._futures:
|
||||
del self._futures[job_id]
|
||||
removed += 1
|
||||
|
||||
# Prune the durable store with the same age policy so it stays bounded.
|
||||
prune = getattr(self._store, "delete_older_than", None)
|
||||
if prune is not None:
|
||||
try:
|
||||
prune(now - max_age)
|
||||
except Exception as e: # noqa: BLE001 — pruning is best-effort
|
||||
logger.debug("Pruning background job store failed (non-fatal): %s", e)
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
@@ -160,6 +160,29 @@ class SqliteBackgroundJobStore:
|
||||
).fetchall()
|
||||
return [self._row_to_job(r) for r in rows]
|
||||
|
||||
def delete_older_than(self, ts: float) -> int:
|
||||
"""Delete terminal jobs completed before ``ts``; return rows removed.
|
||||
|
||||
Completed jobs with an undelivered ``origin`` are kept so restart
|
||||
reconciliation can still re-deliver them.
|
||||
"""
|
||||
with self._lock:
|
||||
cur = self._conn.execute(
|
||||
"""
|
||||
DELETE FROM background_jobs
|
||||
WHERE completed_at IS NOT NULL AND completed_at < ?
|
||||
AND status NOT IN (?, ?)
|
||||
AND NOT (status = ? AND delivered = 0 AND origin IS NOT NULL)
|
||||
""",
|
||||
(
|
||||
ts,
|
||||
JobStatus.PENDING.value,
|
||||
JobStatus.RUNNING.value,
|
||||
JobStatus.COMPLETED.value,
|
||||
),
|
||||
)
|
||||
return cur.rowcount
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the underlying database connection."""
|
||||
with self._lock:
|
||||
|
||||
@@ -307,6 +307,8 @@ def resolve_ingress_admission(
|
||||
# forwards an unset policy does not silently start replying to all group
|
||||
# traffic (a security-relevant regression).
|
||||
policy = (group_policy or "mention_only").lower()
|
||||
if policy not in _GROUP_POLICIES:
|
||||
policy = "mention_only"
|
||||
|
||||
if policy == "command_only":
|
||||
if is_command:
|
||||
@@ -339,7 +341,7 @@ def resolve_ingress_admission(
|
||||
gate=GATE_GROUP_POLICY,
|
||||
)
|
||||
|
||||
# respond_all (or any unknown policy): admit everything.
|
||||
# respond_all: admit everything.
|
||||
return IngressDecision(
|
||||
admit=True, reason_code=REASON_ALLOWED, gate=GATE_GROUP_POLICY
|
||||
)
|
||||
@@ -50,13 +50,15 @@ Example::
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import re
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Literal, Optional, Union
|
||||
|
||||
from .protocols import PlatformCapabilities
|
||||
from .protocols import HealthResult, PlatformCapabilities, ProbeResult
|
||||
|
||||
__all__ = [
|
||||
"SendErrorKind",
|
||||
@@ -469,6 +471,12 @@ class BasePlatformAdapter(ABC):
|
||||
typing heartbeat, edit/delete fallbacks, formatting). Override any default
|
||||
only when the platform can do better.
|
||||
|
||||
The base also satisfies the runtime surface ``Bot``/``BotOS`` drive
|
||||
(``start`` / ``stop`` / ``send_message`` / ``probe`` / ``health``) with
|
||||
defaults built on those primitives, so a minimal subclass runs under the
|
||||
gateway as-is. ``Bot`` builds adapters with ``token=`` / ``agent=`` /
|
||||
``config=`` keyword arguments, so a subclass ``__init__`` should accept them.
|
||||
|
||||
Declare platform features by setting the class attribute
|
||||
:attr:`capabilities` to a :class:`PlatformCapabilities` instance. The
|
||||
default delivery path reads capabilities via ``getattr`` so it degrades
|
||||
@@ -550,20 +558,110 @@ class BasePlatformAdapter(ABC):
|
||||
``start`` should run its inbound source (poll/listen/socket) and only
|
||||
return when the channel is stopped cleanly, raising on an unexpected
|
||||
drop so the supervisor can reconnect with backoff. Concrete adapters
|
||||
override this with their platform run loop.
|
||||
with a blocking platform loop override this.
|
||||
|
||||
Default: :meth:`connect` (passing ``is_reconnect`` on supervised
|
||||
restarts), raise if it fails so the supervisor backs off, then hold
|
||||
until :meth:`stop` — suitable for push/callback transports whose
|
||||
``connect`` registers their inbound listener.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"adapter must implement start() to run its inbound loop"
|
||||
)
|
||||
is_reconnect = getattr(self, "_base_started_at", None) is not None
|
||||
# Created before connecting so a stop() racing connect() is not lost.
|
||||
stop_event = self._base_stop_event = asyncio.Event()
|
||||
# Until connect() returns, a stop()'s disconnect may precede the
|
||||
# connection, so teardown is still owed.
|
||||
stopped_early = True
|
||||
try:
|
||||
if not await self.connect(is_reconnect=is_reconnect):
|
||||
raise ConnectionError(f"{type(self).__name__}.connect() failed")
|
||||
stopped_early = stop_event.is_set()
|
||||
self._base_started_at = time.time()
|
||||
self._base_connected = True
|
||||
await stop_event.wait()
|
||||
finally:
|
||||
self._base_connected = False
|
||||
# Cancelled by the supervisor, failed, or stopped mid-connect:
|
||||
# stop() did not (effectively) disconnect, so do it here.
|
||||
if stopped_early or not stop_event.is_set():
|
||||
stop_event.set()
|
||||
with contextlib.suppress(Exception):
|
||||
await self.disconnect()
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Signal the inbound loop to stop and tear down the connection.
|
||||
|
||||
Default delegates to :meth:`disconnect`. Adapters with an explicit
|
||||
stop event override this to unblock their run loop first.
|
||||
Default releases the default :meth:`start` wait, then delegates to
|
||||
:meth:`disconnect`. Adapters with their own run loop override this to
|
||||
unblock it first.
|
||||
"""
|
||||
event = getattr(self, "_base_stop_event", None)
|
||||
if event is not None:
|
||||
event.set()
|
||||
await self.disconnect()
|
||||
|
||||
def _base_running(self) -> bool:
|
||||
return getattr(self, "_base_connected", False)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
channel_id: Any,
|
||||
content: Union[str, Dict[str, Any]],
|
||||
reply_to: Optional[str] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
) -> SendResult:
|
||||
"""Runtime send entry point (``Bot.send_message``).
|
||||
|
||||
Default routes through :meth:`deliver` so formatting, chunking, typing
|
||||
and retry apply; ``thread_id`` is forwarded in ``metadata``.
|
||||
"""
|
||||
return await self.deliver(
|
||||
channel_id,
|
||||
content,
|
||||
reply_to=reply_to,
|
||||
metadata={"thread_id": thread_id} if thread_id else None,
|
||||
)
|
||||
|
||||
async def probe(self) -> ProbeResult:
|
||||
"""Check connectivity/credentials without processing messages.
|
||||
|
||||
Default: ok while the default :meth:`start` is running; otherwise a
|
||||
:meth:`connect` / :meth:`disconnect` round-trip.
|
||||
"""
|
||||
platform = getattr(self, "platform_name", "") or ""
|
||||
if self._base_running():
|
||||
return ProbeResult(ok=True, platform=platform)
|
||||
t0 = time.monotonic()
|
||||
error: Optional[str] = None
|
||||
try:
|
||||
ok = bool(await self.connect())
|
||||
if not ok:
|
||||
error = "connect() returned False"
|
||||
except Exception as exc:
|
||||
ok, error = False, str(exc)
|
||||
finally:
|
||||
# Also on failure/cancellation (preflight timeout), so a partially
|
||||
# opened client or listener is released.
|
||||
with contextlib.suppress(Exception):
|
||||
await self.disconnect()
|
||||
return ProbeResult(
|
||||
ok=ok,
|
||||
platform=platform,
|
||||
elapsed_ms=(time.monotonic() - t0) * 1000,
|
||||
error=error,
|
||||
)
|
||||
|
||||
async def health(self) -> HealthResult:
|
||||
"""Report health. Default reflects the default run state and uptime."""
|
||||
running = self._base_running()
|
||||
started = getattr(self, "_base_started_at", None)
|
||||
return HealthResult(
|
||||
ok=running,
|
||||
platform=getattr(self, "platform_name", "") or "",
|
||||
is_running=running,
|
||||
uptime_seconds=(time.time() - started) if running and started else None,
|
||||
error=None if running else "not running",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Identity canonicalization seam. #
|
||||
# #
|
||||
|
||||
@@ -8,7 +8,7 @@ import asyncio
|
||||
from praisonaiagents._logging import get_logger
|
||||
import threading
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Union
|
||||
from ..utils.async_bridge import run_coroutine_from_any_context
|
||||
from ..utils.async_bridge import run_coroutine_from_any_context, spawn_background
|
||||
from dataclasses import dataclass, field
|
||||
from .event import Event, EventType
|
||||
|
||||
@@ -235,11 +235,14 @@ class EventBus:
|
||||
if sub.is_async:
|
||||
# Schedule async callback safely
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(sub.callback(event))
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No running loop, use safe async bridge
|
||||
run_coroutine_from_any_context(sub.callback(event))
|
||||
else:
|
||||
# Keep a strong ref and log failures under this
|
||||
# subscriber's id, like the sync path below.
|
||||
spawn_background(sub.callback(event), f"subscriber {sub.id}")
|
||||
else:
|
||||
sub.callback(event)
|
||||
except Exception as e:
|
||||
|
||||
@@ -708,12 +708,17 @@ def _config_write_target() -> Path:
|
||||
|
||||
def _dump_config_file(path: Path, data: Dict[str, Any]) -> None:
|
||||
"""Write ``data`` to ``path`` as YAML or TOML based on its suffix."""
|
||||
from ..utils.atomic_io import atomic_write_text
|
||||
|
||||
# Write through a symlinked config so the shared target gets the update,
|
||||
# and keep an existing file's mode (the atomic temp file is owner-only).
|
||||
path = path.resolve()
|
||||
mode = path.stat().st_mode & 0o7777 if path.exists() else None
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.suffix.lower() in (".yaml", ".yml"):
|
||||
import yaml
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(data, f, sort_keys=False, default_flow_style=False)
|
||||
text = yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
|
||||
else:
|
||||
try:
|
||||
import tomli_w
|
||||
@@ -727,8 +732,10 @@ def _dump_config_file(path: Path, data: Dict[str, Any]) -> None:
|
||||
"Install tomli-w (pip install tomli-w), or convert the config "
|
||||
"to .praisonai/config.yaml."
|
||||
) from None
|
||||
with open(path, "wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
text = tomli_w.dumps(data)
|
||||
atomic_write_text(str(path), text)
|
||||
if mode is not None:
|
||||
os.chmod(path, mode)
|
||||
|
||||
|
||||
def set_plugin_enabled(name: str, enabled: bool) -> Path:
|
||||
|
||||
@@ -16,6 +16,7 @@ from typing import Any, Callable, Dict, Optional, Set, Type, Union
|
||||
import inspect
|
||||
|
||||
from .parse_utils import (
|
||||
canonical_preset_key,
|
||||
detect_url_scheme,
|
||||
is_path_like,
|
||||
make_preset_error,
|
||||
@@ -345,12 +346,12 @@ def _resolve_string(
|
||||
if presets:
|
||||
# Case-insensitive lookup, whitespace-tolerant and treating -/_ as
|
||||
# interchangeable so the accepted spellings match the closed-set guard
|
||||
# in validate_preset_string (which uses the same normalization). Without
|
||||
# this, a value that passed construction-time validation (e.g.
|
||||
# " sliding_window " or "sliding-window") would be rejected here.
|
||||
value_norm = value.strip().lower().replace("-", "_")
|
||||
# in validate_preset_string. Both go through canonical_preset_key, the
|
||||
# single source of truth for the spelling rule, so validation and
|
||||
# resolution cannot disagree about what counts as the same preset.
|
||||
value_norm = canonical_preset_key(value)
|
||||
for preset_key in presets:
|
||||
if preset_key.strip().lower().replace("-", "_") == value_norm:
|
||||
if canonical_preset_key(preset_key) == value_norm:
|
||||
return _apply_preset(preset_key, presets, config_class)
|
||||
|
||||
# Not a valid preset - raise helpful error
|
||||
|
||||
@@ -157,8 +157,8 @@ class FileIndex:
|
||||
"entries": {k: v.to_dict() for k, v in self.entries.items()}
|
||||
}
|
||||
|
||||
with open(self.index_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
from praisonaiagents.utils.atomic_io import atomic_write_json
|
||||
atomic_write_json(self.index_path, data, indent=2)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
|
||||
@@ -659,50 +659,52 @@ class ContextManager:
|
||||
Returns:
|
||||
Tuple of (token_count, metrics)
|
||||
"""
|
||||
# Heuristic is O(len(text)); hashing the whole text to cache it costs
|
||||
# far more than recomputing it, so only cache the tiktoken path.
|
||||
if not (validate or self.config.estimation_mode == EstimationMode.VALIDATED):
|
||||
return estimate_tokens_heuristic(text), None
|
||||
|
||||
# Check cache
|
||||
cache_key = hashlib.sha256(text.encode()).hexdigest()
|
||||
if cache_key in self._estimation_cache:
|
||||
return self._estimation_cache[cache_key], None
|
||||
|
||||
|
||||
heuristic = estimate_tokens_heuristic(text)
|
||||
metrics = None
|
||||
|
||||
if validate or self.config.estimation_mode == EstimationMode.VALIDATED:
|
||||
try:
|
||||
from .tokens import estimate_tokens_accurate
|
||||
accurate = estimate_tokens_accurate(text, self.model)
|
||||
|
||||
error_pct = abs(heuristic - accurate) / accurate * 100 if accurate > 0 else 0
|
||||
|
||||
metrics = EstimationMetrics(
|
||||
heuristic_estimate=heuristic,
|
||||
accurate_estimate=accurate,
|
||||
error_pct=error_pct,
|
||||
estimator_used=EstimationMode.VALIDATED,
|
||||
|
||||
try:
|
||||
from .tokens import estimate_tokens_accurate
|
||||
accurate = estimate_tokens_accurate(text, self.model)
|
||||
|
||||
error_pct = abs(heuristic - accurate) / accurate * 100 if accurate > 0 else 0
|
||||
|
||||
metrics = EstimationMetrics(
|
||||
heuristic_estimate=heuristic,
|
||||
accurate_estimate=accurate,
|
||||
error_pct=error_pct,
|
||||
estimator_used=EstimationMode.VALIDATED,
|
||||
)
|
||||
|
||||
self._estimation_metrics = metrics
|
||||
|
||||
# Log if mismatch exceeds threshold
|
||||
if self.config.log_estimation_mismatch and error_pct > self.config.mismatch_threshold_pct:
|
||||
import logging
|
||||
get_logger(__name__).warning(
|
||||
f"Token estimation mismatch: heuristic={heuristic}, accurate={accurate}, error={error_pct:.1f}%"
|
||||
)
|
||||
|
||||
self._estimation_metrics = metrics
|
||||
|
||||
# Log if mismatch exceeds threshold
|
||||
if self.config.log_estimation_mismatch and error_pct > self.config.mismatch_threshold_pct:
|
||||
import logging
|
||||
get_logger(__name__).warning(
|
||||
f"Token estimation mismatch: heuristic={heuristic}, accurate={accurate}, error={error_pct:.1f}%"
|
||||
)
|
||||
|
||||
# Use accurate if validated mode
|
||||
if self.config.estimation_mode == EstimationMode.ACCURATE:
|
||||
result = accurate
|
||||
else:
|
||||
result = heuristic
|
||||
except ImportError:
|
||||
|
||||
# Use accurate if validated mode
|
||||
if self.config.estimation_mode == EstimationMode.ACCURATE:
|
||||
result = accurate
|
||||
else:
|
||||
result = heuristic
|
||||
metrics = EstimationMetrics(
|
||||
heuristic_estimate=heuristic,
|
||||
estimator_used=EstimationMode.HEURISTIC,
|
||||
)
|
||||
else:
|
||||
except ImportError:
|
||||
result = heuristic
|
||||
metrics = EstimationMetrics(
|
||||
heuristic_estimate=heuristic,
|
||||
estimator_used=EstimationMode.HEURISTIC,
|
||||
)
|
||||
|
||||
# Cache result
|
||||
if len(self._estimation_cache) < self._cache_max_size:
|
||||
|
||||
@@ -228,6 +228,10 @@ _LAZY_IMPORTS = {
|
||||
"StopResult": ("praisonaiagents.gateway.protocols", "StopResult"),
|
||||
# Schema-validated inbound frame codec (Issue #2831)
|
||||
"HelloParams": ("praisonaiagents.gateway.protocols", "HelloParams"),
|
||||
# Device-identity handshake contract (Issue #5998)
|
||||
"DeviceAuth": ("praisonaiagents.gateway.protocols", "DeviceAuth"),
|
||||
"DeviceIdentityProtocol": ("praisonaiagents.gateway.protocols", "DeviceIdentityProtocol"),
|
||||
"device_id_from_public_key": ("praisonaiagents.gateway.protocols", "device_id_from_public_key"),
|
||||
"HelloResult": ("praisonaiagents.gateway.protocols", "HelloResult"),
|
||||
"HelloError": ("praisonaiagents.gateway.protocols", "HelloError"),
|
||||
"ConnectErrorCode": ("praisonaiagents.gateway.protocols", "ConnectErrorCode"),
|
||||
@@ -586,6 +590,9 @@ __all__ = [
|
||||
"StopResult",
|
||||
# Schema-validated inbound frame codec (Issue #2831)
|
||||
"HelloParams",
|
||||
"DeviceAuth",
|
||||
"DeviceIdentityProtocol",
|
||||
"device_id_from_public_key",
|
||||
"HelloResult",
|
||||
"HelloError",
|
||||
"ConnectErrorCode",
|
||||
|
||||
@@ -11,6 +11,8 @@ The ``praisonai`` wrapper provides backward-compatible shims.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import math
|
||||
import time
|
||||
import uuid
|
||||
@@ -347,10 +349,88 @@ class SessionObserverProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device identity (Issue #5998)
|
||||
#
|
||||
# A per-device keypair signs a server-issued challenge nonce so the gateway can
|
||||
# derive a stable ``device_id`` and approve/revoke devices individually instead
|
||||
# of sharing one gateway-wide secret. Core only fixes the wire shape, the
|
||||
# fingerprint and the exact bytes that are signed; signature verification,
|
||||
# device storage and per-device tokens live in the wrapper gateway.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def device_id_from_public_key(public_key: str) -> str:
|
||||
"""Return the stable device id: hex SHA-256 of the raw public-key bytes.
|
||||
|
||||
``public_key`` must be standard base64 (RFC 4648, padded, no whitespace) of
|
||||
the raw key bytes. Hashing the decoded bytes keeps the id independent of
|
||||
how a client spells the encoding. Raises ``ValueError`` on invalid base64.
|
||||
"""
|
||||
return hashlib.sha256(base64.b64decode(public_key, validate=True)).hexdigest()
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceAuth:
|
||||
"""Device-identity proof carried in a ``hello`` frame.
|
||||
|
||||
Attributes:
|
||||
device_id: Fingerprint of ``public_key`` (see :func:`device_id_from_public_key`).
|
||||
public_key: Standard base64 (padded) of the device's raw public key.
|
||||
signature: Standard base64 (padded) signature over :meth:`signing_payload`.
|
||||
signed_at: Integer Unix seconds at which the device signed the challenge
|
||||
(an integer so every client language renders identical signed bytes).
|
||||
nonce: The server-issued challenge nonce being answered.
|
||||
"""
|
||||
device_id: str
|
||||
public_key: str
|
||||
signature: str
|
||||
signed_at: int
|
||||
nonce: str
|
||||
|
||||
def signing_payload(self, agent_id: str) -> bytes:
|
||||
"""Canonical bytes the device signs and the gateway verifies.
|
||||
|
||||
Binds the nonce to this device, the signing time and the target agent so
|
||||
a captured proof cannot be replayed for another device or agent.
|
||||
"""
|
||||
return "\n".join((
|
||||
"praisonai-device-v1",
|
||||
self.device_id,
|
||||
self.nonce,
|
||||
str(int(self.signed_at)),
|
||||
agent_id,
|
||||
)).encode("utf-8")
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DeviceIdentityProtocol(Protocol):
|
||||
"""Seam for per-device challenge/response auth (implemented in the wrapper).
|
||||
|
||||
Implementations must approve only on an exact device-key match — never on
|
||||
network reachability alone — and record how each device was approved.
|
||||
"""
|
||||
|
||||
def issue_challenge(self) -> str:
|
||||
"""Return a fresh single-use challenge nonce for a connecting client."""
|
||||
...
|
||||
|
||||
def verify(self, device: DeviceAuth, *, agent_id: str) -> Optional[str]:
|
||||
"""Verify ``device`` against an issued nonce; return its role, or ``None`` if rejected."""
|
||||
...
|
||||
|
||||
def list_devices(self) -> List[Dict[str, Any]]:
|
||||
"""List known devices (id, role, approval path, last seen)."""
|
||||
...
|
||||
|
||||
def revoke(self, device_id: str) -> bool:
|
||||
"""Revoke one device; return ``False`` if it was unknown."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class HelloParams:
|
||||
"""Parameters for initiating a versioned handshake.
|
||||
|
||||
|
||||
Attributes:
|
||||
agent_id: The agent to connect to
|
||||
protocol_min: Minimum protocol version the client supports
|
||||
@@ -358,6 +438,7 @@ class HelloParams:
|
||||
capabilities: Optional list of capability tokens the client supports
|
||||
session_id: Optional session to resume
|
||||
since: Optional cursor for event replay
|
||||
device: Optional device-identity proof (Issue #5998)
|
||||
"""
|
||||
agent_id: str
|
||||
protocol_min: int
|
||||
@@ -365,6 +446,7 @@ class HelloParams:
|
||||
capabilities: List[str] = field(default_factory=list)
|
||||
session_id: Optional[str] = None
|
||||
since: Optional[int] = None
|
||||
device: Optional[DeviceAuth] = None
|
||||
|
||||
type: str = field(default="hello", init=False)
|
||||
|
||||
@@ -897,6 +979,9 @@ def _decode_hello(data: Dict[str, Any]) -> "HelloParams":
|
||||
if since is not None:
|
||||
since = _coerce_int(since, field_name="since")
|
||||
|
||||
device_data = data.get("device")
|
||||
device = _decode_device(device_data) if device_data is not None else None
|
||||
|
||||
return HelloParams(
|
||||
agent_id=agent_id,
|
||||
protocol_min=client_min,
|
||||
@@ -904,6 +989,60 @@ def _decode_hello(data: Dict[str, Any]) -> "HelloParams":
|
||||
capabilities=capabilities,
|
||||
session_id=_as_opt_str(data.get("session_id")),
|
||||
since=since,
|
||||
device=device,
|
||||
)
|
||||
|
||||
|
||||
def _decode_device(data: Any) -> DeviceAuth:
|
||||
"""Validate a ``hello.device`` block into :class:`DeviceAuth`.
|
||||
|
||||
Shape errors are configuration errors; a ``device_id`` that is not the
|
||||
fingerprint of ``public_key`` is rejected as unauthorized so a client can
|
||||
never claim another device's id.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
raise FrameDecodeError(
|
||||
HelloError(
|
||||
code=ConnectErrorCode.CONFIGURATION_ERROR,
|
||||
message="Field 'device' must be an object",
|
||||
next_step=ConnectRecoveryStep.DO_NOT_RETRY,
|
||||
)
|
||||
)
|
||||
public_key = _require_str(data.get("public_key"), field_name="device.public_key")
|
||||
device_id = _require_str(data.get("device_id"), field_name="device.device_id")
|
||||
signed_at = data.get("signed_at")
|
||||
if isinstance(signed_at, bool) or not isinstance(signed_at, int):
|
||||
raise FrameDecodeError(
|
||||
HelloError(
|
||||
code=ConnectErrorCode.CONFIGURATION_ERROR,
|
||||
message="Field 'device.signed_at' must be an integer (Unix seconds)",
|
||||
next_step=ConnectRecoveryStep.DO_NOT_RETRY,
|
||||
)
|
||||
)
|
||||
try:
|
||||
fingerprint = device_id_from_public_key(public_key)
|
||||
except ValueError:
|
||||
raise FrameDecodeError(
|
||||
HelloError(
|
||||
code=ConnectErrorCode.CONFIGURATION_ERROR,
|
||||
message="Field 'device.public_key' must be padded standard base64",
|
||||
next_step=ConnectRecoveryStep.DO_NOT_RETRY,
|
||||
)
|
||||
) from None
|
||||
if device_id != fingerprint:
|
||||
raise FrameDecodeError(
|
||||
HelloError(
|
||||
code=ConnectErrorCode.AUTH_UNAUTHORIZED,
|
||||
message="device.device_id does not match the public key fingerprint",
|
||||
next_step=ConnectRecoveryStep.REAUTHENTICATE,
|
||||
)
|
||||
)
|
||||
return DeviceAuth(
|
||||
device_id=device_id,
|
||||
public_key=public_key,
|
||||
signature=_require_str(data.get("signature"), field_name="device.signature"),
|
||||
signed_at=signed_at,
|
||||
nonce=_require_str(data.get("nonce"), field_name="device.nonce"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -417,9 +417,9 @@ class FileTracker:
|
||||
return
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self._state_file), exist_ok=True)
|
||||
with open(self._state_file, "w", encoding="utf-8") as f:
|
||||
json.dump(self._tracked, f, indent=2)
|
||||
os.makedirs(os.path.dirname(self._state_file) or ".", exist_ok=True)
|
||||
from ..utils.atomic_io import atomic_write_json
|
||||
atomic_write_json(self._state_file, self._tracked, indent=2)
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
|
||||
@@ -803,6 +803,9 @@ class Knowledge:
|
||||
same path as local files. Failures are surfaced to the caller so a
|
||||
single bad URL in a batch is visible, matching local-file behaviour.
|
||||
"""
|
||||
from ..tools.url_safety import is_safe_http_url
|
||||
if not is_safe_http_url(url):
|
||||
raise ValueError(f"Refusing to fetch unsafe URL: {url}")
|
||||
result = self.markdown.convert(url)
|
||||
content = getattr(result, 'text_content', None)
|
||||
if not content:
|
||||
|
||||
@@ -43,6 +43,14 @@ _TOOL_ARGUMENTS_PARSE_FAILED = object()
|
||||
# Bounded shrink-and-retry steps after a provider context-overflow rejection.
|
||||
_CONTEXT_OVERFLOW_SHRINK_STEPS = 3
|
||||
|
||||
# Default per-request timeout (seconds) when ``timeout`` is not passed. Without
|
||||
# it litellm falls back to its 6000s ceiling, so a provider that accepts the
|
||||
# connection but never answers blocks the turn for ~100 minutes per attempt.
|
||||
# 600s matches the OpenAI SDK default used by the bare-model-name path and is
|
||||
# a read-gap bound for streams. Pass ``timeout=None`` (or 0) to opt out.
|
||||
_DEFAULT_LLM_TIMEOUT_S = 600
|
||||
_TIMEOUT_UNSET = object()
|
||||
|
||||
|
||||
# ``litellm.success_callback``/``_async_success_callback``/``callbacks`` are
|
||||
# process-global lists shared by every LLM instance. Guard the read-modify-write
|
||||
@@ -234,6 +242,19 @@ class TokenUsage:
|
||||
'audio_output_tokens': self.audio_output_tokens,
|
||||
}
|
||||
|
||||
# error_classifier category -> (text-derived kinds that may refine it, default kind).
|
||||
_CATEGORY_KINDS = {
|
||||
"rate_limit": ({"rate_limit", "billing"}, "rate_limit"),
|
||||
"auth": ({"auth", "auth_permanent"}, "auth"),
|
||||
"context_limit": ({"context_overflow"}, "context_overflow"),
|
||||
"invalid_request": (
|
||||
{"context_overflow", "model_not_found", "auth", "auth_permanent", "billing", "format_error"},
|
||||
"format_error",
|
||||
),
|
||||
"transient": ({"overloaded", "idle_timeout"}, "overloaded"),
|
||||
"permanent": ({"billing"}, "billing"),
|
||||
}
|
||||
|
||||
class LLM:
|
||||
"""
|
||||
Easy to use wrapper for language models. Supports multiple providers like OpenAI,
|
||||
@@ -493,7 +514,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
timeout: Optional[int] = None,
|
||||
timeout: Optional[int] = _TIMEOUT_UNSET,
|
||||
temperature: Optional[float] = None,
|
||||
top_p: Optional[float] = None,
|
||||
n: Optional[int] = None,
|
||||
@@ -535,7 +556,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
)
|
||||
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
self.timeout = _DEFAULT_LLM_TIMEOUT_S if timeout is _TIMEOUT_UNSET else timeout
|
||||
self.temperature = temperature
|
||||
self.top_p = top_p
|
||||
self.n = n
|
||||
@@ -886,11 +907,12 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
else:
|
||||
return False
|
||||
|
||||
def _parse_retry_delay(self, error_message: str) -> float:
|
||||
"""Parse retry delay from rate limit error message.
|
||||
def _parse_retry_delay(self, error_message: Union[Exception, str]) -> float:
|
||||
"""Parse retry delay from a rate limit error.
|
||||
|
||||
Args:
|
||||
error_message: The error message from the API
|
||||
error_message: The provider exception (preferred, so the structured
|
||||
``Retry-After`` response header is honoured) or its message
|
||||
|
||||
Returns:
|
||||
Retry delay in seconds, or default if not found
|
||||
@@ -902,7 +924,8 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
|
||||
from .error_classifier import extract_retry_after
|
||||
|
||||
delay = extract_retry_after(Exception(error_message), cap_seconds=max_delay)
|
||||
error = error_message if isinstance(error_message, BaseException) else Exception(error_message)
|
||||
delay = extract_retry_after(error, cap_seconds=max_delay)
|
||||
return self._retry_delay if delay is None else delay
|
||||
|
||||
def _is_rate_limit_error(self, error: Exception) -> bool:
|
||||
@@ -939,15 +962,35 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
def classify_error_kind(self, error: Exception) -> AgentErrorKind:
|
||||
"""
|
||||
Classify error into typed AgentErrorKind instead of freeform strings.
|
||||
|
||||
Replaces scattered regex checks with systematic classification.
|
||||
|
||||
|
||||
The HTTP status code / exception type (via ``error_classifier``) is
|
||||
authoritative; message text only refines the kind within that category,
|
||||
so e.g. a 400 whose body happens to contain "500" is never retried as
|
||||
an overload.
|
||||
|
||||
Args:
|
||||
error: Exception to classify
|
||||
|
||||
|
||||
Returns:
|
||||
AgentErrorKind: Typed error classification
|
||||
"""
|
||||
text_kind = self._classify_error_kind_from_text(error)
|
||||
from .error_classifier import _classify_by_type_and_code, _extract_status_code
|
||||
category = _classify_by_type_and_code(error)
|
||||
if category is None:
|
||||
return text_kind
|
||||
allowed, default = _CATEGORY_KINDS[category.value]
|
||||
if text_kind in allowed:
|
||||
return text_kind
|
||||
# A 404 from a completion endpoint means the model/deployment is missing.
|
||||
if _extract_status_code(error) == 404 or type(error).__name__ == "NotFoundError":
|
||||
return "model_not_found"
|
||||
if default == "overloaded" and "timeout" in type(error).__name__.lower():
|
||||
return "idle_timeout"
|
||||
return default
|
||||
|
||||
def _classify_error_kind_from_text(self, error: Exception) -> AgentErrorKind:
|
||||
"""Message-substring fallback used when no status code / type is known."""
|
||||
error_str = str(error).lower()
|
||||
error_type = type(error).__name__.lower()
|
||||
|
||||
@@ -969,7 +1012,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
|
||||
# Billing/quota issues (must be checked before generic 429/rate-limit)
|
||||
if any(indicator in error_str for indicator in [
|
||||
"insufficient quota", "quota exceeded", "billing", "credit",
|
||||
"insufficient quota", "quota exceeded", "billing", "credit balance", "insufficient credit",
|
||||
"payment required", "subscription required", "subscription expired",
|
||||
"plan limit"
|
||||
]):
|
||||
@@ -1101,7 +1144,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
|
||||
# Rate limiting - extract retry delay
|
||||
if error_kind == "rate_limit":
|
||||
backoff = self._parse_retry_delay(str(error)) # Returns seconds
|
||||
backoff = self._parse_retry_delay(error) # Returns seconds
|
||||
if backoff == 0: # No specific delay found, use exponential backoff
|
||||
backoff = min(2 ** (attempt - 1), 60) # 1s, 2s, 4s, ... cap at 60s
|
||||
return FailoverDecision(
|
||||
|
||||
@@ -154,10 +154,19 @@ def register_approval_callback(callback_fn):
|
||||
add_display_callback = register_display_callback
|
||||
add_approval_callback = register_approval_callback
|
||||
|
||||
def _filter_callback_kwargs(callback, kwargs):
|
||||
"""Filter kwargs to what the callback accepts to maintain backward compatibility."""
|
||||
import inspect
|
||||
sig = inspect.signature(callback)
|
||||
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
|
||||
return kwargs
|
||||
return {k: v for k, v in kwargs.items() if k in sig.parameters}
|
||||
|
||||
def execute_sync_callback(display_type: str, **kwargs):
|
||||
"""Execute synchronous callback for a given display type without displaying anything.
|
||||
|
||||
This function is used to trigger callbacks even when verbose=False.
|
||||
Callback errors are logged and suppressed so observers cannot abort the run.
|
||||
|
||||
Args:
|
||||
display_type (str): Type of display event
|
||||
@@ -167,61 +176,37 @@ def execute_sync_callback(display_type: str, **kwargs):
|
||||
callback = sync_display_callbacks.get(display_type)
|
||||
|
||||
if callback:
|
||||
import inspect
|
||||
sig = inspect.signature(callback)
|
||||
|
||||
# Filter kwargs to what the callback accepts to maintain backward compatibility
|
||||
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
|
||||
# Callback accepts **kwargs, so pass all arguments
|
||||
supported_kwargs = kwargs
|
||||
else:
|
||||
# Only pass arguments that the callback signature supports
|
||||
supported_kwargs = {k: v for k, v in kwargs.items() if k in sig.parameters}
|
||||
|
||||
callback(**supported_kwargs)
|
||||
try:
|
||||
callback(**_filter_callback_kwargs(callback, kwargs))
|
||||
except Exception:
|
||||
logging.exception("Display callback for %r failed; ignored", display_type)
|
||||
|
||||
async def execute_callback(display_type: str, **kwargs):
|
||||
"""Execute both sync and async callbacks for a given display type.
|
||||
|
||||
Callback errors are logged and suppressed so observers cannot abort the run.
|
||||
|
||||
Args:
|
||||
display_type (str): Type of display event
|
||||
**kwargs: Arguments to pass to the callback functions
|
||||
"""
|
||||
import inspect
|
||||
|
||||
# Execute synchronous callback if registered
|
||||
with _callbacks_lock:
|
||||
sync_callback = sync_display_callbacks.get(display_type)
|
||||
async_callback = async_display_callbacks.get(display_type)
|
||||
|
||||
if sync_callback:
|
||||
sig = inspect.signature(sync_callback)
|
||||
|
||||
# Filter kwargs to what the callback accepts to maintain backward compatibility
|
||||
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
|
||||
# Callback accepts **kwargs, so pass all arguments
|
||||
supported_kwargs = kwargs
|
||||
else:
|
||||
# Only pass arguments that the callback signature supports
|
||||
supported_kwargs = {k: v for k, v in kwargs.items() if k in sig.parameters}
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, lambda: sync_callback(**supported_kwargs))
|
||||
try:
|
||||
supported_kwargs = _filter_callback_kwargs(sync_callback, kwargs)
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, lambda: sync_callback(**supported_kwargs))
|
||||
except Exception:
|
||||
logging.exception("Display callback for %r failed; ignored", display_type)
|
||||
|
||||
# Execute asynchronous callback if registered
|
||||
if async_callback:
|
||||
callback = async_callback
|
||||
sig = inspect.signature(callback)
|
||||
|
||||
# Filter kwargs to what the callback accepts to maintain backward compatibility
|
||||
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
|
||||
# Callback accepts **kwargs, so pass all arguments
|
||||
supported_kwargs = kwargs
|
||||
else:
|
||||
# Only pass arguments that the callback signature supports
|
||||
supported_kwargs = {k: v for k, v in kwargs.items() if k in sig.parameters}
|
||||
|
||||
await callback(**supported_kwargs)
|
||||
try:
|
||||
await async_callback(**_filter_callback_kwargs(async_callback, kwargs))
|
||||
except Exception:
|
||||
logging.exception("Async display callback for %r failed; ignored", display_type)
|
||||
|
||||
def _clean_display_content(content: str, max_length: int = 20000) -> str:
|
||||
"""Helper function to clean and truncate content for display."""
|
||||
|
||||
@@ -28,15 +28,35 @@ _LOCALHOST_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
|
||||
|
||||
def _require_https(url: str) -> None:
|
||||
"""Reject non-HTTPS metadata URLs (loopback HTTP is allowed for testing)."""
|
||||
"""Reject OAuth URLs that are not HTTPS or that point at internal hosts.
|
||||
|
||||
Loopback hosts are allowed (over HTTP too) for local testing. Any other
|
||||
host must pass :func:`~praisonaiagents.tools.url_safety.is_safe_http_url`,
|
||||
so private, link-local (cloud metadata) and ambiguous numeric hosts are
|
||||
refused even over HTTPS.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme == "https":
|
||||
if parsed.scheme in ("http", "https") and (parsed.hostname or "") in _LOCALHOST_HOSTS:
|
||||
return
|
||||
if parsed.scheme == "http" and (parsed.hostname or "") in _LOCALHOST_HOSTS:
|
||||
return
|
||||
raise ValueError(
|
||||
f"Refusing to fetch OAuth metadata over insecure transport: {url!r}"
|
||||
)
|
||||
if parsed.scheme != "https":
|
||||
raise ValueError(
|
||||
f"Refusing OAuth request over insecure transport: {url!r}"
|
||||
)
|
||||
from ..tools.url_safety import is_safe_http_url
|
||||
|
||||
if not is_safe_http_url(url):
|
||||
raise ValueError(
|
||||
f"Refusing OAuth request to a private/internal address: {url!r}"
|
||||
)
|
||||
|
||||
|
||||
def _require_success(resp: Any) -> None:
|
||||
"""Raise unless ``resp`` is 2xx (credential POSTs don't follow redirects)."""
|
||||
resp.raise_for_status()
|
||||
if not 200 <= resp.status_code < 300:
|
||||
raise ValueError(
|
||||
f"Unexpected HTTP {resp.status_code} from OAuth endpoint {resp.url!r}"
|
||||
)
|
||||
|
||||
|
||||
def parse_www_authenticate(header: Optional[str]) -> Dict[str, str]:
|
||||
@@ -62,30 +82,31 @@ def parse_www_authenticate(header: Optional[str]) -> Dict[str, str]:
|
||||
return params
|
||||
|
||||
|
||||
def _get_json(url: str, timeout: float = 10.0) -> Dict[str, Any]:
|
||||
def _get_json(url: str, timeout: float = 10.0, max_redirects: int = 3) -> Dict[str, Any]:
|
||||
"""Fetch and parse a JSON metadata document over HTTPS.
|
||||
|
||||
Redirects are followed but every hop — including the final destination — is
|
||||
re-validated with :func:`_require_https` so a redirect cannot escape the
|
||||
HTTPS/loopback guard to an internal or plaintext endpoint (SSRF-safe).
|
||||
Redirects are followed manually and every hop is validated with
|
||||
:func:`_require_https` *before* it is requested, so a redirect cannot
|
||||
reach an internal or plaintext endpoint (SSRF-safe).
|
||||
"""
|
||||
_require_https(url)
|
||||
import requests # lazy import — optional dependency
|
||||
|
||||
resp = requests.get(
|
||||
url,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=timeout,
|
||||
allow_redirects=True,
|
||||
)
|
||||
# Re-validate every redirect hop and the final URL: allow_redirects only
|
||||
# checks the initial URL, so a 30x to http:// or an internal host would
|
||||
# otherwise bypass the guard.
|
||||
for previous in getattr(resp, "history", None) or []:
|
||||
_require_https(previous.url)
|
||||
_require_https(resp.url)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
current = url
|
||||
for _ in range(max_redirects + 1):
|
||||
_require_https(current)
|
||||
resp = requests.get(
|
||||
current,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=timeout,
|
||||
allow_redirects=False,
|
||||
)
|
||||
location = resp.headers.get("Location")
|
||||
if resp.status_code in (301, 302, 303, 307, 308) and location:
|
||||
current = urljoin(current, location)
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
raise ValueError(f"Too many redirects fetching OAuth metadata: {url!r}")
|
||||
|
||||
|
||||
def _wellknown_candidates(server_url: str) -> List[str]:
|
||||
|
||||
@@ -268,8 +268,11 @@ class MCPOAuthProvider:
|
||||
data=data,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=timeout,
|
||||
allow_redirects=False, # never re-send credentials to a redirect target
|
||||
)
|
||||
resp.raise_for_status()
|
||||
from .oauth_discovery import _require_success
|
||||
|
||||
_require_success(resp)
|
||||
return self._normalize_tokens(
|
||||
resp.json(), token_endpoint=metadata["token_endpoint"]
|
||||
)
|
||||
@@ -285,7 +288,7 @@ class MCPOAuthProvider:
|
||||
return False
|
||||
|
||||
try:
|
||||
from .oauth_discovery import _require_https
|
||||
from .oauth_discovery import _require_https, _require_success
|
||||
|
||||
# Defense in depth: never POST a refresh token / client secret to a
|
||||
# persisted endpoint that isn't HTTPS (or loopback).
|
||||
@@ -306,8 +309,9 @@ class MCPOAuthProvider:
|
||||
data=data,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=10.0,
|
||||
allow_redirects=False, # never re-send credentials to a redirect target
|
||||
)
|
||||
resp.raise_for_status()
|
||||
_require_success(resp)
|
||||
new_tokens = self._normalize_tokens(
|
||||
resp.json(), token_endpoint=token_endpoint
|
||||
)
|
||||
|
||||
@@ -10,26 +10,13 @@ returned for persistence via :meth:`MCPAuthStorage.set_client_info`.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
from .oauth_discovery import _require_https, _require_success
|
||||
|
||||
logger = get_logger("mcp-oauth-registration")
|
||||
|
||||
_LOCALHOST_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
|
||||
|
||||
def _require_https(url: str) -> None:
|
||||
"""Reject non-HTTPS registration URLs (loopback HTTP allowed for testing)."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme == "https":
|
||||
return
|
||||
if parsed.scheme == "http" and (parsed.hostname or "") in _LOCALHOST_HOSTS:
|
||||
return
|
||||
raise ValueError(
|
||||
f"Refusing to register client over insecure transport: {url!r}"
|
||||
)
|
||||
|
||||
|
||||
def register_client(
|
||||
registration_endpoint: str,
|
||||
@@ -75,8 +62,9 @@ def register_client(
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
timeout=timeout,
|
||||
allow_redirects=False, # never re-send credentials to a redirect target
|
||||
)
|
||||
resp.raise_for_status()
|
||||
_require_success(resp)
|
||||
client_info = resp.json()
|
||||
|
||||
if not client_info.get("client_id"):
|
||||
|
||||
@@ -8,6 +8,7 @@ LAZY IMPORT: sqlite3 is only imported when this adapter is instantiated.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import json
|
||||
from praisonaiagents._logging import get_logger
|
||||
@@ -23,6 +24,39 @@ from ..protocols import MemoryProtocol
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
_SEARCH_STOP_WORDS = frozenset({
|
||||
"a", "an", "and", "are", "as", "at", "be", "by", "can", "do", "does", "for",
|
||||
"from", "has", "have", "how", "i", "in", "is", "it", "me", "my", "of", "on",
|
||||
"or", "the", "to", "was", "were", "what", "when", "where", "which", "who",
|
||||
"why", "with", "you", "your",
|
||||
})
|
||||
|
||||
|
||||
def _execute_search(conn, table, query, limit, user_id=None):
|
||||
"""Match the whole query or any significant word, best matches first.
|
||||
|
||||
A plain ``LIKE '%query%'`` misses natural-language queries such as
|
||||
"Which mode does the user prefer?", so rows are also matched per word and
|
||||
ranked by exact-phrase hit, then number of matched words, then recency.
|
||||
"""
|
||||
terms = list(dict.fromkeys(
|
||||
w for w in re.findall(r"\w+", (query or "").lower())
|
||||
if w not in _SEARCH_STOP_WORDS
|
||||
))[:32] # keep well under SQLite's bound-parameter limit
|
||||
patterns = [f"%{query}%"] + [f"%{t}%" for t in terms]
|
||||
match = " OR ".join(["content LIKE ?"] * len(patterns))
|
||||
# An empty score would leave ``ORDER BY 0``, which SQLite reads as a column index
|
||||
score = " + ".join(["(content LIKE ?)"] * len(terms)) + " DESC, " if terms else ""
|
||||
user_filter = " AND memory_user_id(metadata) = ?" if user_id is not None else ""
|
||||
params = patterns + ([str(user_id)] if user_id is not None else []) + patterns + [limit]
|
||||
return conn.execute(
|
||||
f"SELECT id, content, metadata, timestamp FROM {table} "
|
||||
f"WHERE ({match}){user_filter} "
|
||||
f"ORDER BY (content LIKE ?) DESC, {score}timestamp DESC LIMIT ?",
|
||||
params,
|
||||
)
|
||||
|
||||
|
||||
def _metadata_user_id(raw):
|
||||
"""Match the existing Python JSON reader, including non-finite values."""
|
||||
try:
|
||||
@@ -206,14 +240,7 @@ class SqliteMemoryAdapter:
|
||||
"""Search short-term memory."""
|
||||
with self._write_lock if self.short_db == ":memory:" else nullcontext():
|
||||
conn = self._get_stm_conn()
|
||||
user_id = kwargs.get("user_id")
|
||||
user_filter = " AND memory_user_id(metadata) = ?" if user_id is not None else ""
|
||||
params = (f"%{query}%", str(user_id), limit) if user_id is not None else (f"%{query}%", limit)
|
||||
cursor = conn.execute(
|
||||
"SELECT id, content, metadata, timestamp FROM short_term_memory "
|
||||
"WHERE content LIKE ?" + user_filter + " ORDER BY timestamp DESC LIMIT ?",
|
||||
params
|
||||
)
|
||||
cursor = _execute_search(conn, "short_term_memory", query, limit, kwargs.get("user_id"))
|
||||
|
||||
results = []
|
||||
for row in cursor.fetchall():
|
||||
@@ -250,14 +277,7 @@ class SqliteMemoryAdapter:
|
||||
"""Search long-term memory."""
|
||||
with self._write_lock if self.long_db == ":memory:" else nullcontext():
|
||||
conn = self._get_ltm_conn()
|
||||
user_id = kwargs.get("user_id")
|
||||
user_filter = " AND memory_user_id(metadata) = ?" if user_id is not None else ""
|
||||
params = (f"%{query}%", str(user_id), limit) if user_id is not None else (f"%{query}%", limit)
|
||||
cursor = conn.execute(
|
||||
"SELECT id, content, metadata, timestamp FROM long_term_memory "
|
||||
"WHERE content LIKE ?" + user_filter + " ORDER BY timestamp DESC LIMIT ?",
|
||||
params
|
||||
)
|
||||
cursor = _execute_search(conn, "long_term_memory", query, limit, kwargs.get("user_id"))
|
||||
|
||||
results = []
|
||||
for row in cursor.fetchall():
|
||||
|
||||
@@ -35,6 +35,7 @@ import os
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from pathlib import Path
|
||||
from ..utils.safe_path import safe_child
|
||||
from typing import Any, Dict, List, Optional, Literal
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -366,7 +367,7 @@ class DocsManager:
|
||||
docs_dir = self.workspace_path / self.DOCS_DIR_NAME.replace("/", os.sep)
|
||||
|
||||
docs_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = docs_dir / f"{name}.md"
|
||||
file_path = safe_child(docs_dir, name, ".md")
|
||||
|
||||
# Build frontmatter
|
||||
import yaml
|
||||
|
||||
@@ -23,6 +23,7 @@ import sys
|
||||
import hashlib
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from praisonaiagents.utils.safe_path import safe_child
|
||||
import threading
|
||||
import weakref
|
||||
from collections import Counter
|
||||
@@ -142,6 +143,15 @@ class FileMemory:
|
||||
raise ValueError("user_id must not contain path separators or parent references")
|
||||
safe = user_id.strip()
|
||||
return safe or "default"
|
||||
|
||||
def _episodic_file(self, date: str) -> Path:
|
||||
"""Map a YYYY-MM-DD date to its episodic file, rejecting anything else."""
|
||||
try:
|
||||
# Normalise so "2026-3-5" lands in the same file recent reads look for
|
||||
date = datetime.strptime(date, "%Y-%m-%d").strftime("%Y-%m-%d")
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"date must be YYYY-MM-DD, got {date!r}") from None
|
||||
return safe_child(self.episodic_path, date, ".json")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -753,10 +763,7 @@ class FileMemory:
|
||||
if date is None:
|
||||
date = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
episodic_file = self.episodic_path / f"{date}.json"
|
||||
|
||||
# Load existing episodic memories for this date
|
||||
episodes = self._read_json(episodic_file, [])
|
||||
episodic_file = self._episodic_file(date)
|
||||
|
||||
# Create new item
|
||||
item = MemoryItem(
|
||||
@@ -765,8 +772,11 @@ class FileMemory:
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
episodes.append(item.to_dict())
|
||||
self._write_json(episodic_file, episodes)
|
||||
# Read-modify-write under the store lock so concurrent adds are not lost
|
||||
with self._lock:
|
||||
episodes = self._read_json(episodic_file, [])
|
||||
episodes.append(item.to_dict())
|
||||
self._write_json(episodic_file, episodes)
|
||||
|
||||
self._log(f"Added episodic memory for {date}: {content[:50]}...")
|
||||
|
||||
@@ -788,7 +798,7 @@ class FileMemory:
|
||||
List of episodic memories
|
||||
"""
|
||||
if date:
|
||||
episodic_file = self.episodic_path / f"{date}.json"
|
||||
episodic_file = self._episodic_file(date)
|
||||
data = self._read_json(episodic_file, [])
|
||||
return [MemoryItem.from_dict(item) for item in data]
|
||||
|
||||
@@ -813,7 +823,8 @@ class FileMemory:
|
||||
try:
|
||||
file_date = datetime.strptime(episodic_file.stem, "%Y-%m-%d")
|
||||
if file_date < cutoff_date:
|
||||
episodic_file.unlink()
|
||||
with self._lock:
|
||||
episodic_file.unlink(missing_ok=True)
|
||||
removed += 1
|
||||
except ValueError:
|
||||
continue
|
||||
@@ -1193,16 +1204,17 @@ class FileMemory:
|
||||
for i in range(30)
|
||||
]
|
||||
|
||||
for check_date in dates_to_check:
|
||||
episodic_file = self.episodic_path / f"{check_date}.json"
|
||||
if episodic_file.exists():
|
||||
data = self._read_json(episodic_file, [])
|
||||
for i, item in enumerate(data):
|
||||
if item.get("id") == memory_id:
|
||||
del data[i]
|
||||
self._write_json(episodic_file, data)
|
||||
self._log(f"Deleted episodic memory: {memory_id}")
|
||||
return True
|
||||
with self._lock:
|
||||
for check_date in dates_to_check:
|
||||
episodic_file = self._episodic_file(check_date)
|
||||
if episodic_file.exists():
|
||||
data = self._read_json(episodic_file, [])
|
||||
for i, item in enumerate(data):
|
||||
if item.get("id") == memory_id:
|
||||
del data[i]
|
||||
self._write_json(episodic_file, data)
|
||||
self._log(f"Deleted episodic memory: {memory_id}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -1440,7 +1452,7 @@ class FileMemory:
|
||||
sessions_path = self.user_path / "sessions"
|
||||
sessions_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
session_file = sessions_path / f"{name}.json"
|
||||
session_file = safe_child(sessions_path, name, ".json")
|
||||
|
||||
# Take snapshots under lock for consistent session data
|
||||
with self._lock:
|
||||
@@ -1479,7 +1491,7 @@ class FileMemory:
|
||||
Session data including conversation history
|
||||
"""
|
||||
sessions_path = self.user_path / "sessions"
|
||||
session_file = sessions_path / f"{name}.json"
|
||||
session_file = safe_child(sessions_path, name, ".json")
|
||||
|
||||
if not session_file.exists():
|
||||
raise FileNotFoundError(f"Session '{name}' not found")
|
||||
@@ -1531,7 +1543,7 @@ class FileMemory:
|
||||
def delete_session(self, name: str) -> bool:
|
||||
"""Delete a saved session."""
|
||||
sessions_path = self.user_path / "sessions"
|
||||
session_file = sessions_path / f"{name}.json"
|
||||
session_file = safe_child(sessions_path, name, ".json")
|
||||
|
||||
if session_file.exists():
|
||||
session_file.unlink()
|
||||
@@ -1687,7 +1699,7 @@ Summary:"""
|
||||
|
||||
from uuid import uuid4
|
||||
checkpoint_id = name or f"checkpoint_{int(time.time())}_{uuid4().hex}"
|
||||
checkpoint_file = checkpoints_path / f"{checkpoint_id}.json"
|
||||
checkpoint_file = safe_child(checkpoints_path, checkpoint_id, ".json")
|
||||
|
||||
checkpoint_data = {
|
||||
"id": checkpoint_id,
|
||||
@@ -1724,7 +1736,7 @@ Summary:"""
|
||||
True if restored successfully
|
||||
"""
|
||||
checkpoints_path = self.user_path / "checkpoints"
|
||||
checkpoint_file = checkpoints_path / f"{checkpoint_id}.json"
|
||||
checkpoint_file = safe_child(checkpoints_path, checkpoint_id, ".json")
|
||||
|
||||
if not checkpoint_file.exists():
|
||||
self._log(f"Checkpoint '{checkpoint_id}' not found", logging.ERROR)
|
||||
@@ -1775,7 +1787,7 @@ Summary:"""
|
||||
def delete_checkpoint(self, checkpoint_id: str) -> bool:
|
||||
"""Delete a checkpoint."""
|
||||
checkpoints_path = self.user_path / "checkpoints"
|
||||
checkpoint_file = checkpoints_path / f"{checkpoint_id}.json"
|
||||
checkpoint_file = safe_child(checkpoints_path, checkpoint_id, ".json")
|
||||
|
||||
if checkpoint_file.exists():
|
||||
checkpoint_file.unlink()
|
||||
|
||||
@@ -35,6 +35,7 @@ import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
from pathlib import Path
|
||||
from ..utils.safe_path import safe_child
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -272,7 +273,7 @@ class MCPConfigManager:
|
||||
mcp_dir = self.workspace_path / self.MCP_DIR_NAME.replace("/", os.sep)
|
||||
|
||||
mcp_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = mcp_dir / f"{name}.json"
|
||||
file_path = safe_child(mcp_dir, name, ".json")
|
||||
|
||||
# Build config
|
||||
config = MCPConfig(
|
||||
|
||||
@@ -44,6 +44,7 @@ import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from ..utils.safe_path import safe_child
|
||||
from typing import Any, Dict, List, Optional, Literal, Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -515,9 +516,13 @@ class RulesManager:
|
||||
dir_prefix = rules_dir_name.split("/")[0].strip(".")
|
||||
self._rules[f"gitroot_{dir_prefix}:{rule.name}"] = rule
|
||||
|
||||
# 6. Load subdirectory rules (walk up from cwd to workspace/git root)
|
||||
stop_at = git_root if git_root else self.workspace_path
|
||||
current = Path.cwd()
|
||||
# 6. Load subdirectory rules (walk up from cwd to workspace/git root).
|
||||
# Skipped when cwd is outside that tree, so ancestors such as the
|
||||
# user's home directory are never scanned.
|
||||
stop_at = (git_root if git_root else self.workspace_path).resolve()
|
||||
current = Path.cwd().resolve()
|
||||
if not current.is_relative_to(stop_at):
|
||||
current = stop_at
|
||||
while current != stop_at and current != current.parent:
|
||||
# Check .praisonai/rules/
|
||||
subdir_rules_dir = current / self.RULES_DIR_NAME.replace("/", os.sep)
|
||||
@@ -808,7 +813,7 @@ class RulesManager:
|
||||
rules_dir = self.workspace_path / self.RULES_DIR_NAME.replace("/", os.sep)
|
||||
|
||||
rules_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = rules_dir / f"{name}.md"
|
||||
file_path = safe_child(rules_dir, name, ".md")
|
||||
|
||||
# Build frontmatter
|
||||
import yaml
|
||||
|
||||
@@ -231,7 +231,14 @@ def load_plugin(filepath: str) -> Optional[Dict[str, Any]]:
|
||||
except PluginParseError as e:
|
||||
logger.error(f"Invalid plugin header: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Refuse to exec a plugin built for an incompatible core.
|
||||
from .plugin import core_incompatibility
|
||||
reason = core_incompatibility(metadata.get("dependencies", []))
|
||||
if reason:
|
||||
logger.warning(f"Plugin '{metadata.get('name')}' disabled: {reason}")
|
||||
return None
|
||||
|
||||
# Generate unique module name to avoid conflicts
|
||||
module_name = f"praison_plugin_{path.stem}_{id(path)}"
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
from .plugin import Plugin, PluginHook, PluginInfo, FunctionPlugin
|
||||
from .plugin import Plugin, PluginHook, PluginInfo, FunctionPlugin, core_incompatibility
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..hooks.registry import HookRegistry
|
||||
@@ -143,6 +143,9 @@ class PluginManager:
|
||||
def __init__(self, disabled: Optional[bool] = None):
|
||||
self._plugins: Dict[str, Plugin] = {}
|
||||
self._enabled: Dict[str, bool] = {}
|
||||
# Names whose on_init is running and that have not been explicitly
|
||||
# enabled/disabled yet; register() applies the default only to these.
|
||||
self._initializing: set = set()
|
||||
self._single_file_plugins: Dict[str, Dict[str, Any]] = {} # WordPress-style plugins
|
||||
# Per-plugin option maps sourced from the unified project config,
|
||||
# delivered to each plugin's on_config hook. {plugin_name: options}.
|
||||
@@ -262,11 +265,10 @@ class PluginManager:
|
||||
if self._enabled.get(name, False) and name in self._plugin_options
|
||||
]
|
||||
for name, plugin, options in targets:
|
||||
on_config = getattr(plugin, "on_config", None)
|
||||
if not callable(on_config):
|
||||
if not callable(getattr(plugin, "on_config", None)):
|
||||
continue
|
||||
try:
|
||||
on_config(options)
|
||||
_ResolvingPlugin(plugin).on_config(options)
|
||||
delivered += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Plugin '{name}' on_config failed: {e}")
|
||||
@@ -288,26 +290,54 @@ class PluginManager:
|
||||
if info.name in self._plugins:
|
||||
logger.warning(f"Plugin '{info.name}' already registered")
|
||||
return False
|
||||
|
||||
self._plugins[info.name] = plugin
|
||||
self._enabled[info.name] = self._default_enabled_for_registration(info.name)
|
||||
|
||||
# Initialize plugin
|
||||
plugin.on_init({})
|
||||
|
||||
# Make the initialization observable to the hook system
|
||||
# too. ``plugin.on_init`` is the plugin's *own* callback;
|
||||
# ON_INIT is the runtime event other listeners
|
||||
# (metrics/audit plugins, gateways) subscribe to. Without
|
||||
# this emit the enum member was declared but never fired.
|
||||
_emit_plugin_init(plugin)
|
||||
|
||||
logger.info(f"Registered plugin: {info.name} v{info.version}")
|
||||
return True
|
||||
reason = core_incompatibility(info.dependencies)
|
||||
if reason:
|
||||
logger.warning(f"Plugin '{info.name}' disabled: {reason}")
|
||||
return False
|
||||
|
||||
# Reserve the name but keep it out of dispatch until on_init succeeds.
|
||||
self._plugins[info.name] = plugin
|
||||
self._enabled[info.name] = False
|
||||
self._initializing.add(info.name)
|
||||
default_enabled = self._default_enabled_for_registration(info.name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register plugin: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Initialize outside the lock: an ``async def on_init`` may run on
|
||||
# another thread and call back into this manager.
|
||||
try:
|
||||
_ResolvingPlugin(plugin).on_init({})
|
||||
|
||||
with self._lock:
|
||||
if self._plugins.get(info.name) is not plugin:
|
||||
logger.warning(f"Plugin '{info.name}' was unregistered during on_init")
|
||||
return False
|
||||
# Publish; an explicit enable()/disable() made during on_init is kept.
|
||||
if info.name in self._initializing:
|
||||
self._initializing.discard(info.name)
|
||||
self._enabled[info.name] = default_enabled
|
||||
|
||||
# Make the initialization observable to the hook system
|
||||
# too. ``plugin.on_init`` is the plugin's *own* callback;
|
||||
# ON_INIT is the runtime event other listeners
|
||||
# (metrics/audit plugins, gateways) subscribe to. Without
|
||||
# this emit the enum member was declared but never fired.
|
||||
_emit_plugin_init(plugin)
|
||||
|
||||
logger.info(f"Registered plugin: {info.name} v{info.version}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register plugin: {e}")
|
||||
# Roll back so a failed plugin is not left half-registered.
|
||||
with self._lock:
|
||||
if self._plugins.get(info.name) is plugin:
|
||||
del self._plugins[info.name]
|
||||
self._enabled.pop(info.name, None)
|
||||
self._initializing.discard(info.name)
|
||||
return False
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""
|
||||
Unregister a plugin.
|
||||
@@ -324,9 +354,10 @@ class PluginManager:
|
||||
return False
|
||||
del self._plugins[name]
|
||||
self._enabled.pop(name, None)
|
||||
self._initializing.discard(name)
|
||||
|
||||
try:
|
||||
plugin.on_shutdown()
|
||||
_ResolvingPlugin(plugin).on_shutdown()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error during plugin shutdown: {e}")
|
||||
|
||||
@@ -342,6 +373,7 @@ class PluginManager:
|
||||
with self._lock:
|
||||
if name in self._enabled:
|
||||
self._enabled[name] = True
|
||||
self._initializing.discard(name)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -356,6 +388,7 @@ class PluginManager:
|
||||
if name not in self._enabled:
|
||||
return False
|
||||
self._enabled[name] = False
|
||||
self._initializing.discard(name)
|
||||
try:
|
||||
self.unwire_from_hook_registry(name)
|
||||
except Exception as e:
|
||||
@@ -537,7 +570,8 @@ class PluginManager:
|
||||
continue
|
||||
|
||||
try:
|
||||
method = getattr(plugin, hook.value, None)
|
||||
# Resolving view: an ``async def`` hook is awaited, not dropped.
|
||||
method = getattr(_ResolvingPlugin(plugin), hook.value, None)
|
||||
if method:
|
||||
if hook == PluginHook.BEFORE_TOOL:
|
||||
result = method(args[0], args[1] if len(args) > 1 else {})
|
||||
@@ -839,21 +873,20 @@ class PluginManager:
|
||||
return 0
|
||||
|
||||
loaded = 0
|
||||
# Use lock to ensure thread-safe discovery of multiple entry points.
|
||||
# register() also acquires the lock but that's fine (RLock allows re-entry).
|
||||
with self._lock:
|
||||
for ep in entry_points:
|
||||
try:
|
||||
plugin_cls = ep.load()
|
||||
if callable(plugin_cls):
|
||||
plugin = plugin_cls()
|
||||
if self.register(plugin):
|
||||
loaded += 1
|
||||
logger.info(f"Loaded entry_point plugin: {ep.name}")
|
||||
else:
|
||||
logger.warning(f"Entry point {ep.name} is not callable")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load plugin from entry point {ep.name}: {e}")
|
||||
# register() is thread-safe on its own; holding the lock here would
|
||||
# keep it held while a plugin's (possibly async) on_init runs.
|
||||
for ep in entry_points:
|
||||
try:
|
||||
plugin_cls = ep.load()
|
||||
if callable(plugin_cls):
|
||||
plugin = plugin_cls()
|
||||
if self.register(plugin):
|
||||
loaded += 1
|
||||
logger.info(f"Loaded entry_point plugin: {ep.name}")
|
||||
else:
|
||||
logger.warning(f"Entry point {ep.name} is not callable")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load plugin from entry point {ep.name}: {e}")
|
||||
|
||||
return loaded
|
||||
|
||||
@@ -1008,6 +1041,44 @@ def _emit_plugin_lifecycle(event: "HookEvent", plugin: Plugin) -> None:
|
||||
logger.debug("Plugin lifecycle hook failed", exc_info=True)
|
||||
|
||||
|
||||
# Deadline for an ``async def`` plugin hook; matches HookRunner's default
|
||||
# per-hook timeout so a hung hook is cancelled and frees its worker thread.
|
||||
_ASYNC_HOOK_TIMEOUT = 60.0
|
||||
|
||||
|
||||
def _resolve_awaitable(value: Any) -> Any:
|
||||
"""Drive a coroutine returned by an ``async def`` plugin hook to completion.
|
||||
|
||||
Hook adapters run on the HookRunner's worker thread (no running loop), so
|
||||
the coroutine runs on a private loop there; from a thread with a running
|
||||
loop the shared bridge offloads it instead of deadlocking. The coroutine is
|
||||
bounded by ``_ASYNC_HOOK_TIMEOUT``. Sync hook return values pass through
|
||||
unchanged.
|
||||
"""
|
||||
if asyncio.iscoroutine(value):
|
||||
from ..utils.async_bridge import run_coroutine_from_any_context
|
||||
return run_coroutine_from_any_context(value, timeout=_ASYNC_HOOK_TIMEOUT)
|
||||
return value
|
||||
|
||||
|
||||
class _ResolvingPlugin:
|
||||
"""View of a plugin whose method calls resolve ``async def`` hooks."""
|
||||
|
||||
__slots__ = ("_plugin",)
|
||||
|
||||
def __init__(self, plugin: Plugin):
|
||||
self._plugin = plugin
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
attr = getattr(self._plugin, name)
|
||||
if not callable(attr):
|
||||
return attr
|
||||
|
||||
def call(*args, **kwargs):
|
||||
return _resolve_awaitable(attr(*args, **kwargs))
|
||||
return call
|
||||
|
||||
|
||||
def _write_back(data, attr: str, new_value: dict) -> None:
|
||||
"""Write a plugin's returned dict back onto the hook payload.
|
||||
|
||||
@@ -1044,6 +1115,9 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
from .plugin import GuardrailBlocked, PluginDecision
|
||||
|
||||
base = Plugin
|
||||
# Adapters call the plugin through this view so ``async def`` hooks are
|
||||
# awaited instead of returning an unawaited coroutine.
|
||||
resolving_plugin = _ResolvingPlugin(plugin)
|
||||
|
||||
def _as_decision(result: Any) -> Optional[HookResult]:
|
||||
"""Coerce a plugin's return value into a denying ``HookResult``.
|
||||
@@ -1081,7 +1155,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
return HookResult.allow()
|
||||
|
||||
if _overrides("before_agent"):
|
||||
def before_agent_hook(data, _p=plugin):
|
||||
def before_agent_hook(data, _p=resolving_plugin):
|
||||
try:
|
||||
new_prompt = _p.before_agent(getattr(data, "prompt", ""),
|
||||
{"agent_name": getattr(data, "agent_name", None)})
|
||||
@@ -1096,7 +1170,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
yield HookEvent.BEFORE_AGENT, before_agent_hook
|
||||
|
||||
if _overrides("after_agent"):
|
||||
def after_agent_hook(data, _p=plugin):
|
||||
def after_agent_hook(data, _p=resolving_plugin):
|
||||
new_resp = _p.after_agent(getattr(data, "response", ""),
|
||||
{"agent_name": getattr(data, "agent_name", None)})
|
||||
if isinstance(new_resp, str) and hasattr(data, "response"):
|
||||
@@ -1105,7 +1179,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
yield HookEvent.AFTER_AGENT, after_agent_hook
|
||||
|
||||
if _overrides("before_llm"):
|
||||
def before_llm_hook(data, _p=plugin):
|
||||
def before_llm_hook(data, _p=resolving_plugin):
|
||||
messages = getattr(data, "messages", [])
|
||||
try:
|
||||
result = _p.before_llm(messages, {"model": getattr(data, "model", "")})
|
||||
@@ -1121,7 +1195,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
yield HookEvent.BEFORE_LLM, before_llm_hook
|
||||
|
||||
if _overrides("after_llm"):
|
||||
def after_llm_hook(data, _p=plugin):
|
||||
def after_llm_hook(data, _p=resolving_plugin):
|
||||
new_resp = _p.after_llm(getattr(data, "response", ""),
|
||||
{"tokens_used": getattr(data, "tokens_used", 0)})
|
||||
if isinstance(new_resp, str) and hasattr(data, "response"):
|
||||
@@ -1130,7 +1204,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
yield HookEvent.AFTER_LLM, after_llm_hook
|
||||
|
||||
if _overrides("before_tool"):
|
||||
def before_tool_hook(data, _p=plugin):
|
||||
def before_tool_hook(data, _p=resolving_plugin):
|
||||
args = getattr(data, "tool_input", {}) or {}
|
||||
try:
|
||||
new_args = _p.before_tool(getattr(data, "tool_name", ""), args)
|
||||
@@ -1146,7 +1220,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
yield HookEvent.BEFORE_TOOL, before_tool_hook
|
||||
|
||||
if _overrides("after_tool"):
|
||||
def after_tool_hook(data, _p=plugin):
|
||||
def after_tool_hook(data, _p=resolving_plugin):
|
||||
try:
|
||||
new_result = _p.after_tool(getattr(data, "tool_name", ""),
|
||||
getattr(data, "tool_output", None))
|
||||
@@ -1161,7 +1235,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
yield HookEvent.AFTER_TOOL, after_tool_hook
|
||||
|
||||
if _overrides("before_tool_definitions"):
|
||||
def before_tool_definitions_hook(data, _p=plugin):
|
||||
def before_tool_definitions_hook(data, _p=resolving_plugin):
|
||||
defs = getattr(data, "tool_definitions", []) or []
|
||||
try:
|
||||
new_defs = _p.before_tool_definitions(defs)
|
||||
@@ -1193,7 +1267,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
return payload
|
||||
|
||||
if _overrides("before_message"):
|
||||
def before_message_hook(data, _p=plugin):
|
||||
def before_message_hook(data, _p=resolving_plugin):
|
||||
try:
|
||||
new = _p.before_message(_message_payload(data))
|
||||
except GuardrailBlocked as e:
|
||||
@@ -1207,7 +1281,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
yield HookEvent.MESSAGE_RECEIVED, before_message_hook
|
||||
|
||||
if _overrides("after_message"):
|
||||
def after_message_hook(data, _p=plugin):
|
||||
def after_message_hook(data, _p=resolving_plugin):
|
||||
try:
|
||||
new = _p.after_message(_message_payload(data))
|
||||
except GuardrailBlocked as e:
|
||||
@@ -1224,14 +1298,14 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
yield HookEvent.MESSAGE_SENDING, after_message_hook
|
||||
|
||||
if _overrides("message_sent"):
|
||||
def message_sent_hook(data, _p=plugin):
|
||||
def message_sent_hook(data, _p=resolving_plugin):
|
||||
payload = _message_payload(data)
|
||||
_p.message_sent(payload)
|
||||
return HookResult.allow()
|
||||
yield HookEvent.MESSAGE_SENT, message_sent_hook
|
||||
|
||||
if _overrides("message_undelivered"):
|
||||
def message_undelivered_hook(data, _p=plugin):
|
||||
def message_undelivered_hook(data, _p=resolving_plugin):
|
||||
payload = _message_payload(data)
|
||||
payload["error"] = getattr(data, "error", "")
|
||||
payload["notice_delivered"] = getattr(data, "notice_delivered", False)
|
||||
@@ -1240,14 +1314,14 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
yield HookEvent.MESSAGE_UNDELIVERED, message_undelivered_hook
|
||||
|
||||
if _overrides("on_permission_ask"):
|
||||
def on_permission_ask_hook(data, _p=plugin):
|
||||
def on_permission_ask_hook(data, _p=resolving_plugin):
|
||||
target = getattr(data, "tool_name", "") or getattr(data, "target", "")
|
||||
reason = getattr(data, "reason", "") or "Permission requested"
|
||||
return _permission_result(_p.on_permission_ask(target, reason), reason)
|
||||
yield HookEvent.ON_PERMISSION_ASK, on_permission_ask_hook
|
||||
|
||||
if _overrides("on_config"):
|
||||
def on_config_hook(data, _p=plugin):
|
||||
def on_config_hook(data, _p=resolving_plugin):
|
||||
config = getattr(data, "config", None)
|
||||
if not isinstance(config, dict):
|
||||
config = getattr(data, "extra", {}) or {}
|
||||
@@ -1258,7 +1332,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
yield HookEvent.ON_CONFIG, on_config_hook
|
||||
|
||||
if _overrides("on_auth"):
|
||||
def on_auth_hook(data, _p=plugin):
|
||||
def on_auth_hook(data, _p=resolving_plugin):
|
||||
auth_type = getattr(data, "auth_type", "") or ""
|
||||
credentials = getattr(data, "credentials", None) or {}
|
||||
new_creds = _p.on_auth(auth_type, credentials)
|
||||
@@ -1268,7 +1342,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
yield HookEvent.ON_AUTH, on_auth_hook
|
||||
|
||||
if _overrides("session_start"):
|
||||
def session_start_hook(data, _p=plugin):
|
||||
def session_start_hook(data, _p=resolving_plugin):
|
||||
_p.session_start({
|
||||
"source": getattr(data, "source", None),
|
||||
"session_name": getattr(data, "session_name", None),
|
||||
@@ -1278,7 +1352,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
yield HookEvent.SESSION_START, session_start_hook
|
||||
|
||||
if _overrides("session_end"):
|
||||
def session_end_hook(data, _p=plugin):
|
||||
def session_end_hook(data, _p=resolving_plugin):
|
||||
_p.session_end({
|
||||
"reason": getattr(data, "reason", None),
|
||||
"total_turns": getattr(data, "total_turns", None),
|
||||
@@ -1289,7 +1363,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
yield HookEvent.SESSION_END, session_end_hook
|
||||
|
||||
if _overrides("on_error"):
|
||||
def on_error_hook(data, _p=plugin):
|
||||
def on_error_hook(data, _p=resolving_plugin):
|
||||
_p.on_error(
|
||||
getattr(data, "error_type", "") or "",
|
||||
getattr(data, "error_message", "") or "",
|
||||
@@ -1303,7 +1377,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
yield HookEvent.ON_ERROR, on_error_hook
|
||||
|
||||
if _overrides("cli_backend_execute"):
|
||||
def cli_backend_execute_hook(data, _p=plugin):
|
||||
def cli_backend_execute_hook(data, _p=resolving_plugin):
|
||||
from ..cli_backend.debug import redact_command
|
||||
_p.cli_backend_execute({
|
||||
"agent_name": getattr(data, "agent_name", None),
|
||||
@@ -1361,7 +1435,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
|
||||
# ``kanban_task_created``) are treated as genuine overrides.
|
||||
if parent is not None and own is parent:
|
||||
continue
|
||||
yield event, _make_observe_hook(method)
|
||||
yield event, _make_observe_hook(getattr(resolving_plugin, event.value))
|
||||
|
||||
|
||||
# Global plugin manager instance
|
||||
|
||||
@@ -111,14 +111,75 @@ class PluginInfo:
|
||||
"plugin_type": self.plugin_type.value,
|
||||
}
|
||||
|
||||
|
||||
def core_incompatibility(dependencies: List[str]) -> Optional[str]:
|
||||
"""Check a plugin's ``dependencies`` against the running core.
|
||||
|
||||
Only a ``praisonaiagents`` entry (e.g. ``"praisonaiagents>=0.9,<2"``) is
|
||||
checked; other entries are ignored. Returns an actionable reason when the
|
||||
installed core does not satisfy it, else ``None`` (permissive: no entry,
|
||||
an unparseable entry, or ``packaging`` unavailable all mean compatible).
|
||||
"""
|
||||
if not dependencies:
|
||||
return None
|
||||
try:
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
from packaging.version import InvalidVersion, Version
|
||||
from .._version import __version__ as core_version
|
||||
except ImportError:
|
||||
return None
|
||||
if core_version == "unknown":
|
||||
# Wheel installs ship no pyproject.toml; read the installed metadata.
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
core_version = version("praisonaiagents")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Comma-separated manifest headers split "pkg>=1,<2" into ["pkg>=1", "<2"];
|
||||
# rejoin bare specifier fragments onto the preceding requirement.
|
||||
merged: List[str] = []
|
||||
for dep in dependencies:
|
||||
dep = str(dep).strip()
|
||||
if merged and dep[:1] in "<>=!~":
|
||||
merged[-1] = f"{merged[-1]},{dep}"
|
||||
elif dep:
|
||||
merged.append(dep)
|
||||
|
||||
for dep in merged:
|
||||
try:
|
||||
req = Requirement(dep)
|
||||
except InvalidRequirement:
|
||||
continue
|
||||
if canonicalize_name(req.name) != "praisonaiagents" or not req.specifier:
|
||||
continue
|
||||
try:
|
||||
if req.marker and not req.marker.evaluate({"extra": ""}):
|
||||
continue # e.g. "; sys_platform == 'win32'" on another OS
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
ok = req.specifier.contains(Version(core_version), prereleases=True)
|
||||
except InvalidVersion:
|
||||
return None
|
||||
if not ok:
|
||||
return (
|
||||
f"requires praisonaiagents{req.specifier}, installed {core_version}. "
|
||||
"Update the plugin or praisonaiagents."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class Plugin(ABC):
|
||||
"""
|
||||
Base class for PraisonAI plugins.
|
||||
|
||||
Plugins can extend agent functionality by implementing
|
||||
hook methods that are called at various points in the
|
||||
agent lifecycle.
|
||||
|
||||
agent lifecycle. Hook methods may be ``def`` or ``async def``;
|
||||
coroutines returned by async hooks are awaited by the dispatcher.
|
||||
|
||||
Example:
|
||||
class MyPlugin(Plugin):
|
||||
@property
|
||||
|
||||
@@ -115,11 +115,8 @@ class PolicyConfig:
|
||||
rules_dir = os.path.dirname(self.rules_file)
|
||||
os.makedirs(rules_dir, exist_ok=True)
|
||||
|
||||
# Atomic write: tmp file + rename
|
||||
tmp_file = self.rules_file + ".tmp"
|
||||
with open(tmp_file, "w") as f:
|
||||
json.dump({"rules": existing}, f, indent=2)
|
||||
os.replace(tmp_file, self.rules_file)
|
||||
from ..utils.atomic_io import atomic_write_json
|
||||
atomic_write_json(self.rules_file, {"rules": existing}, indent=2)
|
||||
|
||||
logger.debug(f"Saved learned rule: {resource} -> {action}")
|
||||
return True
|
||||
|
||||
@@ -11,6 +11,7 @@ from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
from ..context.budgeter import MODEL_LIMITS as _CANONICAL_LIMITS
|
||||
from ..context.tokens import estimate_tokens_heuristic
|
||||
|
||||
|
||||
# RAG-specific context windows not present in the canonical budgeter table
|
||||
@@ -285,11 +286,28 @@ def estimate_tokens(text: str) -> int:
|
||||
Returns:
|
||||
Estimated token count
|
||||
"""
|
||||
if not text:
|
||||
return 0
|
||||
# Average of 4 characters per token for English
|
||||
# This is a conservative estimate
|
||||
return len(text) // 4 + 1
|
||||
# Delegate to the canonical estimator so dense (CJK/non-ASCII) text is not
|
||||
# under-counted ~5x and over-filled past the budget.
|
||||
return estimate_tokens_heuristic(text)
|
||||
|
||||
|
||||
def _truncate_to_tokens(text: str, max_tokens: int, estimator=None) -> str:
|
||||
"""Return the longest prefix of ``text`` whose estimate fits ``max_tokens``.
|
||||
|
||||
Binary-searches on the (monotonic) estimator instead of assuming
|
||||
~4 chars/token, which overshoots the budget for dense (CJK) text.
|
||||
"""
|
||||
estimator = estimator or estimate_tokens
|
||||
if max_tokens <= 0 or not text:
|
||||
return ""
|
||||
lo, hi = 0, len(text)
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if estimator(text[:mid]) <= max_tokens:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid - 1
|
||||
return text[:lo]
|
||||
|
||||
|
||||
class DefaultBudgetEnforcer:
|
||||
@@ -340,9 +358,12 @@ class DefaultBudgetEnforcer:
|
||||
# Try to fit partial chunk if there's remaining space
|
||||
remaining = available - used_tokens
|
||||
if remaining > 100: # Only if meaningful space remains
|
||||
# Truncate text to fit
|
||||
chars_to_keep = remaining * 4 # Approximate
|
||||
truncated_text = text[:chars_to_keep]
|
||||
# Truncate text to fit, leaving room for the "..." suffix
|
||||
truncated_text = _truncate_to_tokens(
|
||||
text,
|
||||
remaining - self._token_estimator("..."),
|
||||
self._token_estimator,
|
||||
)
|
||||
if truncated_text:
|
||||
truncated_chunk = {
|
||||
**chunk,
|
||||
|
||||
@@ -12,7 +12,7 @@ No heavy imports at module level.
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
from .budget import estimate_tokens
|
||||
from .budget import _truncate_to_tokens, estimate_tokens
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -219,8 +219,9 @@ class ContextCompressor:
|
||||
# Partial fit
|
||||
remaining = target_tokens - used_tokens
|
||||
if remaining > 50: # Only if meaningful space
|
||||
chars_to_keep = remaining * 4
|
||||
truncated_text = text[:chars_to_keep] + "..."
|
||||
truncated_text = _truncate_to_tokens(
|
||||
text, remaining - estimate_tokens("...")
|
||||
) + "..."
|
||||
result.append({
|
||||
**chunk,
|
||||
'text': truncated_text,
|
||||
|
||||
@@ -8,7 +8,7 @@ No heavy imports - only stdlib.
|
||||
import hashlib
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
from .budget import estimate_tokens as _budget_estimate_tokens
|
||||
from .budget import _truncate_to_tokens, estimate_tokens as _budget_estimate_tokens
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..knowledge.models import SearchResultItem
|
||||
@@ -156,10 +156,10 @@ def truncate_context(
|
||||
# Calculate target character count
|
||||
suffix_tokens = _estimate_tokens(suffix)
|
||||
target_tokens = max_tokens - suffix_tokens
|
||||
target_chars = target_tokens * 4
|
||||
truncated = _truncate_to_tokens(text, target_tokens, _estimate_tokens)
|
||||
target_chars = len(truncated)
|
||||
|
||||
# Truncate at word boundary
|
||||
truncated = text[:target_chars]
|
||||
last_space = truncated.rfind(" ")
|
||||
if last_space > target_chars * 0.8:
|
||||
truncated = truncated[:last_space]
|
||||
|
||||
@@ -119,10 +119,15 @@ class ScheduleRunner:
|
||||
job_name=job.name,
|
||||
)
|
||||
|
||||
if job.should_retire(job.last_run_at):
|
||||
self._store.remove(job.id)
|
||||
else:
|
||||
self._store.update(job)
|
||||
# The run already happened (its claim was persisted), so a failed write
|
||||
# here is reported, not raised: one bad write must not stop the loop.
|
||||
try:
|
||||
if job.should_retire(job.last_run_at):
|
||||
self._store.remove(job.id)
|
||||
else:
|
||||
self._store.update(job)
|
||||
except OSError as e:
|
||||
logger.error("Failed to persist run state for job %r: %s", job.id, e)
|
||||
|
||||
# ── job-to-job context chaining ───────────────────────────────────
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from typing import Dict, Iterator, List, Optional
|
||||
from .models import ScheduleJob, RunRecord
|
||||
from .due import is_due as _is_due, is_misfire as _is_misfire
|
||||
from .hook_emit import emit_schedule_add, emit_schedule_remove
|
||||
from ..utils.atomic_io import atomic_write_json
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -41,6 +42,8 @@ class FileScheduleStore:
|
||||
self._lock = threading.RLock()
|
||||
self._jobs: Dict[str, ScheduleJob] = {}
|
||||
self._history: List[RunRecord] = []
|
||||
# Run records whose history write failed; merged into the next save.
|
||||
self._unsaved: List[RunRecord] = []
|
||||
# In-memory record of leases we currently hold, so ``complete`` can
|
||||
# release them. Cross-process leases live in the persisted job
|
||||
# (``lease_until`` / ``lease_owner``) which is the source of truth.
|
||||
@@ -60,7 +63,7 @@ class FileScheduleStore:
|
||||
if job.id in self._jobs:
|
||||
raise ValueError(f"Job '{job.id}' already exists")
|
||||
self._jobs[job.id] = job
|
||||
self._save()
|
||||
self._save_or_rollback(job.id)
|
||||
# Emit SCHEDULE_ADD outside the store locks: a hook must never be able
|
||||
# to deadlock the cross-process file lock by calling back in.
|
||||
emit_schedule_add(job)
|
||||
@@ -120,7 +123,7 @@ class FileScheduleStore:
|
||||
job._lease_until = getattr(existing, "_lease_until", 0.0) or 0.0
|
||||
job._lease_owner = getattr(existing, "_lease_owner", None)
|
||||
self._jobs[job.id] = job
|
||||
self._save()
|
||||
self._save_or_rollback(job.id)
|
||||
|
||||
def remove(self, job_id: str) -> bool:
|
||||
removed = None
|
||||
@@ -128,7 +131,7 @@ class FileScheduleStore:
|
||||
self._reload_locked()
|
||||
if job_id in self._jobs:
|
||||
removed = self._jobs.pop(job_id)
|
||||
self._save()
|
||||
self._save_or_rollback(job_id)
|
||||
if removed is None:
|
||||
return False
|
||||
emit_schedule_remove(removed)
|
||||
@@ -150,7 +153,7 @@ class FileScheduleStore:
|
||||
if principal is not None and job.principal != principal:
|
||||
continue
|
||||
removed = self._jobs.pop(jid)
|
||||
self._save()
|
||||
self._save_or_rollback(jid)
|
||||
break
|
||||
if removed is None:
|
||||
return False
|
||||
@@ -255,7 +258,7 @@ class FileScheduleStore:
|
||||
emit_schedule_remove(job)
|
||||
# Record suppressed occurrences so a missed run is visible in history
|
||||
# rather than silently dropped. Logged outside the file lock (log_run
|
||||
# takes only the thread lock) to keep the critical section short.
|
||||
# takes it itself; flock is not re-entrant across file handles).
|
||||
for job_id, job_name in missed:
|
||||
self.log_run(
|
||||
job_id=job_id,
|
||||
@@ -301,12 +304,26 @@ class FileScheduleStore:
|
||||
delivered=delivered,
|
||||
timestamp=time.time(),
|
||||
)
|
||||
with self._lock:
|
||||
self._history.insert(0, record)
|
||||
# Prune to max_history
|
||||
if len(self._history) > self._max_history:
|
||||
self._history = self._history[:self._max_history]
|
||||
self._save_history()
|
||||
with self._lock, self._file_lock():
|
||||
# Reload under the cross-process lock so concurrent writers (e.g.
|
||||
# the scheduler daemon and a CLI process) don't drop each other's
|
||||
# records (last-writer-wins). Records whose earlier save failed are
|
||||
# carried forward so they are retried, not discarded.
|
||||
pending = ([record] + self._unsaved)[:self._max_history]
|
||||
on_disk = self._read_history()
|
||||
if on_disk is None:
|
||||
# Unreadable history: never overwrite saved records with a
|
||||
# partial view. Keep the new record in memory for a retry.
|
||||
self._history = ([record] + self._history)[:self._max_history]
|
||||
self._unsaved = pending
|
||||
return
|
||||
# Retried records may be older than ones other processes wrote
|
||||
# since; sort so history stays newest-first before trimming.
|
||||
merged = sorted(
|
||||
pending + on_disk, key=lambda r: r.timestamp, reverse=True
|
||||
)
|
||||
self._history = merged[:self._max_history]
|
||||
self._unsaved = [] if self._save_history() else pending
|
||||
|
||||
def get_history(
|
||||
self,
|
||||
@@ -392,6 +409,17 @@ class FileScheduleStore:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load schedule store from %s: %s", self._path, e)
|
||||
|
||||
def _save_or_rollback(self, job_id: str) -> None:
|
||||
"""Persist jobs or restore on-disk state and raise (file lock held).
|
||||
|
||||
Keeps memory consistent with disk so a failed write never reports
|
||||
success (or fires lifecycle hooks) for a change that will vanish on
|
||||
restart.
|
||||
"""
|
||||
if not self._save():
|
||||
self._reload_locked()
|
||||
raise OSError(f"Failed to persist schedule job '{job_id}'")
|
||||
|
||||
def _save(self) -> bool:
|
||||
"""Persist jobs to disk.
|
||||
|
||||
@@ -401,10 +429,7 @@ class FileScheduleStore:
|
||||
try:
|
||||
os.makedirs(self._dir, exist_ok=True)
|
||||
data = [j.to_dict() for j in self._jobs.values()]
|
||||
tmp = self._path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp, self._path)
|
||||
atomic_write_json(self._path, data, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save schedule store to %s: %s", self._path, e)
|
||||
@@ -412,25 +437,27 @@ class FileScheduleStore:
|
||||
|
||||
def _load_history(self) -> None:
|
||||
"""Load execution history from history.json."""
|
||||
self._history = self._read_history() or []
|
||||
|
||||
def _read_history(self) -> Optional[List[RunRecord]]:
|
||||
"""Read history.json; ``[]`` if absent, ``None`` if unreadable."""
|
||||
if not os.path.exists(self._history_path):
|
||||
return
|
||||
return []
|
||||
try:
|
||||
with open(self._history_path, "r") as f:
|
||||
data = json.load(f)
|
||||
for d in data:
|
||||
record = RunRecord.from_dict(d)
|
||||
self._history.append(record)
|
||||
return [RunRecord.from_dict(d) for d in data]
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load history from %s: %s", self._history_path, e)
|
||||
return None
|
||||
|
||||
def _save_history(self) -> None:
|
||||
"""Save execution history to history.json."""
|
||||
def _save_history(self) -> bool:
|
||||
"""Save execution history to history.json; ``True`` on success."""
|
||||
try:
|
||||
os.makedirs(self._dir, exist_ok=True)
|
||||
data = [r.to_dict() for r in self._history]
|
||||
tmp = self._history_path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp, self._history_path)
|
||||
atomic_write_json(self._history_path, data, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save history to %s: %s", self._history_path, e)
|
||||
return False
|
||||
@@ -23,6 +23,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
CACHE_SUBDIR = "remote-skills"
|
||||
_CURRENT_LINK = "current"
|
||||
# Deadline for git calls so a stalled remote falls back to the cached copy.
|
||||
_GIT_TIMEOUT = 120
|
||||
|
||||
|
||||
def get_remote_cache_root() -> Path:
|
||||
@@ -89,7 +91,10 @@ class GitRemoteSkillSource:
|
||||
if self.ref:
|
||||
cmd += ["--branch", self.ref]
|
||||
cmd += [self.url, str(clone_path)]
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=_GIT_TIMEOUT,
|
||||
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
"Remote skill sync failed for %s: %s; using cached copy.",
|
||||
@@ -123,7 +128,7 @@ class GitRemoteSkillSource:
|
||||
def _resolve_version(self, clone_path: Path) -> str:
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(clone_path), "rev-parse", "HEAD"],
|
||||
capture_output=True, text=True,
|
||||
capture_output=True, text=True, timeout=_GIT_TIMEOUT,
|
||||
)
|
||||
if proc.returncode == 0 and proc.stdout.strip():
|
||||
return proc.stdout.strip()[:16]
|
||||
|
||||
@@ -120,6 +120,17 @@ class CircuitBreakerException(Exception):
|
||||
super().__init__(f"Circuit breaker open for service '{service_name}', state: {state.value}")
|
||||
|
||||
|
||||
class _NeutralOutcome(Exception):
|
||||
"""Raised by a guarded callable whose outcome says nothing about the service's
|
||||
health (e.g. a caller's bad input): recorded as neither success nor failure,
|
||||
so it can neither open the circuit nor close a half-open one. Carries the
|
||||
real outcome in ``value`` for the caller to unwrap."""
|
||||
|
||||
def __init__(self, value):
|
||||
super().__init__("neutral outcome")
|
||||
self.value = value
|
||||
|
||||
|
||||
class CircuitBreaker:
|
||||
"""Lightweight circuit breaker implementation.
|
||||
|
||||
@@ -226,6 +237,8 @@ class CircuitBreaker:
|
||||
result = func(*args, **kwargs)
|
||||
self._on_success()
|
||||
return result
|
||||
except _NeutralOutcome:
|
||||
raise
|
||||
except Exception as e:
|
||||
self._on_failure()
|
||||
raise
|
||||
@@ -269,6 +282,8 @@ class CircuitBreaker:
|
||||
result = func(*args, **kwargs)
|
||||
self._on_success()
|
||||
return result
|
||||
except _NeutralOutcome:
|
||||
raise
|
||||
except Exception as e:
|
||||
self._on_failure()
|
||||
raise
|
||||
|
||||
@@ -331,8 +331,15 @@ class OpenAPIOperation:
|
||||
# Returned, not raised, for the same reason as the transport errors
|
||||
# below: a raising tool ends the turn.
|
||||
return f"{self.name} failed: {exc}"
|
||||
from .url_safety import is_safe_http_url
|
||||
if not is_safe_http_url(request["url"]):
|
||||
# The host comes from an untrusted spec's `servers:` entry; keep it
|
||||
# off loopback/private/metadata addresses (ALLOW_LOCAL_CRAWL=true
|
||||
# opts out for local development).
|
||||
return (f"{self.name} failed: refusing unsafe URL "
|
||||
f"{request['url']!r}")
|
||||
try:
|
||||
with httpx.Client(timeout=self.timeout) as client:
|
||||
with httpx.Client(timeout=self.timeout, follow_redirects=False) as client:
|
||||
response = client.request(**request)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
@@ -391,7 +398,11 @@ class OpenAPIToolset:
|
||||
text = spec_str
|
||||
else:
|
||||
import httpx
|
||||
with httpx.Client(timeout=30) as client:
|
||||
from .url_safety import is_safe_http_url
|
||||
if not is_safe_http_url(spec_url):
|
||||
raise ValueError(
|
||||
f"OpenAPIToolset refuses unsafe spec_url {spec_url!r}")
|
||||
with httpx.Client(timeout=30, follow_redirects=False) as client:
|
||||
response = client.get(spec_url)
|
||||
response.raise_for_status()
|
||||
text = response.text
|
||||
|
||||
@@ -7,6 +7,7 @@ This module provides a safe interface for executing shell commands with:
|
||||
- Resource limits
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import shlex
|
||||
import logging
|
||||
@@ -89,6 +90,48 @@ def _find_shell_syntax(command: str):
|
||||
return None
|
||||
|
||||
|
||||
# Credential-looking variable names are withheld from model-driven commands:
|
||||
# a gateway serving untrusted chat would otherwise hand `env` / `echo $X` every
|
||||
# LLM key, channel token and its own GATEWAY_AUTH_TOKEN. Operators who need one
|
||||
# inside a command name it in PRAISONAI_SHELL_ENV_ALLOWLIST (comma-separated).
|
||||
_SECRET_NAME_PARTS = frozenset({"TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", "CREDENTIALS", "APIKEY"})
|
||||
_ENV_REF = re.compile(r"\$(\w+|\{[^}]*\})")
|
||||
_ENV_REF_NT = re.compile(r"\$(\w+|\{[^}]*\})|%(\w+)%")
|
||||
|
||||
|
||||
def _is_secret_name(name: str) -> bool:
|
||||
upper = name.upper()
|
||||
return upper.endswith("_KEY") or any(part in _SECRET_NAME_PARTS for part in upper.split("_"))
|
||||
|
||||
|
||||
def _build_subprocess_env(extra_env: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
||||
"""Parent environment minus secret-looking names, plus explicit *extra_env*."""
|
||||
allow = {n.strip() for n in os.environ.get("PRAISONAI_SHELL_ENV_ALLOWLIST", "").split(",") if n.strip()}
|
||||
process_env = {k: v for k, v in os.environ.items() if k in allow or not _is_secret_name(k)}
|
||||
if extra_env:
|
||||
process_env.update(extra_env)
|
||||
return process_env
|
||||
|
||||
|
||||
def _expandvars(value: str, env: Dict[str, str]) -> str:
|
||||
"""``os.path.expandvars`` against *env* rather than ``os.environ``.
|
||||
|
||||
On Windows ``%VAR%`` is expanded too and names are case-insensitive,
|
||||
matching ``ntpath.expandvars``.
|
||||
"""
|
||||
windows = platform.system() == 'Windows'
|
||||
if windows:
|
||||
env = {k.upper(): v for k, v in env.items()}
|
||||
|
||||
def _sub(match):
|
||||
name = (match.group(1) or match.group(2)).strip("{}")
|
||||
return env.get(name.upper() if windows else name, match.group(0))
|
||||
|
||||
if windows:
|
||||
return _ENV_REF_NT.sub(_sub, value) if ("$" in value or "%" in value) else value
|
||||
return _ENV_REF.sub(_sub, value) if "$" in value else value
|
||||
|
||||
|
||||
class ShellTools:
|
||||
"""Tools for executing shell commands safely."""
|
||||
|
||||
@@ -187,24 +230,23 @@ class ShellTools:
|
||||
if not command:
|
||||
return {"error": "Empty command", "stdout": "", "stderr": "", "exit_code": 1}
|
||||
|
||||
# Set up process environment (secrets stripped, explicit env kept)
|
||||
process_env = _build_subprocess_env(env)
|
||||
|
||||
# Expand tilde and environment variables in command arguments
|
||||
# (shell=False means the shell won't do this for us)
|
||||
command = [os.path.expanduser(os.path.expandvars(arg)) for arg in command]
|
||||
|
||||
# (shell=False means the shell won't do this for us). Expansion
|
||||
# uses the scrubbed env so `echo $OPENAI_API_KEY` cannot leak it.
|
||||
command = [os.path.expanduser(_expandvars(arg, process_env)) for arg in command]
|
||||
|
||||
# Expand tilde in cwd (subprocess doesn't do this)
|
||||
if cwd:
|
||||
cwd = os.path.expanduser(cwd)
|
||||
cwd = os.path.expandvars(cwd) # Also expand $HOME, $USER, etc.
|
||||
cwd = _expandvars(cwd, process_env) # Also expand $HOME, $USER, etc.
|
||||
if not os.path.isdir(cwd):
|
||||
# Fallback: try home directory, then current working directory
|
||||
fallback = os.path.expanduser("~") if os.path.isdir(os.path.expanduser("~")) else os.getcwd()
|
||||
logging.warning(f"Working directory '{cwd}' does not exist, using '{fallback}'")
|
||||
cwd = fallback
|
||||
|
||||
# Set up process environment
|
||||
process_env = os.environ.copy()
|
||||
if env:
|
||||
process_env.update(env)
|
||||
|
||||
if run_in_background:
|
||||
return self._start_background(command, cwd, process_env)
|
||||
@@ -256,43 +298,38 @@ class ShellTools:
|
||||
result['stderr_artifact'] = stderr_path
|
||||
return result
|
||||
|
||||
except _CommandCancelled:
|
||||
except _CommandCancelled as e:
|
||||
# A cooperative interrupt (/stop, Ctrl-C) fired while the child
|
||||
# was still running. Kill the whole process group so descendants
|
||||
# do not keep mutating state, then report an interrupted outcome.
|
||||
self._kill_process_tree(process)
|
||||
return {
|
||||
'stdout': '',
|
||||
'stderr': 'Command interrupted by user',
|
||||
'exit_code': -1,
|
||||
'success': False,
|
||||
'interrupted': True,
|
||||
'execution_time': time.time() - start_time
|
||||
}
|
||||
result = self._partial_result(
|
||||
e, 'Command interrupted by user', max_output_size, spill, spill_dir
|
||||
)
|
||||
result.update(interrupted=True, execution_time=time.time() - start_time)
|
||||
return result
|
||||
|
||||
except _StreamDrainTimeout:
|
||||
except _StreamDrainTimeout as e:
|
||||
# The direct child already exited; only an inherited pipe kept a
|
||||
# reader blocked. Report a timeout but do NOT kill — the direct
|
||||
# child's PID is gone (and may have been recycled), so killing it
|
||||
# would be unsafe and misleading.
|
||||
return {
|
||||
'stdout': '',
|
||||
'stderr': f'Command output streaming timed out after {timeout} seconds',
|
||||
'exit_code': -1,
|
||||
'success': False,
|
||||
'execution_time': timeout
|
||||
}
|
||||
result = self._partial_result(
|
||||
e, f'Command output streaming timed out after {timeout} seconds',
|
||||
max_output_size, spill, spill_dir,
|
||||
)
|
||||
result.update(timed_out=True, execution_time=timeout)
|
||||
return result
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
except subprocess.TimeoutExpired as e:
|
||||
# Kill process on timeout
|
||||
self._kill_process_tree(process)
|
||||
return {
|
||||
'stdout': '',
|
||||
'stderr': f'Command timed out after {timeout} seconds',
|
||||
'exit_code': -1,
|
||||
'success': False,
|
||||
'execution_time': timeout
|
||||
}
|
||||
result = self._partial_result(
|
||||
e, f'Command timed out after {timeout} seconds',
|
||||
max_output_size, spill, spill_dir,
|
||||
)
|
||||
result.update(timed_out=True, execution_time=timeout)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# Name the exception type and log the traceback. Some exceptions
|
||||
@@ -314,6 +351,28 @@ class ShellTools:
|
||||
'execution_time': 0
|
||||
}
|
||||
|
||||
def _partial_result(self, exc, reason, max_output_size, spill, spill_dir):
|
||||
"""Build a failed result that keeps output captured before ``exc``."""
|
||||
collect = getattr(exc, 'collect_output', None)
|
||||
stdout, stderr = collect() if collect else ('', '')
|
||||
stdout, stdout_path = self._handle_overflow(
|
||||
stdout, "stdout", max_output_size, spill, spill_dir
|
||||
)
|
||||
stderr, stderr_path = self._handle_overflow(
|
||||
stderr, "stderr", max_output_size, spill, spill_dir
|
||||
)
|
||||
result: Dict[str, Union[str, int, bool]] = {
|
||||
'stdout': stdout,
|
||||
'stderr': f"{stderr.rstrip()}\n{reason}" if stderr.strip() else reason,
|
||||
'exit_code': -1,
|
||||
'success': False,
|
||||
}
|
||||
if stdout_path:
|
||||
result['stdout_artifact'] = stdout_path
|
||||
if stderr_path:
|
||||
result['stderr_artifact'] = stderr_path
|
||||
return result
|
||||
|
||||
def _start_background(self, command, cwd, process_env):
|
||||
"""Start *command* detached, writing stdout+stderr to a log file."""
|
||||
import tempfile
|
||||
@@ -560,18 +619,31 @@ class ShellTools:
|
||||
t_out.start()
|
||||
t_err.start()
|
||||
|
||||
def _with_partial(exc, drain=True):
|
||||
# Keep whatever the command already printed: on a timeout/interrupt
|
||||
# the real error usually lives in the output produced so far. The
|
||||
# caller collects only AFTER killing the child, so the pipes close
|
||||
# and readline() returns trailing text written without a newline.
|
||||
def _collect():
|
||||
if drain:
|
||||
for reader in (t_out, t_err):
|
||||
reader.join(timeout=_DRAIN_GRACE_SECONDS)
|
||||
return "".join(stdout_chunks), "".join(stderr_chunks)
|
||||
exc.collect_output = _collect
|
||||
return exc
|
||||
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
if cancel_event is None:
|
||||
try:
|
||||
process.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
# The direct child is still running. Disable any further emission
|
||||
# and re-raise TimeoutExpired so the caller's timeout path KILLS
|
||||
# the process before returning — a recorded read error must not
|
||||
# short-circuit that cleanup (otherwise the still-running child
|
||||
# leaks with its pipes open).
|
||||
stop_emitting.set()
|
||||
raise
|
||||
raise _with_partial(exc)
|
||||
else:
|
||||
# Poll in short slices so a cooperative interrupt aborts the child
|
||||
# promptly (within the poll interval) rather than only on timeout.
|
||||
@@ -579,12 +651,12 @@ class ShellTools:
|
||||
while True:
|
||||
if cancel_event.is_set():
|
||||
stop_emitting.set()
|
||||
raise _CommandCancelled()
|
||||
raise _with_partial(_CommandCancelled())
|
||||
if deadline is not None:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
stop_emitting.set()
|
||||
raise subprocess.TimeoutExpired(process.args, timeout)
|
||||
raise _with_partial(subprocess.TimeoutExpired(process.args, timeout))
|
||||
wait_slice = min(_POLL, remaining)
|
||||
else:
|
||||
wait_slice = _POLL
|
||||
@@ -616,7 +688,7 @@ class ShellTools:
|
||||
# Stop emitting and signal a drain-timeout so the caller reports a
|
||||
# timeout WITHOUT trying to kill an already-exited process.
|
||||
stop_emitting.set()
|
||||
raise _StreamDrainTimeout()
|
||||
raise _with_partial(_StreamDrainTimeout(), drain=False)
|
||||
|
||||
if read_errors:
|
||||
raise read_errors[0]
|
||||
|
||||
@@ -395,6 +395,24 @@ def _html_to_markdown(html: str, base_url: Optional[str] = None) -> tuple:
|
||||
return markdown, unescape(parser.title).strip()
|
||||
|
||||
|
||||
# Raw bytes read per page. Only 50,000 characters are returned, so a few MB is
|
||||
# ample and keeps memory (and HTML parsing) bounded for oversized responses.
|
||||
_MAX_CRAWL_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
def _read_capped_httpx(response) -> str:
|
||||
"""Read at most ``_MAX_CRAWL_BYTES`` from a streamed httpx response."""
|
||||
buf = bytearray()
|
||||
for chunk in response.iter_bytes(chunk_size=64 * 1024):
|
||||
buf += chunk
|
||||
if len(buf) >= _MAX_CRAWL_BYTES:
|
||||
break
|
||||
try:
|
||||
return bytes(buf[:_MAX_CRAWL_BYTES]).decode(response.charset_encoding or "utf-8", errors="ignore")
|
||||
except LookupError:
|
||||
return bytes(buf[:_MAX_CRAWL_BYTES]).decode("utf-8", errors="ignore")
|
||||
|
||||
|
||||
def _crawl_with_httpx(urls: List[str]) -> List[Dict[str, Any]]:
|
||||
"""Crawl URLs using basic HTTP fetch with httpx or urllib."""
|
||||
import urllib.parse
|
||||
@@ -409,31 +427,49 @@ def _crawl_with_httpx(urls: List[str]) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
import httpx
|
||||
current = url
|
||||
response = None
|
||||
with httpx.Client(follow_redirects=False, timeout=30.0) as client:
|
||||
for _ in range(max_redirects + 1):
|
||||
if not _is_safe_crawl_url(current):
|
||||
raise ValueError("Redirect target failed SSRF validation")
|
||||
response = client.get(current)
|
||||
if response.status_code not in redirect_statuses:
|
||||
break
|
||||
location = response.headers.get("Location")
|
||||
if not location:
|
||||
break
|
||||
# Stream so the body is never buffered past the byte cap.
|
||||
with client.stream("GET", current) as response:
|
||||
location = response.headers.get("Location")
|
||||
if response.status_code not in redirect_statuses or not location:
|
||||
response.raise_for_status()
|
||||
content = _read_capped_httpx(response)
|
||||
base_url = current
|
||||
break
|
||||
current = urllib.parse.urljoin(current, location)
|
||||
else:
|
||||
raise ValueError("Too many redirects")
|
||||
if response is not None:
|
||||
response.raise_for_status()
|
||||
content = response.text
|
||||
base_url = current
|
||||
except ImportError:
|
||||
# Fallback to urllib
|
||||
# Fallback to urllib, walking redirects by hand so every hop
|
||||
# is SSRF-validated before it is requested (as above).
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
if not _is_safe_crawl_url(url):
|
||||
raise ValueError("URL blocked by SSRF policy")
|
||||
with urllib.request.urlopen(url, timeout=30) as response:
|
||||
content = response.read().decode('utf-8', errors='ignore')
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None # surface 30x as HTTPError
|
||||
|
||||
opener = urllib.request.build_opener(_NoRedirect)
|
||||
current = url
|
||||
for _ in range(max_redirects + 1):
|
||||
if not _is_safe_crawl_url(current):
|
||||
raise ValueError("Redirect target failed SSRF validation")
|
||||
try:
|
||||
with opener.open(current, timeout=30) as response:
|
||||
content = response.read(_MAX_CRAWL_BYTES).decode('utf-8', errors='ignore')
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
location = e.headers.get("Location")
|
||||
if e.code not in redirect_statuses or not location:
|
||||
raise
|
||||
e.close() # release the redirect response's socket
|
||||
current = urllib.parse.urljoin(current, location)
|
||||
else:
|
||||
raise ValueError("Too many redirects")
|
||||
base_url = current
|
||||
|
||||
# Convert HTML to structured Markdown using the standard library so
|
||||
# headings, lists, links, and code blocks survive on this path.
|
||||
|
||||
@@ -6,11 +6,11 @@ Exposes PraisonAI Agents via the A2A (Agent2Agent) protocol.
|
||||
|
||||
import asyncio
|
||||
from praisonaiagents._logging import get_logger
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
from typing import Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from praisonaiagents.ui.a2a.types import AgentCard
|
||||
from praisonaiagents.ui.a2a.agent_card import generate_agent_card
|
||||
from praisonaiagents.ui.a2a.task_store import TaskStore
|
||||
from praisonaiagents.ui.a2a.task_store import TaskStore, TERMINAL_STATES
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from praisonaiagents import Agent, AgentTeam
|
||||
@@ -98,6 +98,8 @@ class A2A:
|
||||
|
||||
# Initialize task store
|
||||
self.task_store = TaskStore()
|
||||
# Strong refs to background tasks so they aren't GC'd and can be cancelled
|
||||
self._running: Dict[str, asyncio.Task] = {}
|
||||
|
||||
# Router cache
|
||||
self._router: Optional["APIRouter"] = None
|
||||
@@ -290,12 +292,17 @@ class A2A:
|
||||
self.task_store.add_to_history(task_id, response_msg)
|
||||
self.task_store.add_artifact(task_id, artifact)
|
||||
self.task_store.update_status(task_id, TaskState.COMPLETED)
|
||||
except asyncio.CancelledError:
|
||||
self.task_store.update_status(task_id, TaskState.CANCELLED)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Background agent error: {e}")
|
||||
self.task_store.update_status(task_id, TaskState.FAILED)
|
||||
|
||||
user_input = extract_user_input([message])
|
||||
asyncio.create_task(_background_process(task.id, user_input))
|
||||
bg = asyncio.create_task(_background_process(task.id, user_input))
|
||||
self._running[task.id] = bg
|
||||
bg.add_done_callback(lambda _t, tid=task.id: self._running.pop(tid, None))
|
||||
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": "2.0",
|
||||
@@ -305,6 +312,9 @@ class A2A:
|
||||
|
||||
# Update to working
|
||||
self.task_store.update_status(task.id, TaskState.WORKING)
|
||||
# Keep the task while we wait on the agent: a cancel can make it
|
||||
# terminal (and evictable) before we build the response.
|
||||
self.task_store._pinned.add(task.id)
|
||||
|
||||
try:
|
||||
# Extract user input text
|
||||
@@ -333,6 +343,8 @@ class A2A:
|
||||
except Exception as e:
|
||||
logger.error(f"Agent error in message/send: {e}")
|
||||
updated_task = self.task_store.update_status(task.id, TaskState.FAILED)
|
||||
finally:
|
||||
self.task_store._pinned.discard(task.id)
|
||||
|
||||
# Return JSON-RPC response
|
||||
return JSONResponse(content={
|
||||
@@ -410,9 +422,19 @@ class A2A:
|
||||
if not task_id:
|
||||
return _jsonrpc_error(request_id, -32602, "Invalid params: 'id' required")
|
||||
|
||||
task = self.task_store.cancel_task(task_id)
|
||||
if not task:
|
||||
existing = self.task_store.get_task(task_id)
|
||||
if not existing:
|
||||
return _jsonrpc_error(request_id, -32000, f"Task not found: {task_id}")
|
||||
if existing.status.state in TERMINAL_STATES:
|
||||
return _jsonrpc_error(
|
||||
request_id, -32002,
|
||||
f"Task not cancelable: already {existing.status.state.value}",
|
||||
)
|
||||
|
||||
task = self.task_store.cancel_task(task_id)
|
||||
running = self._running.get(task_id)
|
||||
if running:
|
||||
running.cancel()
|
||||
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": "2.0",
|
||||
|
||||
@@ -6,7 +6,7 @@ In-memory storage for A2A Task lifecycle management.
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from praisonaiagents.ui.a2a.types import (
|
||||
Task,
|
||||
@@ -16,6 +16,12 @@ from praisonaiagents.ui.a2a.types import (
|
||||
Artifact,
|
||||
)
|
||||
|
||||
# Terminal states are final: once reached, status can no longer change.
|
||||
TERMINAL_STATES = frozenset({TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED})
|
||||
|
||||
# Upper bound on stored tasks; oldest terminal tasks are evicted beyond this.
|
||||
_MAX_TASKS = 10_000
|
||||
|
||||
|
||||
class TaskStore:
|
||||
"""
|
||||
@@ -28,6 +34,8 @@ class TaskStore:
|
||||
def __init__(self):
|
||||
"""Initialize empty task store."""
|
||||
self._tasks: Dict[str, Task] = {}
|
||||
# Ids of tasks a request is still waiting on; never evicted.
|
||||
self._pinned: Set[str] = set()
|
||||
|
||||
def create_task(self, message: Message, context_id: Optional[str] = None) -> Task:
|
||||
"""
|
||||
@@ -59,8 +67,22 @@ class TaskStore:
|
||||
)
|
||||
|
||||
self._tasks[task_id] = task
|
||||
self._evict()
|
||||
return task
|
||||
|
||||
def _evict(self, keep: Optional[str] = None):
|
||||
"""Drop the oldest terminal tasks (except *keep*) once over the cap."""
|
||||
excess = len(self._tasks) - _MAX_TASKS
|
||||
if excess <= 0:
|
||||
return
|
||||
old = [
|
||||
t.id for t in self._tasks.values()
|
||||
if t.status.state in TERMINAL_STATES
|
||||
and t.id != keep and t.id not in self._pinned
|
||||
]
|
||||
for tid in old[:excess]:
|
||||
del self._tasks[tid]
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[Task]:
|
||||
"""
|
||||
Retrieve a task by its ID.
|
||||
@@ -88,11 +110,14 @@ class TaskStore:
|
||||
message: Optional status message
|
||||
|
||||
Returns:
|
||||
Updated Task if found, None otherwise
|
||||
Updated Task if found, None otherwise. A task already in a
|
||||
terminal state is returned unchanged.
|
||||
"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return None
|
||||
if task.status.state in TERMINAL_STATES:
|
||||
return task
|
||||
|
||||
# Create new status
|
||||
new_status = TaskStatus(
|
||||
@@ -112,6 +137,8 @@ class TaskStore:
|
||||
)
|
||||
|
||||
self._tasks[task_id] = updated
|
||||
if state in TERMINAL_STATES:
|
||||
self._evict(keep=task_id)
|
||||
return updated
|
||||
|
||||
def add_artifact(self, task_id: str, artifact: Artifact) -> Optional[Task]:
|
||||
@@ -123,11 +150,14 @@ class TaskStore:
|
||||
artifact: Artifact to add
|
||||
|
||||
Returns:
|
||||
Updated Task if found, None otherwise
|
||||
Updated Task if found, None otherwise. A task in a terminal
|
||||
state is returned unchanged.
|
||||
"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return None
|
||||
if task.status.state in TERMINAL_STATES:
|
||||
return task
|
||||
|
||||
# Get existing artifacts or create new list
|
||||
artifacts = list(task.artifacts) if task.artifacts else []
|
||||
@@ -155,11 +185,14 @@ class TaskStore:
|
||||
message: Message to add to history
|
||||
|
||||
Returns:
|
||||
Updated Task if found, None otherwise
|
||||
Updated Task if found, None otherwise. A task in a terminal
|
||||
state is returned unchanged.
|
||||
"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return None
|
||||
if task.status.state in TERMINAL_STATES:
|
||||
return task
|
||||
|
||||
# Get existing history or create new list
|
||||
history = list(task.history) if task.history else []
|
||||
|
||||
@@ -120,4 +120,28 @@ async def run_sync_in_executor(func, *args, **kwargs) -> Any:
|
||||
return func(*args, **kwargs)
|
||||
return await loop.run_in_executor(None, wrapper)
|
||||
else:
|
||||
return await loop.run_in_executor(None, func, *args)
|
||||
return await loop.run_in_executor(None, func, *args)
|
||||
|
||||
# Strong refs to fire-and-forget tasks: the event loop only keeps weak refs,
|
||||
# so an unreferenced task can be garbage-collected before it finishes.
|
||||
_background_tasks: "set[asyncio.Task]" = set()
|
||||
|
||||
|
||||
def spawn_background(coro: Awaitable[Any], label: str = "background task") -> "asyncio.Task":
|
||||
"""
|
||||
Schedule *coro* on the running loop without awaiting it.
|
||||
|
||||
Keeps a strong reference until the task finishes and logs any exception
|
||||
with *label*, instead of leaving it to asyncio's "Task exception was never
|
||||
retrieved" warning at GC time. Must be called with a running loop.
|
||||
"""
|
||||
task = asyncio.get_running_loop().create_task(coro)
|
||||
_background_tasks.add(task)
|
||||
|
||||
def _done(t: "asyncio.Task") -> None:
|
||||
_background_tasks.discard(t)
|
||||
if not t.cancelled() and t.exception() is not None:
|
||||
logger.error("Error in %s: %s", label, t.exception(), exc_info=t.exception())
|
||||
|
||||
task.add_done_callback(_done)
|
||||
return task
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Resolve a caller-supplied name to a file directly inside a base directory.
|
||||
|
||||
Used wherever a store builds ``base / f"{name}{suffix}"`` from a session,
|
||||
checkpoint, rule, doc or config name, so the name cannot climb out of the
|
||||
store via ``..`` or path separators.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["safe_child"]
|
||||
|
||||
|
||||
def safe_child(base: Path, name: str, suffix: str = "") -> Path:
|
||||
"""Return ``base / f"{name}{suffix}"``, refusing names that escape ``base``."""
|
||||
if (
|
||||
not isinstance(name, str)
|
||||
or not name.strip()
|
||||
or name in (".", "..")
|
||||
or any(sep in name for sep in ("/", "\\", "\0"))
|
||||
):
|
||||
raise ValueError(f"invalid name: {name!r}")
|
||||
path = Path(base) / f"{name}{suffix}"
|
||||
if path.resolve().parent != Path(base).resolve():
|
||||
raise ValueError(f"name escapes {base}: {name!r}")
|
||||
return path
|
||||
@@ -27,6 +27,7 @@ import time
|
||||
import logging
|
||||
import collections
|
||||
from praisonaiagents._logging import get_logger
|
||||
from praisonaiagents.utils.safe_path import safe_child
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Callable, Tuple, Union
|
||||
from dataclasses import dataclass, field
|
||||
@@ -6564,7 +6565,7 @@ class WorkflowManager:
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
checkpoint_file = self._get_checkpoints_dir() / f"{name}.json"
|
||||
checkpoint_file = safe_child(self._get_checkpoints_dir(), name, ".json")
|
||||
|
||||
checkpoint_data = {
|
||||
"name": name,
|
||||
@@ -6599,7 +6600,7 @@ class WorkflowManager:
|
||||
"""
|
||||
import json
|
||||
|
||||
checkpoint_file = self._get_checkpoints_dir() / f"{name}.json"
|
||||
checkpoint_file = safe_child(self._get_checkpoints_dir(), name, ".json")
|
||||
|
||||
if not checkpoint_file.exists():
|
||||
self._log(f"Checkpoint '{name}' not found")
|
||||
@@ -6647,7 +6648,7 @@ class WorkflowManager:
|
||||
Returns:
|
||||
True if deleted successfully
|
||||
"""
|
||||
checkpoint_file = self._get_checkpoints_dir() / f"{name}.json"
|
||||
checkpoint_file = safe_child(self._get_checkpoints_dir(), name, ".json")
|
||||
|
||||
if checkpoint_file.exists():
|
||||
checkpoint_file.unlink()
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.14"
|
||||
version = "1.7.16"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -51,7 +51,7 @@ parallel-search = [
|
||||
|
||||
memory = [
|
||||
"chromadb>=1.0.0",
|
||||
"litellm>=1.83.14",
|
||||
"litellm>=1.83.14,<1.106", # 1.106 requires mcp>=2.2; mcp is pinned <2
|
||||
]
|
||||
|
||||
knowledge = [
|
||||
@@ -69,7 +69,7 @@ graph = [
|
||||
|
||||
# Add LLM dependencies
|
||||
llm = [
|
||||
"litellm>=1.83.14",
|
||||
"litellm>=1.83.14,<1.106", # 1.106 requires mcp>=2.2; mcp is pinned <2
|
||||
"pydantic>=2.10.0"
|
||||
]
|
||||
|
||||
|
||||
@@ -388,7 +388,7 @@ class TestA2AJsonRpc:
|
||||
assert data["error"]["code"] == -32000
|
||||
|
||||
def test_post_a2a_tasks_cancel(self):
|
||||
"""tasks/cancel → cancels existing task."""
|
||||
"""tasks/cancel on a completed task → -32002 TaskNotCancelable."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, a2a = _make_app()
|
||||
@@ -421,8 +421,14 @@ class TestA2AJsonRpc:
|
||||
|
||||
assert cancel_resp.status_code == 200
|
||||
data = cancel_resp.json()
|
||||
assert "result" in data
|
||||
assert data["result"]["status"]["state"] == "cancelled"
|
||||
assert data["error"]["code"] == -32002
|
||||
|
||||
# The completed task keeps its terminal state
|
||||
get_resp = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0", "method": "tasks/get", "id": "3",
|
||||
"params": {"id": task_id},
|
||||
})
|
||||
assert get_resp.json()["result"]["status"]["state"] == "completed"
|
||||
|
||||
def test_post_a2a_message_stream(self):
|
||||
"""message/stream → returns SSE streaming response."""
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""tasks/cancel must stop background work and stay final (issue #5990)."""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.ui.a2a.task_store import TaskStore
|
||||
from praisonaiagents.ui.a2a.types import Message, Role, TextPart, TaskState
|
||||
|
||||
|
||||
def _msg():
|
||||
return Message(message_id="m1", role=Role.USER, parts=[TextPart(text="hi")])
|
||||
|
||||
|
||||
def test_terminal_states_are_final():
|
||||
store = TaskStore()
|
||||
t = store.create_task(_msg())
|
||||
store.update_status(t.id, TaskState.WORKING)
|
||||
store.cancel_task(t.id)
|
||||
store.update_status(t.id, TaskState.COMPLETED)
|
||||
assert store.get_task(t.id).status.state == TaskState.CANCELLED
|
||||
|
||||
t2 = store.create_task(_msg())
|
||||
store.update_status(t2.id, TaskState.COMPLETED)
|
||||
store.cancel_task(t2.id)
|
||||
assert store.get_task(t2.id).status.state == TaskState.COMPLETED
|
||||
|
||||
|
||||
def test_store_evicts_oldest_terminal_tasks(monkeypatch):
|
||||
from praisonaiagents.ui.a2a import task_store as ts
|
||||
monkeypatch.setattr(ts, "_MAX_TASKS", 3)
|
||||
store = TaskStore()
|
||||
done = store.create_task(_msg())
|
||||
store.update_status(done.id, TaskState.COMPLETED)
|
||||
live = [store.create_task(_msg()) for _ in range(3)]
|
||||
assert store.get_task(done.id) is None
|
||||
assert all(store.get_task(t.id) for t in live)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_stops_background_task_and_is_not_overwritten():
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.ui.a2a import A2A
|
||||
|
||||
release = threading.Event()
|
||||
agent = Agent(name="Cancel Test", role="Tester", goal="Test")
|
||||
agent.chat = lambda _text: (release.wait(5), "late result")[1]
|
||||
a2a = A2A(agent=agent, url="http://localhost:8000/a2a")
|
||||
|
||||
resp = await a2a._handle_message_send("1", {
|
||||
"message": {"messageId": "m1", "role": "user", "parts": [{"text": "Hi"}]},
|
||||
"configuration": {"returnImmediately": True},
|
||||
})
|
||||
import json
|
||||
task_id = json.loads(resp.body)["result"]["id"]
|
||||
await asyncio.sleep(0.05)
|
||||
running = a2a._running[task_id]
|
||||
|
||||
a2a._handle_tasks_cancel("2", {"id": task_id})
|
||||
release.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await running
|
||||
|
||||
task = a2a.task_store.get_task(task_id)
|
||||
assert task.status.state == TaskState.CANCELLED
|
||||
assert not task.artifacts
|
||||
assert task_id not in a2a._running
|
||||
|
||||
|
||||
def test_store_evicts_when_tasks_finish(monkeypatch):
|
||||
from praisonaiagents.ui.a2a import task_store as ts
|
||||
monkeypatch.setattr(ts, "_MAX_TASKS", 2)
|
||||
store = TaskStore()
|
||||
live = [store.create_task(_msg()) for _ in range(4)]
|
||||
for t in live:
|
||||
store.update_status(t.id, TaskState.COMPLETED)
|
||||
assert len(store.list_tasks()) == 2
|
||||
# The task that just finished is kept for the caller's response.
|
||||
assert store.get_task(live[-1].id) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_during_sync_send_discards_late_result():
|
||||
import json
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.ui.a2a import A2A
|
||||
|
||||
started, release = threading.Event(), threading.Event()
|
||||
agent = Agent(name="Cancel Test", role="Tester", goal="Test")
|
||||
agent.chat = lambda _text: (started.set(), release.wait(5), "late result")[2]
|
||||
a2a = A2A(agent=agent, url="http://localhost:8000/a2a")
|
||||
|
||||
send = asyncio.create_task(a2a._handle_message_send("1", {
|
||||
"message": {"messageId": "m1", "role": "user", "parts": [{"text": "Hi"}]},
|
||||
}))
|
||||
await asyncio.to_thread(started.wait, 5)
|
||||
task_id = a2a.task_store.list_tasks()[0].id
|
||||
a2a._handle_tasks_cancel("2", {"id": task_id})
|
||||
release.set()
|
||||
result = json.loads((await send).body)["result"]
|
||||
|
||||
task = a2a.task_store.get_task(task_id)
|
||||
assert result["status"]["state"] == TaskState.CANCELLED.value
|
||||
assert task.status.state == TaskState.CANCELLED
|
||||
assert not task.artifacts
|
||||
assert all(m.role == Role.USER for m in task.history or [])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_sync_send_survives_eviction(monkeypatch):
|
||||
import json
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.ui.a2a import A2A
|
||||
from praisonaiagents.ui.a2a import task_store as ts
|
||||
|
||||
monkeypatch.setattr(ts, "_MAX_TASKS", 2)
|
||||
started, release = threading.Event(), threading.Event()
|
||||
agent = Agent(name="Cancel Test", role="Tester", goal="Test")
|
||||
agent.chat = lambda _text: (started.set(), release.wait(5), "late result")[2]
|
||||
a2a = A2A(agent=agent, url="http://localhost:8000/a2a")
|
||||
|
||||
send = asyncio.create_task(a2a._handle_message_send("1", {
|
||||
"message": {"messageId": "m1", "role": "user", "parts": [{"text": "Hi"}]},
|
||||
}))
|
||||
await asyncio.to_thread(started.wait, 5)
|
||||
task_id = a2a.task_store.list_tasks()[0].id
|
||||
a2a._handle_tasks_cancel("2", {"id": task_id})
|
||||
# Other tasks finish and push the store over the cap while we wait.
|
||||
for _ in range(3):
|
||||
other = a2a.task_store.create_task(_msg())
|
||||
a2a.task_store.update_status(other.id, TaskState.COMPLETED)
|
||||
assert a2a.task_store.get_task(task_id) is not None
|
||||
release.set()
|
||||
result = json.loads((await send).body)["result"]
|
||||
|
||||
assert result["id"] == task_id
|
||||
assert result["status"]["state"] == TaskState.CANCELLED.value
|
||||
assert task_id not in a2a.task_store._pinned
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Tool guards on a shared Agent must not punish the wrong caller (issue #5860).
|
||||
|
||||
1. Each chat turn gets its own loop guard, so overlapping turns never exhaust
|
||||
each other's per-turn limits.
|
||||
2. Caller errors (ValueError/TypeError/AttributeError from bad input) do not
|
||||
open the tool circuit breaker for every other caller; real failures still do.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_factory(monkeypatch):
|
||||
from praisonaiagents import Agent
|
||||
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
||||
|
||||
def make(tools):
|
||||
return Agent(name="shared", instructions="x", tools=tools, llm="gpt-4o-mini")
|
||||
|
||||
return make
|
||||
|
||||
|
||||
def _lookup(path: str) -> str:
|
||||
"""Read something (distinct path each call)."""
|
||||
return f"read {path}"
|
||||
|
||||
|
||||
def test_overlapping_turns_get_independent_loop_guards(agent_factory):
|
||||
from praisonaiagents.agent.chat_mixin import _enter_turn, _exit_turn
|
||||
|
||||
agent = agent_factory([_lookup])
|
||||
agent_guard = agent._ensure_loop_guard()
|
||||
outer = _enter_turn(agent, None) # a long-running turn stays in flight
|
||||
try:
|
||||
outer_guard = agent._ensure_loop_guard()
|
||||
assert outer_guard is not agent_guard
|
||||
assert outer_guard.config is agent_guard.config
|
||||
outer_guard.record("lookup", {"q": "x"}, True, "same")
|
||||
|
||||
seen = {}
|
||||
|
||||
def other_turn():
|
||||
tok = _enter_turn(agent, None)
|
||||
try:
|
||||
seen["guard"] = agent._ensure_loop_guard()
|
||||
finally:
|
||||
_exit_turn(agent, tok)
|
||||
|
||||
th = threading.Thread(target=other_turn)
|
||||
th.start()
|
||||
th.join(5)
|
||||
assert seen["guard"] is not outer_guard
|
||||
assert seen["guard"]._tool_counts == {}
|
||||
assert outer_guard._tool_counts == {"lookup": 1}
|
||||
finally:
|
||||
_exit_turn(agent, outer)
|
||||
# Outside a turn the agent-level guard is used, as before.
|
||||
assert agent._ensure_loop_guard() is agent_guard
|
||||
|
||||
|
||||
def test_many_overlapping_turns_are_not_halted(agent_factory):
|
||||
from praisonaiagents.agent.chat_mixin import _enter_turn, _exit_turn
|
||||
|
||||
agent = agent_factory([_lookup])
|
||||
agent._ensure_loop_guard()
|
||||
tokens = []
|
||||
try:
|
||||
for i in range(20): # 20 concurrent turns, one tool call each
|
||||
tokens.append(_enter_turn(agent, None))
|
||||
result = agent.execute_tool("_lookup", {"path": f"/tmp/{i}"})
|
||||
assert result == f"read /tmp/{i}"
|
||||
finally:
|
||||
for tok in reversed(tokens):
|
||||
_exit_turn(agent, tok)
|
||||
|
||||
|
||||
def _get_order(order_id: str) -> str:
|
||||
"""Look up an order. Raises for unknown ids."""
|
||||
if not order_id.startswith("A"):
|
||||
raise ValueError(f"unknown order {order_id}")
|
||||
return f"order {order_id}: shipped"
|
||||
|
||||
|
||||
def _down(x: str) -> str:
|
||||
"""A tool whose dependency is down."""
|
||||
raise RuntimeError("dependency down")
|
||||
|
||||
|
||||
def _sync_run(agent, name, args):
|
||||
try:
|
||||
return agent.execute_tool(name, args)
|
||||
except Exception as e: # surfaced ToolExecutionError
|
||||
return str(e)
|
||||
|
||||
|
||||
def _async_run(agent, name, args):
|
||||
try:
|
||||
return asyncio.run(agent.execute_tool_async(name, args))
|
||||
except Exception as e:
|
||||
return str(e)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("run", [_sync_run, _async_run], ids=["sync", "async"])
|
||||
def test_bad_input_does_not_open_circuit_breaker(agent_factory, run):
|
||||
agent = agent_factory([_get_order])
|
||||
agent._ensure_loop_guard().config.enabled = False
|
||||
for i in range(6):
|
||||
run(agent, "_get_order", {"order_id": f"bad{i}"})
|
||||
assert run(agent, "_get_order", {"order_id": "A100"}) == "order A100: shipped"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("run", [_sync_run, _async_run], ids=["sync", "async"])
|
||||
def test_real_failures_still_open_circuit_breaker(agent_factory, run):
|
||||
agent = agent_factory([_down])
|
||||
agent._ensure_loop_guard().config.enabled = False
|
||||
for _ in range(6):
|
||||
result = run(agent, "_down", {"x": "1"})
|
||||
assert "circuit breaker open" in str(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("run", [_sync_run, _async_run], ids=["sync", "async"])
|
||||
def test_bad_input_does_not_close_half_open_breaker(agent_factory, run):
|
||||
from praisonaiagents.tools.circuit_breaker import CircuitState, _get_global_registry
|
||||
|
||||
agent = agent_factory([_get_order])
|
||||
agent._ensure_loop_guard().config.enabled = False
|
||||
run(agent, "_get_order", {"order_id": "A1"}) # create the breaker
|
||||
breaker = _get_global_registry().get(f"tool_{id(agent)}__get_order")
|
||||
with breaker._lock:
|
||||
breaker._stats.state = CircuitState.HALF_OPEN
|
||||
breaker._stats.success_count = 0
|
||||
for i in range(breaker.config.success_threshold + 1):
|
||||
run(agent, "_get_order", {"order_id": f"bad{i}"})
|
||||
assert breaker.state == CircuitState.HALF_OPEN
|
||||
assert breaker.stats.success_count == 0
|
||||
|
||||
|
||||
def test_streams_get_their_own_loop_guard(agent_factory, monkeypatch):
|
||||
agent = agent_factory([_lookup])
|
||||
agent_guard = agent._ensure_loop_guard()
|
||||
seen = []
|
||||
|
||||
def fake_impl(prompt, **kwargs):
|
||||
guard = agent._ensure_loop_guard()
|
||||
guard.record("lookup", {"q": prompt}, True, "same")
|
||||
seen.append(guard)
|
||||
yield prompt
|
||||
|
||||
monkeypatch.setattr(agent, "_start_stream_impl", fake_impl)
|
||||
assert list(agent._start_stream("a")) == ["a"]
|
||||
assert list(agent._start_stream("b")) == ["b"]
|
||||
assert seen[0] is not seen[1] and agent_guard not in seen
|
||||
assert seen[1]._tool_counts == {"lookup": 1}
|
||||
assert agent._ensure_loop_guard() is agent_guard
|
||||
assert agent_guard._tool_counts == {}
|
||||
@@ -497,3 +497,22 @@ class TestEventType:
|
||||
bus.publish(EventType.SESSION_CREATED, {})
|
||||
|
||||
assert len(received) == 1
|
||||
|
||||
|
||||
class TestAsyncSubscriberErrors:
|
||||
"""In-loop async subscriber failures are logged by the bus (issue #5990)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_subscriber_error_is_logged(self, caplog):
|
||||
import logging
|
||||
bus = EventBus()
|
||||
|
||||
async def boom(event):
|
||||
raise ValueError("boom")
|
||||
|
||||
sub_id = bus.subscribe(boom)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
bus.publish("test.event", {})
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
assert any(sub_id in r.getMessage() and "boom" in r.getMessage() for r in caplog.records)
|
||||
@@ -259,15 +259,31 @@ class TestContextManager:
|
||||
manager = ContextManager(model="gpt-4o-mini")
|
||||
|
||||
text = "Hello world"
|
||||
|
||||
# First call
|
||||
tokens1, _ = manager.estimate_tokens(text)
|
||||
|
||||
# Second call should use cache
|
||||
tokens2, _ = manager.estimate_tokens(text)
|
||||
|
||||
|
||||
with patch(
|
||||
"praisonaiagents.context.tokens.estimate_tokens_accurate", return_value=3
|
||||
) as accurate:
|
||||
# First validated call computes and caches
|
||||
tokens1, _ = manager.estimate_tokens(text, validate=True)
|
||||
|
||||
# Second call should use cache
|
||||
tokens2, _ = manager.estimate_tokens(text, validate=True)
|
||||
|
||||
assert tokens1 == tokens2
|
||||
|
||||
assert accurate.call_count == 1
|
||||
|
||||
def test_estimate_tokens_heuristic_skips_hashing(self):
|
||||
"""Heuristic path must not hash or cache the text."""
|
||||
manager = ContextManager(model="gpt-4o-mini")
|
||||
|
||||
with patch("praisonaiagents.context.manager.hashlib.sha256") as sha:
|
||||
tokens, metrics = manager.estimate_tokens("Hello world")
|
||||
|
||||
assert tokens > 0
|
||||
assert metrics is None
|
||||
sha.assert_not_called()
|
||||
assert manager._estimation_cache == {}
|
||||
|
||||
def test_reset(self):
|
||||
"""Test manager reset."""
|
||||
manager = ContextManager(model="gpt-4o-mini")
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""An unset LLM timeout is bounded; an explicit None/0 opts out (Issue #5968)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.llm.llm import LLM, _DEFAULT_LLM_TIMEOUT_S
|
||||
|
||||
|
||||
def _params(llm):
|
||||
return (
|
||||
llm._build_completion_params(),
|
||||
llm._build_responses_params([{'role': 'user', 'content': 'hi'}]),
|
||||
)
|
||||
|
||||
|
||||
def test_unset_timeout_uses_finite_default():
|
||||
llm = LLM(model='gpt-4o-mini')
|
||||
assert llm.timeout == _DEFAULT_LLM_TIMEOUT_S
|
||||
for params in _params(llm):
|
||||
assert params['timeout'] == _DEFAULT_LLM_TIMEOUT_S
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value', [None, 0])
|
||||
def test_explicit_opt_out_omits_timeout(value):
|
||||
llm = LLM(model='gpt-4o-mini', timeout=value)
|
||||
for params in _params(llm):
|
||||
assert 'timeout' not in params
|
||||
|
||||
|
||||
def test_explicit_timeout_is_forwarded():
|
||||
llm = LLM(model='gpt-4o-mini', timeout=30)
|
||||
for params in _params(llm):
|
||||
assert params['timeout'] == 30
|
||||
@@ -7,6 +7,14 @@ do not require live network access or a browser.
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def public_dns(monkeypatch):
|
||||
"""Treat example.com hosts as public without needing live DNS."""
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.tools.url_safety.is_safe_http_url", lambda url: True
|
||||
)
|
||||
|
||||
|
||||
class TestParseWWWAuthenticate:
|
||||
def test_none_returns_empty(self):
|
||||
from praisonaiagents.mcp.oauth_discovery import parse_www_authenticate
|
||||
@@ -31,7 +39,7 @@ class TestHttpsGuard:
|
||||
with pytest.raises(ValueError):
|
||||
_require_https("http://evil.example.com/.well-known/x")
|
||||
|
||||
def test_allows_https(self):
|
||||
def test_allows_https(self, public_dns):
|
||||
from praisonaiagents.mcp.oauth_discovery import _require_https
|
||||
_require_https("https://api.example.com/.well-known/x")
|
||||
|
||||
@@ -50,7 +58,7 @@ class TestWellKnownCandidates:
|
||||
|
||||
|
||||
class TestEndpointHttpsValidation:
|
||||
def test_rejects_plaintext_advertised_endpoints(self, monkeypatch):
|
||||
def test_rejects_plaintext_advertised_endpoints(self, monkeypatch, public_dns):
|
||||
from praisonaiagents.mcp import oauth_discovery
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -64,10 +72,10 @@ class TestEndpointHttpsValidation:
|
||||
"token_endpoint": "http://auth.example.com/token",
|
||||
},
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
with pytest.raises(ValueError, match="insecure transport"):
|
||||
oauth_discovery.discover_oauth_metadata("https://api.example.com/mcp")
|
||||
|
||||
def test_accepts_all_https_endpoints(self, monkeypatch):
|
||||
def test_accepts_all_https_endpoints(self, monkeypatch, public_dns):
|
||||
from praisonaiagents.mcp import oauth_discovery
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -87,32 +95,52 @@ class TestEndpointHttpsValidation:
|
||||
|
||||
|
||||
class TestRedirectRevalidation:
|
||||
def test_rejects_redirect_to_plaintext(self, monkeypatch):
|
||||
def test_rejects_redirect_before_requesting_it(self, monkeypatch):
|
||||
from praisonaiagents.mcp import oauth_discovery
|
||||
|
||||
class _Hop:
|
||||
url = "http://internal.local/.well-known/x"
|
||||
requested = []
|
||||
|
||||
class _Resp:
|
||||
url = "http://internal.local/.well-known/x"
|
||||
history = [_Hop()]
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def json(self):
|
||||
return {}
|
||||
status_code = 302
|
||||
headers = {"Location": "http://internal.local/.well-known/x"}
|
||||
|
||||
class _Requests:
|
||||
@staticmethod
|
||||
def get(*a, **k):
|
||||
def get(url, **k):
|
||||
requested.append(url)
|
||||
assert k["allow_redirects"] is False
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setitem(
|
||||
__import__("sys").modules, "requests", _Requests()
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
oauth_discovery._get_json("https://api.example.com/.well-known/x")
|
||||
oauth_discovery._get_json("http://127.0.0.1:8080/.well-known/x")
|
||||
# The redirect target was refused without ever being fetched.
|
||||
assert requested == ["http://127.0.0.1:8080/.well-known/x"]
|
||||
|
||||
|
||||
class TestPrivateHostGuard:
|
||||
@pytest.mark.parametrize("url", [
|
||||
"https://169.254.169.254/latest/meta-data/",
|
||||
"https://10.0.0.5/admin",
|
||||
"https://[::ffff:169.254.169.254]/",
|
||||
"https://0177.0.0.1/",
|
||||
])
|
||||
def test_rejects_internal_https_hosts(self, url, monkeypatch):
|
||||
from praisonaiagents.mcp.oauth_discovery import _require_https
|
||||
monkeypatch.delenv("ALLOW_LOCAL_CRAWL", raising=False)
|
||||
with pytest.raises(ValueError):
|
||||
_require_https(url)
|
||||
|
||||
def test_registration_rejects_internal_endpoint(self, monkeypatch):
|
||||
from praisonaiagents.mcp.oauth_registration import register_client
|
||||
monkeypatch.delenv("ALLOW_LOCAL_CRAWL", raising=False)
|
||||
with pytest.raises(ValueError):
|
||||
register_client(
|
||||
"https://10.0.0.5/register",
|
||||
redirect_uris=["http://127.0.0.1:19876/mcp/oauth/callback"],
|
||||
)
|
||||
|
||||
|
||||
class TestRegistrationGuard:
|
||||
@@ -145,6 +173,29 @@ class TestProviderTokenReuse:
|
||||
assert provider.get_valid_token() == "tok123"
|
||||
assert provider.ensure_authenticated() == "tok123"
|
||||
|
||||
def test_refresh_redirect_keeps_saved_tokens(self, storage, monkeypatch, public_dns):
|
||||
import requests
|
||||
from praisonaiagents.mcp.oauth_provider import MCPOAuthProvider
|
||||
|
||||
url = "https://api.example.com/mcp"
|
||||
tokens = {
|
||||
"access_token": "old",
|
||||
"refresh_token": "r1",
|
||||
"token_endpoint": "https://auth.example.com/token",
|
||||
}
|
||||
storage.set_tokens("example", tokens, server_url=url)
|
||||
storage.set_client_info("example", {"client_id": "c1"}, server_url=url)
|
||||
|
||||
resp = requests.Response()
|
||||
resp.status_code = 307
|
||||
resp.url = tokens["token_endpoint"]
|
||||
resp._content = b'{"detail": "redirect"}'
|
||||
monkeypatch.setattr(requests, "post", lambda *a, **k: resp)
|
||||
|
||||
provider = MCPOAuthProvider("example", url, storage=storage)
|
||||
assert provider._try_refresh(storage.get_for_url("example", url)) is False
|
||||
assert storage.get_for_url("example", url)["tokens"]["access_token"] == "old"
|
||||
|
||||
def test_expired_token_without_refresh_returns_none(self, storage):
|
||||
import time
|
||||
from praisonaiagents.mcp.oauth_provider import MCPOAuthProvider
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Caller-supplied names and dates must not escape the memory store (#5942)."""
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.memory import FileMemory
|
||||
from praisonaiagents.memory.rules_manager import RulesManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory(tmp_path):
|
||||
return FileMemory(user_id="u", base_path=str(tmp_path / "store"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("date", ["../../../escaped", "2026-01-01/../x", "not-a-date"])
|
||||
def test_episodic_date_must_be_a_plain_date(memory, tmp_path, date):
|
||||
with pytest.raises(ValueError):
|
||||
memory.add_episodic("pwn", date=date)
|
||||
assert not list(tmp_path.glob("**/escaped.json"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["../../../../esc", "a/b", ".."])
|
||||
def test_session_and_checkpoint_names_cannot_traverse(memory, name):
|
||||
with pytest.raises(ValueError):
|
||||
memory.save_session(name)
|
||||
with pytest.raises(ValueError):
|
||||
memory.create_checkpoint(name)
|
||||
|
||||
|
||||
def test_plain_names_still_work(memory):
|
||||
memory.add_episodic("event", date="2026-01-01")
|
||||
assert [e.content for e in memory.get_episodic(date="2026-01-01")] == ["event"]
|
||||
assert memory.save_session("my session").endswith("my session.json")
|
||||
assert memory.create_checkpoint("ckpt-1") == "ckpt-1"
|
||||
|
||||
|
||||
def test_unpadded_episodic_date_is_normalised(memory):
|
||||
memory.add_episodic("event", date="2026-3-5")
|
||||
assert [e.content for e in memory.get_episodic(date="2026-03-05")] == ["event"]
|
||||
assert (memory.episodic_path / "2026-03-05.json").exists()
|
||||
|
||||
|
||||
def test_rule_name_cannot_traverse(tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
RulesManager(workspace_path=str(tmp_path)).create_rule("../../esc_rule", "x")
|
||||
assert not (tmp_path.parent / "esc_rule.md").exists()
|
||||
|
||||
|
||||
def test_concurrent_episodic_adds_are_not_lost(memory):
|
||||
threads = [
|
||||
threading.Thread(target=memory.add_episodic, args=(f"event {i}",), kwargs={"date": "2026-01-01"})
|
||||
for i in range(40)
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
assert len(memory.get_episodic(date="2026-01-01")) == 40
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Step-6 subdirectory walk stays inside the workspace tree (#5937)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.memory.rules_manager import RulesManager
|
||||
|
||||
|
||||
def _write_rule(directory, name, body):
|
||||
directory.mkdir(parents=True)
|
||||
(directory / f"{name}.md").write_text(
|
||||
f"---\nactivation: always\n---\n{body}\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_git_root(monkeypatch):
|
||||
monkeypatch.setattr(RulesManager, "_find_git_root", lambda self: None)
|
||||
|
||||
|
||||
def test_cwd_outside_workspace_does_not_load_ancestor_rules(tmp_path, monkeypatch):
|
||||
outside = tmp_path / "home"
|
||||
_write_rule(outside / ".claude" / "rules", "stray", "Stray instruction")
|
||||
cwd = outside / "repo"
|
||||
cwd.mkdir()
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
monkeypatch.chdir(cwd)
|
||||
|
||||
manager = RulesManager(workspace_path=str(workspace), global_rules_path=str(tmp_path / "global"))
|
||||
|
||||
assert manager.get_rule_by_name("stray") is None
|
||||
assert manager.get_active_rules() == []
|
||||
|
||||
|
||||
def test_cwd_inside_workspace_still_loads_subdir_rules(tmp_path, monkeypatch):
|
||||
workspace = tmp_path / "workspace"
|
||||
nested = workspace / "pkg" / "sub"
|
||||
_write_rule(workspace / "pkg" / ".claude" / "rules", "pkgrule", "Package instruction")
|
||||
nested.mkdir(parents=True)
|
||||
_write_rule(tmp_path / ".claude" / "rules", "above", "Above workspace")
|
||||
monkeypatch.chdir(nested)
|
||||
|
||||
manager = RulesManager(workspace_path=str(workspace), global_rules_path=str(tmp_path / "global"))
|
||||
|
||||
assert manager.get_rule_by_name("pkgrule") is not None
|
||||
assert manager.get_rule_by_name("above") is None
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Natural-language queries must recall facts from the SQLite adapter (#5942)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.memory.adapters.sqlite_adapter import SqliteMemoryAdapter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(tmp_path):
|
||||
a = SqliteMemoryAdapter(short_db=str(tmp_path / "s.db"), long_db=str(tmp_path / "l.db"))
|
||||
yield a
|
||||
a.close_connections()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", [
|
||||
"dark mode",
|
||||
"user prefers Python",
|
||||
"Which mode does the user prefer?",
|
||||
"What language does the user work in?",
|
||||
])
|
||||
def test_long_term_matches_on_words(adapter, query):
|
||||
adapter.store_long_term("The user prefers dark mode and works in Python.")
|
||||
adapter.store_long_term("The weather is sunny.")
|
||||
results = adapter.search_long_term(query)
|
||||
assert results and "dark mode" in results[0]["text"]
|
||||
|
||||
|
||||
def test_exact_phrase_ranks_first_and_unrelated_query_misses(adapter):
|
||||
adapter.store_short_term("python tips")
|
||||
adapter.store_short_term("I write python code daily")
|
||||
assert adapter.search_short_term("write python")[0]["text"] == "I write python code daily"
|
||||
assert adapter.search_short_term("zebra") == []
|
||||
@@ -0,0 +1,268 @@
|
||||
"""``async def`` plugin hooks are awaited, not silently dropped (#5964)."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
import warnings
|
||||
|
||||
from praisonaiagents.plugins.plugin import (
|
||||
GuardrailBlocked,
|
||||
Plugin,
|
||||
PluginHook,
|
||||
PluginInfo,
|
||||
)
|
||||
from praisonaiagents.plugins.manager import PluginManager
|
||||
from praisonaiagents.hooks.registry import HookRegistry
|
||||
from praisonaiagents.hooks.runner import HookRunner
|
||||
from praisonaiagents.hooks.types import HookEvent
|
||||
from praisonaiagents.hooks.events import BeforeToolInput, SessionStartInput
|
||||
|
||||
|
||||
class AsyncToolPlugin(Plugin):
|
||||
def __init__(self):
|
||||
self.sessions = []
|
||||
|
||||
@property
|
||||
def info(self):
|
||||
return PluginInfo(
|
||||
name="async_tool",
|
||||
hooks=[PluginHook.BEFORE_TOOL, PluginHook.SESSION_START],
|
||||
)
|
||||
|
||||
async def before_tool(self, tool_name, args):
|
||||
await asyncio.sleep(0)
|
||||
if tool_name == "rm":
|
||||
raise GuardrailBlocked("rm is not allowed")
|
||||
return {**args, "injected": True}
|
||||
|
||||
async def session_start(self, context):
|
||||
await asyncio.sleep(0)
|
||||
self.sessions.append(context["source"])
|
||||
|
||||
|
||||
def _tool_input(tool_name="bash"):
|
||||
return BeforeToolInput(
|
||||
session_id="s", cwd=os.getcwd(), event_name=HookEvent.BEFORE_TOOL,
|
||||
timestamp=str(time.time()), agent_name="a",
|
||||
tool_name=tool_name, tool_input={"cmd": "ls"},
|
||||
)
|
||||
|
||||
|
||||
def _runner(plugin):
|
||||
reg = HookRegistry()
|
||||
mgr = PluginManager()
|
||||
mgr.register(plugin)
|
||||
mgr.wire_into_hook_registry(reg)
|
||||
return HookRunner(registry=reg, cwd=os.getcwd())
|
||||
|
||||
|
||||
def test_async_transforming_hook_is_awaited_via_runner():
|
||||
runner = _runner(AsyncToolPlugin())
|
||||
data = _tool_input()
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", RuntimeWarning)
|
||||
runner.execute_sync(HookEvent.BEFORE_TOOL, data)
|
||||
assert data.tool_input == {"cmd": "ls", "injected": True}
|
||||
|
||||
|
||||
def test_async_hook_guardrail_block_propagates():
|
||||
runner = _runner(AsyncToolPlugin())
|
||||
results = runner.execute_sync(HookEvent.BEFORE_TOOL, _tool_input("rm"))
|
||||
assert any(r.output and r.output.is_denied() for r in results)
|
||||
|
||||
|
||||
def test_async_observer_hook_runs_inside_running_loop():
|
||||
plugin = AsyncToolPlugin()
|
||||
runner = _runner(plugin)
|
||||
data = SessionStartInput(
|
||||
session_id="s", cwd=os.getcwd(), event_name=HookEvent.SESSION_START,
|
||||
timestamp=str(time.time()), source="startup",
|
||||
)
|
||||
asyncio.run(runner.execute(HookEvent.SESSION_START, data))
|
||||
assert plugin.sessions == ["startup"]
|
||||
|
||||
|
||||
def test_execute_hook_awaits_async_hook_sync_and_async_paths():
|
||||
mgr = PluginManager()
|
||||
mgr.register(AsyncToolPlugin())
|
||||
assert mgr.execute_hook(PluginHook.BEFORE_TOOL, "bash", {"a": 1}) == {
|
||||
"a": 1, "injected": True,
|
||||
}
|
||||
out = asyncio.run(mgr.async_execute_hook(PluginHook.BEFORE_TOOL, "bash", {"a": 1}))
|
||||
assert out == {"a": 1, "injected": True}
|
||||
|
||||
|
||||
class AsyncLifecyclePlugin(Plugin):
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
@property
|
||||
def info(self):
|
||||
return PluginInfo(name="async_lifecycle")
|
||||
|
||||
async def on_init(self, context):
|
||||
await asyncio.sleep(0)
|
||||
self.calls.append("init")
|
||||
|
||||
async def on_config(self, config):
|
||||
await asyncio.sleep(0)
|
||||
self.calls.append(("config", config))
|
||||
return config
|
||||
|
||||
async def on_shutdown(self):
|
||||
await asyncio.sleep(0)
|
||||
self.calls.append("shutdown")
|
||||
|
||||
|
||||
def test_async_lifecycle_callbacks_are_awaited():
|
||||
plugin = AsyncLifecyclePlugin()
|
||||
mgr = PluginManager()
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", RuntimeWarning)
|
||||
assert mgr.register(plugin)
|
||||
mgr.enable("async_lifecycle")
|
||||
mgr.set_plugin_options({"async_lifecycle": {"k": 1}})
|
||||
assert mgr.apply_plugin_options() == 1
|
||||
assert mgr.unregister("async_lifecycle")
|
||||
assert plugin.calls == ["init", ("config", {"k": 1}), "shutdown"]
|
||||
|
||||
|
||||
def test_hung_async_hook_is_cancelled_at_deadline(monkeypatch):
|
||||
from praisonaiagents.plugins import manager as manager_mod
|
||||
|
||||
monkeypatch.setattr(manager_mod, "_ASYNC_HOOK_TIMEOUT", 0.05)
|
||||
cancelled = []
|
||||
|
||||
async def hang():
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
cancelled.append(True)
|
||||
raise
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
manager_mod._resolve_awaitable(hang())
|
||||
except (TimeoutError, asyncio.TimeoutError):
|
||||
pass
|
||||
assert time.time() - start < 2
|
||||
assert cancelled == [True]
|
||||
|
||||
|
||||
def test_async_on_init_can_call_back_into_manager_from_running_loop(monkeypatch):
|
||||
"""register() must not hold the manager lock while async on_init runs."""
|
||||
from praisonaiagents.plugins import manager as manager_mod
|
||||
|
||||
# A deadlock would otherwise only surface after the 60s deadline.
|
||||
monkeypatch.setattr(manager_mod, "_ASYNC_HOOK_TIMEOUT", 2.0)
|
||||
mgr = PluginManager()
|
||||
|
||||
class ReentrantInitPlugin(Plugin):
|
||||
seen = None
|
||||
|
||||
@property
|
||||
def info(self):
|
||||
return PluginInfo(name="reentrant_init")
|
||||
|
||||
async def on_init(self, context):
|
||||
await asyncio.sleep(0)
|
||||
# Bridged onto another thread when called from a running loop.
|
||||
# enable() takes the manager lock.
|
||||
self.seen = mgr.enable("reentrant_init")
|
||||
|
||||
plugin = ReentrantInitPlugin()
|
||||
|
||||
async def main():
|
||||
start = time.monotonic()
|
||||
assert mgr.register(plugin) is True
|
||||
return time.monotonic() - start
|
||||
|
||||
elapsed = asyncio.run(main())
|
||||
assert plugin.seen is True
|
||||
assert elapsed < 1.5
|
||||
|
||||
|
||||
def test_failed_async_on_init_rolls_back_registration():
|
||||
"""A plugin whose on_init fails is not left half-registered."""
|
||||
mgr = PluginManager()
|
||||
|
||||
class FailingInitPlugin(Plugin):
|
||||
@property
|
||||
def info(self):
|
||||
return PluginInfo(name="failing_init")
|
||||
|
||||
async def on_init(self, context):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
assert mgr.register(FailingInitPlugin()) is False
|
||||
assert mgr.get_plugin("failing_init") is None
|
||||
|
||||
class FixedInitPlugin(FailingInitPlugin):
|
||||
async def on_init(self, context):
|
||||
return None
|
||||
|
||||
# A retry is not rejected as "already registered".
|
||||
assert mgr.register(FixedInitPlugin()) is True
|
||||
|
||||
|
||||
def test_plugin_not_dispatched_until_async_on_init_finishes():
|
||||
"""Concurrent dispatch during a slow on_init must not reach the plugin."""
|
||||
import threading
|
||||
|
||||
mgr = PluginManager()
|
||||
init_started = threading.Event()
|
||||
release_init = threading.Event()
|
||||
|
||||
class SlowInitPlugin(Plugin):
|
||||
def __init__(self):
|
||||
self.ready = False
|
||||
self.calls_before_ready = 0
|
||||
|
||||
@property
|
||||
def info(self):
|
||||
return PluginInfo(name="slow_init", hooks=[PluginHook.BEFORE_TOOL])
|
||||
|
||||
async def on_init(self, context):
|
||||
init_started.set()
|
||||
await asyncio.get_running_loop().run_in_executor(None, release_init.wait, 5)
|
||||
self.ready = True
|
||||
|
||||
def before_tool(self, tool_name, args):
|
||||
if not self.ready:
|
||||
self.calls_before_ready += 1
|
||||
return {**args, "seen": True}
|
||||
|
||||
plugin = SlowInitPlugin()
|
||||
results = {}
|
||||
t = threading.Thread(target=lambda: results.setdefault("ok", mgr.register(plugin)))
|
||||
t.start()
|
||||
assert init_started.wait(5)
|
||||
|
||||
# During on_init: neither direct dispatch nor registry wiring sees it
|
||||
# (with no enabled plugins execute_hook passes its first arg through).
|
||||
assert mgr.execute_hook(PluginHook.BEFORE_TOOL, "bash", {"a": 1}) == "bash"
|
||||
assert mgr.wire_into_hook_registry(HookRegistry()) == 0
|
||||
assert mgr.is_enabled("slow_init") is False
|
||||
|
||||
release_init.set()
|
||||
t.join(5)
|
||||
assert results["ok"] is True
|
||||
assert mgr.is_enabled("slow_init") is True
|
||||
assert mgr.execute_hook(PluginHook.BEFORE_TOOL, "bash", {"a": 1}) == {"a": 1, "seen": True}
|
||||
assert plugin.calls_before_ready == 0
|
||||
|
||||
|
||||
def test_disable_during_on_init_is_kept():
|
||||
"""A plugin that disables itself in on_init stays disabled after register."""
|
||||
mgr = PluginManager()
|
||||
|
||||
class SelfDisablingPlugin(Plugin):
|
||||
@property
|
||||
def info(self):
|
||||
return PluginInfo(name="self_disabling", hooks=[PluginHook.BEFORE_TOOL])
|
||||
|
||||
async def on_init(self, context):
|
||||
mgr.disable("self_disabling")
|
||||
|
||||
assert mgr.register(SelfDisablingPlugin()) is True
|
||||
assert mgr.is_enabled("self_disabling") is False
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Load-time praisonaiagents compatibility gate for plugins (#5951)."""
|
||||
|
||||
from praisonaiagents._version import __version__
|
||||
from praisonaiagents.plugins.discovery import load_plugin
|
||||
from praisonaiagents.plugins.manager import PluginManager
|
||||
from praisonaiagents.plugins.plugin import Plugin, PluginInfo, core_incompatibility
|
||||
|
||||
|
||||
class _P(Plugin):
|
||||
def __init__(self, deps):
|
||||
self._deps = deps
|
||||
|
||||
@property
|
||||
def info(self):
|
||||
return PluginInfo(name="compat_probe", dependencies=self._deps)
|
||||
|
||||
|
||||
def test_no_core_entry_is_compatible():
|
||||
assert core_incompatibility([]) is None
|
||||
assert core_incompatibility(["requests>=2", "praisonaiagents"]) is None
|
||||
assert core_incompatibility(["not a valid req!!"]) is None
|
||||
|
||||
|
||||
def test_satisfied_and_unsatisfied_ranges():
|
||||
assert core_incompatibility([f"praisonaiagents>={__version__}"]) is None
|
||||
reason = core_incompatibility(["praisonaiagents>=999"])
|
||||
assert "praisonaiagents>=999" in reason and __version__ in reason
|
||||
|
||||
|
||||
def test_comma_split_header_fragments_are_rejoined():
|
||||
assert core_incompatibility(["praisonaiagents>=0.0.1", "<999"]) is None
|
||||
assert core_incompatibility(["praisonaiagents>=0.0.1", "<0.0.2"]) is not None
|
||||
|
||||
|
||||
def test_environment_markers_are_honoured():
|
||||
import sys
|
||||
other = "win32" if sys.platform != "win32" else "linux"
|
||||
assert core_incompatibility([f"praisonaiagents>=999; sys_platform == '{other}'"]) is None
|
||||
assert core_incompatibility([f"praisonaiagents>=999; sys_platform == '{sys.platform}'"]) is not None
|
||||
|
||||
|
||||
def test_unknown_source_version_falls_back_to_installed_metadata(monkeypatch):
|
||||
import importlib.metadata
|
||||
from praisonaiagents import _version
|
||||
monkeypatch.setattr(_version, "__version__", "unknown")
|
||||
monkeypatch.setattr(importlib.metadata, "version", lambda name: "1.0.0")
|
||||
assert core_incompatibility(["praisonaiagents>=999"]) is not None
|
||||
assert core_incompatibility(["praisonaiagents>=1,<2"]) is None
|
||||
|
||||
|
||||
def test_register_skips_incompatible_plugin():
|
||||
pm = PluginManager()
|
||||
assert pm.register(_P(["praisonaiagents>=999"])) is False
|
||||
assert pm.get_plugin("compat_probe") is None
|
||||
assert pm.register(_P([])) is True
|
||||
|
||||
|
||||
def test_single_file_plugin_not_executed_when_incompatible(tmp_path):
|
||||
marker = tmp_path / "ran"
|
||||
plugin = tmp_path / "p.py"
|
||||
plugin.write_text(
|
||||
'"""\nPlugin Name: Compat\nDependencies: praisonaiagents>=999\n"""\n'
|
||||
f"open({str(marker)!r}, 'w').close()\n"
|
||||
)
|
||||
assert load_plugin(str(plugin)) is None
|
||||
assert not marker.exists()
|
||||
@@ -100,5 +100,5 @@ class TestEstimateTokens:
|
||||
from praisonaiagents.rag.compressor import estimate_tokens
|
||||
|
||||
assert estimate_tokens("") == 0
|
||||
assert estimate_tokens("hello") == 2 # 5/4 + 1 = 2
|
||||
assert estimate_tokens("a" * 100) == 26 # 100/4 + 1 = 26
|
||||
assert estimate_tokens("hello") == 1 # canonical: 5 * 0.25, min 1
|
||||
assert estimate_tokens("a" * 100) == 25
|
||||
@@ -136,4 +136,4 @@ class TestEstimateTokensSummarizer:
|
||||
from praisonaiagents.rag.summarizer import estimate_tokens
|
||||
|
||||
assert estimate_tokens("") == 0
|
||||
assert estimate_tokens("test") == 2
|
||||
assert estimate_tokens("test") == 1
|
||||
@@ -355,3 +355,99 @@ class TestDeliverAggregatesOutcome:
|
||||
bot = QueuingBot()
|
||||
result = asyncio.run(bot.deliver("c1", "hello"))
|
||||
assert result.status == "sent"
|
||||
|
||||
|
||||
class TestRuntimeContractDefaults:
|
||||
"""Issue #5984: a minimal subclass satisfies what Bot/BotOS drive."""
|
||||
|
||||
def test_runtime_methods_present(self):
|
||||
bot = RecordingBot()
|
||||
for name in ("start", "stop", "send_message", "probe", "health"):
|
||||
assert callable(getattr(bot, name))
|
||||
|
||||
def test_start_runs_until_stop(self):
|
||||
async def scenario():
|
||||
bot = RecordingBot()
|
||||
task = asyncio.ensure_future(bot.start())
|
||||
await asyncio.sleep(0.01)
|
||||
assert bot.connected and not task.done()
|
||||
health = await bot.health()
|
||||
assert health.ok and health.is_running
|
||||
assert (await bot.probe()).ok
|
||||
await bot.stop()
|
||||
await asyncio.wait_for(task, 1)
|
||||
assert not bot.connected
|
||||
assert not (await bot.health()).is_running
|
||||
|
||||
run(scenario())
|
||||
|
||||
def test_start_raises_when_connect_fails(self):
|
||||
class NoConnect(RecordingBot):
|
||||
async def connect(self, *, is_reconnect=False):
|
||||
return False
|
||||
|
||||
with pytest.raises(ConnectionError):
|
||||
run(NoConnect().start())
|
||||
|
||||
def test_stop_during_connect_is_honoured(self):
|
||||
async def scenario():
|
||||
gate = asyncio.Event()
|
||||
|
||||
class SlowConnect(RecordingBot):
|
||||
async def connect(self, *, is_reconnect=False):
|
||||
await gate.wait()
|
||||
return await super().connect(is_reconnect=is_reconnect)
|
||||
|
||||
bot = SlowConnect()
|
||||
task = asyncio.ensure_future(bot.start())
|
||||
await asyncio.sleep(0.01)
|
||||
await bot.stop()
|
||||
gate.set()
|
||||
await asyncio.wait_for(task, 1)
|
||||
assert not bot.connected
|
||||
|
||||
run(scenario())
|
||||
|
||||
def test_cancelled_start_disconnects(self):
|
||||
async def scenario():
|
||||
bot = RecordingBot()
|
||||
task = asyncio.ensure_future(bot.start())
|
||||
await asyncio.sleep(0.01)
|
||||
assert bot.connected
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert not bot.connected
|
||||
assert not (await bot.health()).is_running
|
||||
|
||||
run(scenario())
|
||||
|
||||
def test_send_message_routes_through_deliver(self):
|
||||
bot = RecordingBot()
|
||||
res = run(bot.send_message("c1", "hello", reply_to="r1", thread_id="t1"))
|
||||
assert res.ok and res.status == "sent"
|
||||
assert bot.sends == [("c1", "hello", "r1")]
|
||||
|
||||
def test_probe_round_trips_when_not_running(self):
|
||||
bot = RecordingBot()
|
||||
res = run(bot.probe())
|
||||
assert res.ok and not bot.connected
|
||||
|
||||
class BadCreds(RecordingBot):
|
||||
async def connect(self, *, is_reconnect=False):
|
||||
raise RuntimeError("401 unauthorized")
|
||||
|
||||
res = run(BadCreds().probe())
|
||||
assert not res.ok and "401" in res.error
|
||||
|
||||
def test_probe_timeout_still_disconnects(self):
|
||||
class Hangs(RecordingBot):
|
||||
async def connect(self, *, is_reconnect=False):
|
||||
self.connected = True # partially opened resource
|
||||
await asyncio.sleep(10)
|
||||
return True
|
||||
|
||||
bot = Hangs()
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
run(asyncio.wait_for(bot.probe(), 0.05))
|
||||
assert not bot.connected
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Device-identity handshake contract (Issue #5998)."""
|
||||
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.gateway import (
|
||||
ConnectErrorCode,
|
||||
DeviceAuth,
|
||||
DeviceIdentityProtocol,
|
||||
FrameDecodeError,
|
||||
HelloParams,
|
||||
decode_client_frame,
|
||||
device_id_from_public_key,
|
||||
)
|
||||
|
||||
PUB = "dGVzdC1wdWJsaWMta2V5"
|
||||
|
||||
|
||||
def _hello(**device):
|
||||
block = {
|
||||
"device_id": device_id_from_public_key(PUB),
|
||||
"public_key": PUB,
|
||||
"signature": "sig",
|
||||
"signed_at": 1700000000,
|
||||
"nonce": "n-1",
|
||||
}
|
||||
block.update(device)
|
||||
return {"type": "hello", "agent_id": "a", "device": block}
|
||||
|
||||
|
||||
def test_hello_without_device_is_unchanged():
|
||||
frame = decode_client_frame({"type": "hello", "agent_id": "a"})
|
||||
assert isinstance(frame, HelloParams)
|
||||
assert frame.device is None
|
||||
|
||||
|
||||
def test_hello_decodes_device():
|
||||
frame = decode_client_frame(_hello())
|
||||
assert isinstance(frame.device, DeviceAuth)
|
||||
assert frame.device.device_id == device_id_from_public_key(PUB)
|
||||
assert frame.device.nonce == "n-1"
|
||||
assert frame.device.signed_at == 1700000000
|
||||
|
||||
|
||||
def test_spoofed_device_id_is_unauthorized():
|
||||
with pytest.raises(FrameDecodeError) as exc:
|
||||
decode_client_frame(_hello(device_id="0" * 64))
|
||||
assert exc.value.error.code == ConnectErrorCode.AUTH_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
{"signature": ""},
|
||||
{"nonce": None},
|
||||
{"signed_at": True},
|
||||
{"signed_at": "now"},
|
||||
{"signed_at": float("nan")},
|
||||
{"signed_at": 1700000000.5},
|
||||
{"public_key": "not base64!"},
|
||||
{"public_key": "dGVzdC1wdWJsaWMta2V5\n"},
|
||||
])
|
||||
def test_malformed_device_rejected(bad):
|
||||
with pytest.raises(FrameDecodeError) as exc:
|
||||
decode_client_frame(_hello(**bad))
|
||||
assert exc.value.error.code == ConnectErrorCode.CONFIGURATION_ERROR
|
||||
|
||||
|
||||
def test_device_must_be_object():
|
||||
with pytest.raises(FrameDecodeError):
|
||||
decode_client_frame({"type": "hello", "agent_id": "a", "device": "x"})
|
||||
|
||||
|
||||
def test_wire_format_is_pinned():
|
||||
# Literal vectors: changing the fingerprint or signed bytes breaks clients.
|
||||
assert device_id_from_public_key(PUB) == (
|
||||
"c88e80202a4d650841e48649b4c3f553e48131b415db680476fd0d65632ff2b0"
|
||||
)
|
||||
d = DeviceAuth(device_id_from_public_key(PUB), PUB, "sig", 1700000000, "n-1")
|
||||
assert d.signing_payload("a") == (
|
||||
b"praisonai-device-v1\n"
|
||||
b"c88e80202a4d650841e48649b4c3f553e48131b415db680476fd0d65632ff2b0\n"
|
||||
b"n-1\n1700000000\na"
|
||||
)
|
||||
|
||||
|
||||
def test_signing_payload_binds_agent_and_nonce():
|
||||
d = decode_client_frame(_hello()).device
|
||||
assert d.signing_payload("a") != d.signing_payload("b")
|
||||
other = DeviceAuth(d.device_id, d.public_key, d.signature, d.signed_at, "n-2")
|
||||
assert d.signing_payload("a") != other.signing_payload("a")
|
||||
|
||||
|
||||
def test_ed25519_round_trip():
|
||||
ed = pytest.importorskip("cryptography.hazmat.primitives.asymmetric.ed25519")
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
key = ed.Ed25519PrivateKey.generate()
|
||||
raw = key.public_key().public_bytes(
|
||||
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
||||
)
|
||||
pub = base64.b64encode(raw).decode()
|
||||
unsigned = DeviceAuth(device_id_from_public_key(pub), pub, "", 1700000000, "nonce")
|
||||
sig = base64.b64encode(key.sign(unsigned.signing_payload("a"))).decode()
|
||||
|
||||
frame = decode_client_frame({
|
||||
"type": "hello", "agent_id": "a",
|
||||
"device": {**unsigned.__dict__, "signature": sig},
|
||||
})
|
||||
ed.Ed25519PublicKey.from_public_bytes(base64.b64decode(frame.device.public_key)).verify(
|
||||
base64.b64decode(frame.device.signature),
|
||||
frame.device.signing_payload(frame.agent_id),
|
||||
)
|
||||
|
||||
|
||||
def test_protocol_is_runtime_checkable():
|
||||
class Store:
|
||||
def issue_challenge(self): return "n"
|
||||
def verify(self, device, *, agent_id): return None
|
||||
def list_devices(self): return []
|
||||
def revoke(self, device_id): return False
|
||||
|
||||
assert isinstance(Store(), DeviceIdentityProtocol)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""The TypeScript client's ``GatewayEventType`` must mirror Python's ``EventType``.
|
||||
|
||||
The gateway wire event vocabulary is defined once in
|
||||
``praisonaiagents/gateway/protocols.py`` and re-declared in
|
||||
``praisonai-ts/src/gateway/index.ts``. Nothing else ties the two together, so an
|
||||
event type added (or renamed) on the Python side would otherwise reach the TS
|
||||
client only as silently ignored or misrouted frames at runtime.
|
||||
|
||||
This is a lockstep test: it asserts that the two independently-maintained lists
|
||||
agree member-for-member (name and wire value), so drift fails CI instead.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.gateway.protocols import EventType
|
||||
|
||||
_TS_GATEWAY = (
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "praisonai-ts"
|
||||
/ "src"
|
||||
/ "gateway"
|
||||
/ "index.ts"
|
||||
)
|
||||
|
||||
|
||||
_TS_MEMBER = re.compile(r"""([A-Za-z_$][\w$]*)\s*=\s*(['"])(.*?)\2""", re.S)
|
||||
|
||||
|
||||
def _parse_ts_enum(source: str) -> dict[str, str]:
|
||||
"""Parse ``export enum GatewayEventType { NAME = 'value', ... }`` from TS source.
|
||||
|
||||
Every enum member must be a ``NAME = 'value'`` (or double-quoted) pair; any
|
||||
other entry fails loudly rather than being silently skipped.
|
||||
"""
|
||||
body = re.search(r"export enum GatewayEventType\s*\{(.*?)\n\}", source, re.S)
|
||||
assert body, "GatewayEventType enum not found"
|
||||
text = re.sub(r"/\*.*?\*/|//[^\n]*", "", body.group(1), flags=re.S)
|
||||
members = {}
|
||||
for entry in filter(None, (e.strip() for e in text.split(","))):
|
||||
match = _TS_MEMBER.fullmatch(entry)
|
||||
assert match, f"Unparseable GatewayEventType entry: {entry!r}"
|
||||
members[match.group(1)] = match.group(3)
|
||||
return members
|
||||
|
||||
|
||||
def _ts_event_types() -> dict[str, str]:
|
||||
return _parse_ts_enum(_TS_GATEWAY.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_parse_ts_enum_rejects_or_captures_every_entry():
|
||||
src = """export enum GatewayEventType {
|
||||
// comment
|
||||
A = 'a',
|
||||
MESSAGE_V2 = 'message_v2',
|
||||
B = "b",
|
||||
}"""
|
||||
assert _parse_ts_enum(src) == {"A": "a", "MESSAGE_V2": "message_v2", "B": "b"}
|
||||
with pytest.raises(AssertionError, match="Unparseable"):
|
||||
_parse_ts_enum("export enum GatewayEventType {\n A = 'a',\n C,\n}")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _TS_GATEWAY.exists(), reason="TypeScript SDK not present")
|
||||
def test_ts_gateway_event_type_matches_python():
|
||||
python = {member.name: member.value for member in EventType}
|
||||
ts = _ts_event_types()
|
||||
|
||||
missing_in_ts = sorted(set(python) - set(ts))
|
||||
extra_in_ts = sorted(set(ts) - set(python))
|
||||
mismatched = sorted(n for n in set(python) & set(ts) if python[n] != ts[n])
|
||||
|
||||
assert not (missing_in_ts or extra_in_ts or mismatched), (
|
||||
"praisonai-ts GatewayEventType drifted from praisonaiagents EventType: "
|
||||
f"missing in TS={missing_in_ts}, extra in TS={extra_in_ts}, "
|
||||
f"value mismatch={mismatched}. Update src/praisonai-ts/src/gateway/index.ts."
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""ImageAgent must not send the base Agent's chat default model to image APIs (#5944)."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import ImageAgent
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_kwargs,expected", [
|
||||
({}, {"generate": "dall-e-3", "edit": "dall-e-2", "variation": "dall-e-2"}),
|
||||
({"llm": "openai/gpt-image-1"}, dict.fromkeys(("generate", "edit", "variation"), "openai/gpt-image-1")),
|
||||
({"model": "openai/gpt-image-1"}, dict.fromkeys(("generate", "edit", "variation"), "openai/gpt-image-1")),
|
||||
])
|
||||
def test_image_model_resolution(monkeypatch, tmp_path, model_kwargs, expected):
|
||||
calls = {}
|
||||
|
||||
def record(name):
|
||||
def call(**kwargs):
|
||||
calls[name] = kwargs
|
||||
return {"data": []}
|
||||
return call
|
||||
|
||||
agent = ImageAgent(instructions="Create images", verbose=False, **model_kwargs)
|
||||
monkeypatch.setattr(agent, "_litellm", record("generate"))
|
||||
monkeypatch.setitem(sys.modules, "litellm", types.SimpleNamespace(
|
||||
image_edit=record("edit"),
|
||||
image_variation=record("variation"),
|
||||
get_llm_provider=lambda model: (model, "openai", None, None),
|
||||
))
|
||||
image = tmp_path / "source.png"
|
||||
image.write_bytes(b"png")
|
||||
|
||||
agent.generate("A red circle")
|
||||
agent.edit(str(image), "Add clouds")
|
||||
agent.variation(str(image))
|
||||
|
||||
assert {name: kwargs["model"] for name, kwargs in calls.items()} == expected
|
||||
@@ -0,0 +1,27 @@
|
||||
"""OpenAI image calls must not forward the default response_format="url"."""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import ImageAgent
|
||||
|
||||
|
||||
@pytest.mark.parametrize("llm,kwargs,expected", [
|
||||
("openai/dall-e-2", {}, None),
|
||||
("openai/gpt-image-1", {}, None),
|
||||
("openai/dall-e-3", {"response_format": "b64_json"}, "b64_json"),
|
||||
("gemini/imagen-3.0-generate-002", {}, None),
|
||||
])
|
||||
def test_response_format_forwarding(llm, kwargs, expected):
|
||||
agent = ImageAgent(llm=llm, verbose=False)
|
||||
received = {}
|
||||
|
||||
def fake_image_generation(**call_kwargs):
|
||||
received.update(call_kwargs)
|
||||
return {"data": [{"url": "https://example.com/image.png"}]}
|
||||
|
||||
agent._litellm = fake_image_generation
|
||||
try:
|
||||
agent.generate("A minimal red circle icon", **kwargs)
|
||||
assert received.get("response_format") == expected
|
||||
finally:
|
||||
agent.close()
|
||||
@@ -302,3 +302,11 @@ def test_expand_access_groups_cycle_rejected():
|
||||
|
||||
with pytest.raises(ValueError, match="cycle"):
|
||||
expand_access_groups(["group:a"], {"a": ["group:b"], "b": ["group:a"]})
|
||||
|
||||
|
||||
def test_unknown_group_policy_fails_closed_to_mention_only():
|
||||
"""Issue #5962: an unrecognised policy is mention-gated, not respond_all."""
|
||||
from praisonaiagents.bots.admission import resolve_ingress_admission
|
||||
|
||||
d = resolve_ingress_admission(chat_type="group", sender_id="u", group_policy="bogus")
|
||||
assert d.admit is False and d.reason_code == "group_mention_only"
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Hot paths use the shared error classifier, token estimator and atomic writer (#5838)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _E(Exception):
|
||||
def __init__(self, msg, status_code=None, headers=None):
|
||||
super().__init__(msg)
|
||||
self.status_code = status_code
|
||||
|
||||
class _R:
|
||||
pass
|
||||
|
||||
self.response = _R()
|
||||
self.response.headers = headers or {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def llm():
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
inst = LLM.__new__(LLM)
|
||||
inst._rate_limiter = None
|
||||
inst._retry_delay = 1.0
|
||||
inst._max_retries = 3
|
||||
return inst
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error, kind", [
|
||||
(_E("max_tokens must be <= 4096, got 8500 (request req_5003abc)", 400), "format_error"),
|
||||
(_E("Error code: 401 - Incorrect credentials", 401), "auth"),
|
||||
(_E("Error code: 403 - region not supported", 403), "auth"),
|
||||
(_E("Service Error", 503), "overloaded"),
|
||||
(_E("Error code: 404 - The model `gpt-9` does not exist", 404), "model_not_found"),
|
||||
(_E("Error code: 400 - invalid image: credit card detected", 400), "format_error"),
|
||||
(_E("You exceeded your quota: insufficient quota", 429), "billing"),
|
||||
(_E("Your credit balance is too low", 400), "billing"),
|
||||
(_E("Rate limit reached for API key sk-...", 429), "rate_limit"),
|
||||
(Exception("503 service unavailable"), "overloaded"),
|
||||
])
|
||||
def test_status_code_drives_error_kind(llm, error, kind):
|
||||
assert llm.classify_error_kind(error) == kind
|
||||
|
||||
|
||||
def test_retry_after_header_is_honoured(llm):
|
||||
err = _E("Error code: 429", 429, {"retry-after": "37"})
|
||||
assert llm._parse_retry_delay(err) == 37.0
|
||||
decision = llm.resolve_failover_decision(err, {"attempt": 1, "max_retries": 3})
|
||||
assert decision.action == "retry"
|
||||
assert decision.backoff_ms == 37000
|
||||
|
||||
|
||||
def test_budget_estimators_count_cjk_densely():
|
||||
from praisonaiagents.context.tokens import estimate_tokens_heuristic
|
||||
from praisonaiagents.rag.budget import estimate_tokens
|
||||
|
||||
text = "今天天气很好,我们一起去公园散步。" * 100
|
||||
assert estimate_tokens(text) == estimate_tokens_heuristic(text)
|
||||
assert estimate_tokens(text) > len(text)
|
||||
|
||||
|
||||
def test_file_tracker_failed_save_keeps_previous_state(tmp_path):
|
||||
from praisonaiagents.knowledge.indexing import FileTracker
|
||||
|
||||
state = str(tmp_path / "tracker.json")
|
||||
tracker = FileTracker(state)
|
||||
tracker._tracked = {"a.md": {"hash": "1", "mtime": 1, "memory_ids": ["m1"]}}
|
||||
tracker.save()
|
||||
tracker._tracked["b.md"] = {"hash": "2", "mtime": 2, "memory_ids": {"m3"}}
|
||||
with pytest.raises(TypeError):
|
||||
tracker.save()
|
||||
|
||||
reloaded = FileTracker(state)
|
||||
reloaded.load()
|
||||
assert list(reloaded._tracked) == ["a.md"]
|
||||
|
||||
|
||||
def test_permission_allowlist_failed_save_and_corrupt_load(tmp_path):
|
||||
from praisonaiagents.approval import PermissionAllowlist
|
||||
|
||||
path = str(tmp_path / "perm.json")
|
||||
allowlist = PermissionAllowlist()
|
||||
allowlist.add_tool("shell", paths=["/tmp"])
|
||||
allowlist.save(path)
|
||||
allowlist._tools["shell"].allowed_paths = [object()]
|
||||
with pytest.raises(TypeError):
|
||||
allowlist.save(path)
|
||||
assert PermissionAllowlist.load(path).list_tools() == ["shell"]
|
||||
|
||||
with open(path, "w") as f:
|
||||
f.write("{truncated")
|
||||
assert PermissionAllowlist.load(path).is_empty()
|
||||
assert not os.path.exists(path)
|
||||
|
||||
|
||||
def test_file_index_save_is_atomic(tmp_path):
|
||||
from praisonaiagents.context.fast.index_manager import FileIndex
|
||||
|
||||
index = FileIndex(workspace_path=str(tmp_path))
|
||||
assert index.save()
|
||||
with open(index.index_path) as f:
|
||||
assert json.load(f)["workspace"] == str(tmp_path)
|
||||
|
||||
|
||||
def test_cjk_truncation_stays_within_token_budget():
|
||||
from praisonaiagents.rag.budget import DefaultBudgetEnforcer, TokenBudget, estimate_tokens
|
||||
from praisonaiagents.rag.compressor import ContextCompressor
|
||||
from praisonaiagents.rag.context import truncate_context
|
||||
|
||||
cjk = "汉" * 1000
|
||||
out = truncate_context(cjk, 200)
|
||||
assert out.endswith("[Context truncated...]")
|
||||
assert estimate_tokens(out) <= 200
|
||||
|
||||
budget = TokenBudget(model_max_tokens=1200, reserved_response_tokens=0)
|
||||
available = budget.dynamic_budget()
|
||||
kept = DefaultBudgetEnforcer().enforce([{"text": cjk}, {"text": cjk}], budget)
|
||||
assert sum(estimate_tokens(c["text"]) for c in kept) <= available
|
||||
assert kept[-1]["metadata"]["_truncated"]
|
||||
|
||||
kept = ContextCompressor()._truncate_to_budget([{"text": cjk}], 200)
|
||||
assert estimate_tokens(kept[0]["text"]) <= 200
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Regression tests for issue #5900 (BackgroundJobManager reliability)."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import CancelledError
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.background.job_manager import BackgroundJobManager, JobInfo, JobStatus
|
||||
from praisonaiagents.background.sqlite_store import SqliteBackgroundJobStore
|
||||
|
||||
|
||||
def _wait_status(m, job_id, status, timeout=5.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if m.get_status(job_id) == status:
|
||||
return
|
||||
time.sleep(0.01)
|
||||
raise AssertionError(f"{job_id} never reached {status}")
|
||||
|
||||
|
||||
def test_reusing_active_job_id_raises_and_terminal_id_reusable():
|
||||
m = BackgroundJobManager(max_workers=1)
|
||||
gate = threading.Event()
|
||||
try:
|
||||
m.start_job(gate.wait, job_id="x")
|
||||
_wait_status(m, "x", JobStatus.RUNNING)
|
||||
with pytest.raises(ValueError):
|
||||
m.start_job(lambda: "B", job_id="x")
|
||||
gate.set()
|
||||
m.get_result("x", timeout=5)
|
||||
m.start_job(lambda: "B", job_id="x")
|
||||
assert m.get_result("x", timeout=5) == "B"
|
||||
finally:
|
||||
gate.set()
|
||||
m.shutdown()
|
||||
|
||||
|
||||
def test_auto_job_id_is_longer():
|
||||
m = BackgroundJobManager(max_workers=1)
|
||||
try:
|
||||
assert len(m.start_job(lambda: 1)) >= 12
|
||||
finally:
|
||||
m.shutdown()
|
||||
|
||||
|
||||
def test_running_job_can_be_cancelled_and_result_discarded():
|
||||
m = BackgroundJobManager(max_workers=1)
|
||||
gate = threading.Event()
|
||||
delivered = []
|
||||
try:
|
||||
m.start_job(lambda: gate.wait() and "late", job_id="r", on_complete=delivered.append)
|
||||
_wait_status(m, "r", JobStatus.RUNNING)
|
||||
assert m.cancel_job("r") is True
|
||||
assert m.get_status("r") == JobStatus.CANCELLED
|
||||
with pytest.raises(CancelledError):
|
||||
m.get_result("r", timeout=0.1)
|
||||
finally:
|
||||
gate.set()
|
||||
m.shutdown(wait=True)
|
||||
assert m.get_status("r") == JobStatus.CANCELLED
|
||||
assert m.get_job_info("r").result is None
|
||||
assert delivered == []
|
||||
|
||||
|
||||
def test_cancel_wakes_untimed_result_waiter():
|
||||
m = BackgroundJobManager(max_workers=1)
|
||||
gate = threading.Event()
|
||||
outcome = []
|
||||
|
||||
def waiter():
|
||||
try:
|
||||
m.get_result("w")
|
||||
except CancelledError:
|
||||
outcome.append("cancelled")
|
||||
|
||||
try:
|
||||
m.start_job(gate.wait, job_id="w")
|
||||
_wait_status(m, "w", JobStatus.RUNNING)
|
||||
t = threading.Thread(target=waiter, daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.05)
|
||||
assert m.cancel_job("w") is True
|
||||
t.join(timeout=2)
|
||||
assert outcome == ["cancelled"]
|
||||
finally:
|
||||
gate.set()
|
||||
m.shutdown()
|
||||
|
||||
|
||||
def test_cancel_pending_job_never_runs():
|
||||
m = BackgroundJobManager(max_workers=1)
|
||||
gate = threading.Event()
|
||||
ran = []
|
||||
try:
|
||||
m.start_job(gate.wait, job_id="blocker")
|
||||
m.start_job(lambda: ran.append(1), job_id="p")
|
||||
assert m.cancel_job("p") is True
|
||||
with pytest.raises(CancelledError):
|
||||
m.get_result("p")
|
||||
finally:
|
||||
gate.set()
|
||||
m.shutdown(wait=True)
|
||||
assert ran == []
|
||||
assert m.cancel_job("p") is False
|
||||
|
||||
|
||||
def test_stale_callback_does_not_overwrite_reused_id_in_store():
|
||||
store = SqliteBackgroundJobStore(":memory:")
|
||||
m = BackgroundJobManager(max_workers=2, store=store)
|
||||
in_callback = threading.Event()
|
||||
release = threading.Event()
|
||||
gate = threading.Event()
|
||||
|
||||
def slow_callback(info):
|
||||
in_callback.set()
|
||||
release.wait(5)
|
||||
|
||||
try:
|
||||
m.start_job(lambda: "old", job_id="id", on_complete=slow_callback, origin={"chat_id": "1"})
|
||||
assert in_callback.wait(5)
|
||||
m.start_job(gate.wait, job_id="id")
|
||||
_wait_status(m, "id", JobStatus.RUNNING)
|
||||
release.set()
|
||||
time.sleep(0.1)
|
||||
persisted = store.get("id")
|
||||
assert persisted.status == JobStatus.RUNNING
|
||||
assert persisted.result is None
|
||||
finally:
|
||||
release.set()
|
||||
gate.set()
|
||||
m.shutdown()
|
||||
|
||||
|
||||
def test_get_result_timeout_raises_builtin_timeout_error():
|
||||
m = BackgroundJobManager(max_workers=1)
|
||||
gate = threading.Event()
|
||||
try:
|
||||
m.start_job(gate.wait, job_id="t")
|
||||
with pytest.raises(TimeoutError, match="did not complete"):
|
||||
m.get_result("t", timeout=0.05)
|
||||
finally:
|
||||
gate.set()
|
||||
m.shutdown()
|
||||
|
||||
|
||||
def test_cleanup_prunes_durable_store():
|
||||
store = SqliteBackgroundJobStore(":memory:")
|
||||
old = time.time() - 7200
|
||||
store.upsert(JobInfo(job_id="old", status=JobStatus.COMPLETED, completed_at=old))
|
||||
store.upsert(JobInfo(
|
||||
job_id="undelivered", status=JobStatus.COMPLETED, completed_at=old,
|
||||
origin={"chat_id": "1"},
|
||||
))
|
||||
store.upsert(JobInfo(job_id="fresh", status=JobStatus.FAILED, completed_at=time.time()))
|
||||
store.upsert(JobInfo(job_id="running", status=JobStatus.RUNNING))
|
||||
m = BackgroundJobManager(max_workers=1, store=store)
|
||||
try:
|
||||
m.cleanup_completed(max_age=3600)
|
||||
assert store.get("old") is None
|
||||
assert store.get("undelivered") is not None
|
||||
assert store.get("fresh") is not None
|
||||
assert store.get("running") is not None
|
||||
finally:
|
||||
m.shutdown()
|
||||
|
||||
|
||||
def test_submit_after_shutdown_fails_job_and_frees_id():
|
||||
m = BackgroundJobManager(max_workers=1)
|
||||
m.shutdown()
|
||||
seen = []
|
||||
with pytest.raises(RuntimeError):
|
||||
m.start_job(lambda: "A", job_id="x", on_complete=seen.append)
|
||||
assert m.get_status("x") == JobStatus.FAILED
|
||||
assert [i.status for i in seen] == [JobStatus.FAILED] # failure not silent
|
||||
with pytest.raises(RuntimeError):
|
||||
m.get_result("x") # resolved, not blocking forever
|
||||
|
||||
|
||||
def test_callback_base_exception_still_resolves_result():
|
||||
class Stop(BaseException):
|
||||
pass
|
||||
|
||||
def boom(_info):
|
||||
raise Stop()
|
||||
|
||||
m = BackgroundJobManager(max_workers=1)
|
||||
try:
|
||||
m.start_job(lambda: "A", job_id="x", on_complete=boom)
|
||||
assert m.get_result("x", timeout=5) == "A"
|
||||
finally:
|
||||
m.shutdown()
|
||||
|
||||
|
||||
def test_func_base_exception_fails_job_and_resolves_result():
|
||||
m = BackgroundJobManager(max_workers=1)
|
||||
delivered = threading.Event()
|
||||
seen = []
|
||||
|
||||
def on_complete(info):
|
||||
seen.append(info.status)
|
||||
delivered.set()
|
||||
|
||||
try:
|
||||
m.start_job(
|
||||
lambda: (_ for _ in ()).throw(SystemExit()), job_id="x",
|
||||
on_complete=on_complete,
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
m.get_result("x", timeout=5)
|
||||
assert m.get_status("x") == JobStatus.FAILED
|
||||
assert m.list_jobs()["x"].error == "SystemExit" # never an empty error
|
||||
assert delivered.wait(5) and seen == [JobStatus.FAILED] # failure not silent
|
||||
finally:
|
||||
m.shutdown()
|
||||
|
||||
|
||||
def test_queued_job_dropped_at_shutdown_is_cancelled():
|
||||
m = BackgroundJobManager(max_workers=1)
|
||||
gate = threading.Event()
|
||||
try:
|
||||
m.start_job(gate.wait, job_id="a")
|
||||
_wait_status(m, "a", JobStatus.RUNNING)
|
||||
seen = []
|
||||
m.start_job(lambda: "B", job_id="b", on_complete=seen.append)
|
||||
m._executor.shutdown(wait=False, cancel_futures=True)
|
||||
assert m.get_status("b") == JobStatus.CANCELLED
|
||||
with pytest.raises(CancelledError):
|
||||
m.get_result("b")
|
||||
assert seen == [] # cancellations stay silent, like cancel_job()
|
||||
finally:
|
||||
gate.set()
|
||||
m.shutdown()
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Regression tests for issue #5870: observer isolation, atomic state writes, git deadlines."""
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import main as _main
|
||||
from praisonaiagents.approval import PermissionAllowlist
|
||||
|
||||
|
||||
def test_raising_sync_callback_does_not_propagate():
|
||||
def boom(**kw):
|
||||
raise RuntimeError("callback bug")
|
||||
|
||||
_main.register_display_callback("interaction", boom)
|
||||
_main.execute_sync_callback("interaction", message="hi") # must not raise
|
||||
|
||||
|
||||
def test_raising_callbacks_do_not_propagate_async():
|
||||
seen = []
|
||||
|
||||
def boom(**kw):
|
||||
raise RuntimeError("sync bug")
|
||||
|
||||
async def aboom(message):
|
||||
seen.append(message)
|
||||
raise RuntimeError("async bug")
|
||||
|
||||
_main.register_display_callback("interaction", boom)
|
||||
_main.register_display_callback("interaction", aboom, is_async=True)
|
||||
asyncio.run(_main.execute_callback("interaction", message="hi", extra=1))
|
||||
assert seen == ["hi"]
|
||||
|
||||
|
||||
def test_allowlist_failed_save_keeps_previous_file(tmp_path):
|
||||
p = str(tmp_path / "allow.json")
|
||||
a = PermissionAllowlist()
|
||||
a.add_tool("read_file", paths=["/ok"])
|
||||
a.save(p)
|
||||
good = open(p).read()
|
||||
|
||||
a._tools["read_file"].allowed_paths = ["/ok", object()]
|
||||
with pytest.raises(TypeError):
|
||||
a.save(p)
|
||||
assert open(p).read() == good
|
||||
assert PermissionAllowlist.load(p).list_tools() == ["read_file"]
|
||||
|
||||
|
||||
def test_allowlist_load_quarantines_corrupt_file(tmp_path):
|
||||
p = tmp_path / "allow.json"
|
||||
p.write_text('{"tools": {')
|
||||
assert PermissionAllowlist.load(str(p)).list_tools() == []
|
||||
assert not p.exists()
|
||||
assert list(tmp_path.glob("allow.json.*.corrupt"))
|
||||
|
||||
|
||||
def _timeout_run(calls):
|
||||
def fake_run(cmd, **kwargs):
|
||||
calls.append(kwargs)
|
||||
raise subprocess.TimeoutExpired(cmd, kwargs.get("timeout"))
|
||||
return fake_run
|
||||
|
||||
|
||||
def test_git_fetch_timeout_without_cache_returns_empty(tmp_path, monkeypatch):
|
||||
from praisonaiagents.skills import remote
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(remote.subprocess, "run", _timeout_run(calls))
|
||||
src = remote.GitRemoteSkillSource("http://127.0.0.1:1/org/skills.git")
|
||||
assert src.fetch(tmp_path) == []
|
||||
assert calls[0]["timeout"] == remote._GIT_TIMEOUT
|
||||
assert calls[0]["env"]["GIT_TERMINAL_PROMPT"] == "0"
|
||||
|
||||
|
||||
def test_git_fetch_timeout_falls_back_to_cache(tmp_path, monkeypatch):
|
||||
from praisonaiagents.skills import remote
|
||||
|
||||
src = remote.GitRemoteSkillSource("http://127.0.0.1:1/org/skills.git")
|
||||
skill = src._cache_base(tmp_path) / remote._CURRENT_LINK / "my-skill"
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text("---\nname: my-skill\n---\n")
|
||||
|
||||
monkeypatch.setattr(remote.subprocess, "run", _timeout_run([]))
|
||||
result = src.fetch(tmp_path)
|
||||
assert len(result) == 1
|
||||
assert (result[0] / "my-skill" / "SKILL.md").exists()
|
||||
|
||||
|
||||
def test_config_dump_preserves_symlink_and_mode(tmp_path):
|
||||
import os
|
||||
from praisonaiagents.config.loader import _dump_config_file
|
||||
|
||||
shared = tmp_path / "shared.yaml"
|
||||
shared.write_text("plugins: {}\n")
|
||||
os.chmod(shared, 0o644)
|
||||
link = tmp_path / "proj" / "config.yaml"
|
||||
link.parent.mkdir()
|
||||
link.symlink_to(shared)
|
||||
|
||||
_dump_config_file(link, {"plugins": {"enabled": ["x"]}})
|
||||
assert link.is_symlink()
|
||||
assert "x" in shared.read_text()
|
||||
assert shared.stat().st_mode & 0o777 == 0o644
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Regression tests for FileScheduleStore persistence (issue #5899).
|
||||
|
||||
- Run history from two stores on the same dir is merged, not last-writer-wins.
|
||||
- A failed save from add/update/remove raises, keeps memory consistent with
|
||||
disk, and does not fire lifecycle hooks.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.scheduler import store as store_mod
|
||||
from praisonaiagents.scheduler.models import Schedule, ScheduleJob
|
||||
from praisonaiagents.scheduler.store import FileScheduleStore
|
||||
|
||||
|
||||
def _make_job(name="job"):
|
||||
return ScheduleJob(
|
||||
name=name,
|
||||
schedule=Schedule(kind="every", every_seconds=60),
|
||||
message="hello",
|
||||
)
|
||||
|
||||
|
||||
def test_history_not_lost_across_stores(tmp_path):
|
||||
d = str(tmp_path)
|
||||
a = FileScheduleStore(store_dir=d)
|
||||
b = FileScheduleStore(store_dir=d)
|
||||
a.log_run("j1", "succeeded", job_name="n1")
|
||||
b.log_run("j2", "succeeded", job_name="n2")
|
||||
ids = [r.job_id for r in FileScheduleStore(store_dir=d).get_history()]
|
||||
assert ids == ["j2", "j1"]
|
||||
|
||||
|
||||
def test_history_respects_max_after_reload(tmp_path):
|
||||
d = str(tmp_path)
|
||||
a = FileScheduleStore(store_dir=d, max_history=2)
|
||||
b = FileScheduleStore(store_dir=d, max_history=2)
|
||||
a.log_run("j1", "succeeded")
|
||||
b.log_run("j2", "succeeded")
|
||||
a.log_run("j3", "succeeded")
|
||||
assert [r.job_id for r in a.get_history()] == ["j3", "j2"]
|
||||
|
||||
|
||||
def _fail_replace(*args, **kwargs):
|
||||
raise OSError("disk full")
|
||||
|
||||
|
||||
def test_add_failed_save_raises_and_rolls_back(tmp_path):
|
||||
d = str(tmp_path)
|
||||
s = FileScheduleStore(store_dir=d)
|
||||
with mock.patch("praisonaiagents.utils.atomic_io.os.replace", _fail_replace), \
|
||||
mock.patch.object(store_mod, "emit_schedule_add") as emit:
|
||||
with pytest.raises(OSError):
|
||||
s.add(_make_job())
|
||||
emit.assert_not_called()
|
||||
assert s.list() == []
|
||||
assert FileScheduleStore(store_dir=d).list() == []
|
||||
assert not [f for f in os.listdir(d) if f.endswith(".tmp")]
|
||||
|
||||
|
||||
def test_update_failed_save_restores_previous(tmp_path):
|
||||
d = str(tmp_path)
|
||||
s = FileScheduleStore(store_dir=d)
|
||||
job = _make_job()
|
||||
s.add(job)
|
||||
changed = ScheduleJob.from_dict(job.to_dict())
|
||||
changed.message = "changed"
|
||||
with mock.patch("praisonaiagents.utils.atomic_io.os.replace", _fail_replace):
|
||||
with pytest.raises(OSError):
|
||||
s.update(changed)
|
||||
assert s.get(job.id).message == "hello"
|
||||
|
||||
|
||||
def test_remove_failed_save_keeps_job(tmp_path):
|
||||
d = str(tmp_path)
|
||||
s = FileScheduleStore(store_dir=d)
|
||||
job = _make_job()
|
||||
s.add(job)
|
||||
with mock.patch("praisonaiagents.utils.atomic_io.os.replace", _fail_replace), \
|
||||
mock.patch.object(store_mod, "emit_schedule_remove") as emit:
|
||||
with pytest.raises(OSError):
|
||||
s.remove(job.id)
|
||||
with pytest.raises(OSError):
|
||||
s.remove_by_name(job.name)
|
||||
emit.assert_not_called()
|
||||
assert s.get(job.id) is not None
|
||||
|
||||
|
||||
def _log_many(d, prefix, n):
|
||||
s = FileScheduleStore(store_dir=d, max_history=1000)
|
||||
for i in range(n):
|
||||
s.log_run(f"{prefix}{i}", "succeeded")
|
||||
|
||||
|
||||
def test_history_overlapping_processes_keep_all_records(tmp_path):
|
||||
import multiprocessing as mp
|
||||
d = str(tmp_path)
|
||||
ctx = mp.get_context("spawn")
|
||||
procs = [ctx.Process(target=_log_many, args=(d, p, 20)) for p in ("a", "b")]
|
||||
for p in procs:
|
||||
p.start()
|
||||
for p in procs:
|
||||
p.join(60)
|
||||
assert p.exitcode == 0
|
||||
ids = {r.job_id for r in FileScheduleStore(store_dir=d, max_history=1000).get_history()}
|
||||
assert ids == {f"{p}{i}" for p in ("a", "b") for i in range(20)}
|
||||
|
||||
|
||||
def test_unreadable_history_is_not_overwritten(tmp_path):
|
||||
d = str(tmp_path)
|
||||
s = FileScheduleStore(store_dir=d)
|
||||
s.log_run("j1", "succeeded")
|
||||
with mock.patch.object(FileScheduleStore, "_read_history", return_value=None):
|
||||
s.log_run("j2", "succeeded")
|
||||
assert [r.job_id for r in FileScheduleStore(store_dir=d).get_history()] == ["j1"]
|
||||
# The unsaved record is retried once history is readable again.
|
||||
s.log_run("j3", "succeeded")
|
||||
assert [r.job_id for r in FileScheduleStore(store_dir=d).get_history()] == ["j3", "j2", "j1"]
|
||||
|
||||
|
||||
def test_failed_history_save_is_retried(tmp_path):
|
||||
d = str(tmp_path)
|
||||
s = FileScheduleStore(store_dir=d)
|
||||
with mock.patch("praisonaiagents.utils.atomic_io.os.replace", _fail_replace):
|
||||
s.log_run("j1", "succeeded")
|
||||
s.log_run("j2", "succeeded")
|
||||
assert [r.job_id for r in FileScheduleStore(store_dir=d).get_history()] == ["j2", "j1"]
|
||||
|
||||
|
||||
def test_retried_record_does_not_hide_newer_records(tmp_path):
|
||||
d = str(tmp_path)
|
||||
a = FileScheduleStore(store_dir=d, max_history=2)
|
||||
b = FileScheduleStore(store_dir=d, max_history=2)
|
||||
with mock.patch.object(store_mod.time, "time", return_value=100.0):
|
||||
with mock.patch("praisonaiagents.utils.atomic_io.os.replace", _fail_replace):
|
||||
a.log_run("old", "succeeded")
|
||||
with mock.patch.object(store_mod.time, "time", return_value=200.0):
|
||||
b.log_run("newer", "succeeded")
|
||||
with mock.patch.object(store_mod.time, "time", return_value=300.0):
|
||||
a.log_run("newest", "succeeded")
|
||||
ids = [r.job_id for r in FileScheduleStore(store_dir=d).get_history()]
|
||||
assert ids == ["newest", "newer"]
|
||||
|
||||
|
||||
def test_mark_run_contains_store_write_error(tmp_path):
|
||||
from praisonaiagents.scheduler.runner import ScheduleRunner
|
||||
s = FileScheduleStore(store_dir=str(tmp_path))
|
||||
job = _make_job()
|
||||
s.add(job)
|
||||
with mock.patch("praisonaiagents.utils.atomic_io.os.replace", _fail_replace):
|
||||
ScheduleRunner(s).mark_run(job) # must not raise
|
||||
@@ -33,6 +33,54 @@ def test_web_crawl_rejects_redirect_to_loopback():
|
||||
assert _is_safe_crawl_url("http://example.com/") is True
|
||||
|
||||
|
||||
def test_web_crawl_urllib_fallback_does_not_follow_redirect_to_internal(monkeypatch):
|
||||
import http.server
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from praisonaiagents.tools import web_crawl_tools as w
|
||||
|
||||
monkeypatch.setitem(sys.modules, "httpx", None) # force the urllib fallback
|
||||
hits = []
|
||||
|
||||
class Internal(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
hits.append(self.path)
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"<html>internal-metadata</html>")
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
internal = http.server.HTTPServer(("127.0.0.1", 0), Internal)
|
||||
|
||||
class Redir(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(302)
|
||||
self.send_header("Location", f"http://127.0.0.1:{internal.server_port}/meta")
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
redir = http.server.HTTPServer(("127.0.0.1", 0), Redir)
|
||||
for server in (internal, redir):
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
start = f"http://127.0.0.1:{redir.server_port}/"
|
||||
real = w._is_safe_crawl_url
|
||||
monkeypatch.setattr(w, "_is_safe_crawl_url", lambda u: u == start or real(u))
|
||||
try:
|
||||
result = w._crawl_with_httpx([start])
|
||||
finally:
|
||||
internal.shutdown()
|
||||
redir.shutdown()
|
||||
|
||||
assert hits == []
|
||||
assert "internal-metadata" not in result[0]["content"]
|
||||
assert "SSRF" in result[0]["error"]
|
||||
|
||||
|
||||
def test_searxng_may_use_loopback_but_the_crawler_may_not():
|
||||
"""One predicate, two callers, opposite requirements. Pin both."""
|
||||
from praisonaiagents.tools.url_safety import validate_searxng_url
|
||||
|
||||
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user