mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-10 17:29:39 -10:00
Compare commits
No files matched your search
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: praisonai-js-hub-intake
|
||||
description: >-
|
||||
Explains Option B hub intake for PraisonAI TypeScript/JavaScript (npm praisonai):
|
||||
accept TS issues on MervinPraison/PraisonAI, implement fixes in MervinPraison/praisonai-js,
|
||||
sync to src/praisonai-ts/. Use when routing issues, implementing TS fixes, sync workflows,
|
||||
merge-gate TS paths, or when the user mentions praisonai-js, praisonai-ts, npm SDK, or TS hub intake.
|
||||
---
|
||||
|
||||
# praisonai-js hub intake (Option B)
|
||||
|
||||
## Model in one sentence
|
||||
|
||||
**Users file TypeScript issues on [PraisonAI](https://github.com/MervinPraison/PraisonAI); fixes land in [praisonai-js](https://github.com/MervinPraison/praisonai-js); the monorepo `src/praisonai-ts/` is a sync mirror only.**
|
||||
|
||||
Do **not** ask users to re-file on praisonai-js unless they opened a duplicate on the wrong repo.
|
||||
|
||||
## Routing table
|
||||
|
||||
| Topic | Report | Fix repo | Monorepo mirror |
|
||||
|-------|--------|----------|-----------------|
|
||||
| Python SDK / CLI | PraisonAI | `src/praisonai-agents/`, `src/praisonai/` | — |
|
||||
| TypeScript / npm `praisonai` | PraisonAI (preferred) or praisonai-js | **praisonai-js** | `src/praisonai-ts/` |
|
||||
| Cross-language parity | PraisonAI | Often both Python + praisonai-js | sync after TS merge |
|
||||
| Agent-callable tools | PraisonAI | PraisonAI-Tools | — |
|
||||
| Lifecycle plugins | PraisonAI | PraisonAI-Plugins | — |
|
||||
|
||||
## End-to-end flow
|
||||
|
||||
```
|
||||
Issue on PraisonAI
|
||||
→ issue-triage: labels javascript, praisonai-js (if TS focus)
|
||||
→ claude label → Claude STEP 3-ALT clones praisonai-js
|
||||
→ PR on praisonai-js with body: Fixes MervinPraison/PraisonAI#N
|
||||
→ merge on praisonai-js (CI + merge gate there)
|
||||
→ Sync to PraisonAI Monorepo workflow on praisonai-js
|
||||
→ PR on PraisonAI: sync: update src/praisonai-ts/...
|
||||
```
|
||||
|
||||
## Agent rules
|
||||
|
||||
1. **Never implement TS features in `src/praisonai-ts/` on PraisonAI** — edit praisonai-js instead.
|
||||
2. **External PR body** must include `Fixes MervinPraison/PraisonAI#<n>` when fixing a hub issue.
|
||||
3. **After praisonai-js merge**, run sync workflow (or tell maintainer to):
|
||||
```bash
|
||||
gh workflow run "Sync to PraisonAI Monorepo" \
|
||||
--repo MervinPraison/praisonai-js \
|
||||
-f upstream_issue=<issue_number>
|
||||
```
|
||||
4. **Merge gate on PraisonAI** blocks direct `src/praisonai-ts/` changes unless sync PR (title `sync:` or body mentions praisonai-js).
|
||||
5. **Tests for TS fixes**: `npm install && npm run build && npm test` in praisonai-js clone.
|
||||
|
||||
## TS issue detection (PraisonAI triage)
|
||||
|
||||
Labels `javascript` + `praisonai-js` when issue mentions: `praisonai-js`, `praisonai-ts`, `src/praisonai-ts`, `typescript`+`praisonai`, `npm`+`praisonai` (not pypi).
|
||||
|
||||
## Key files
|
||||
|
||||
See [reference.md](reference.md) for paths in both repos.
|
||||
|
||||
## Quick commands
|
||||
|
||||
```bash
|
||||
# Sync Claude secrets to praisonai-js (from PraisonAI hub)
|
||||
gh workflow run "Sync Claude Secrets" \
|
||||
--repo MervinPraison/PraisonAI \
|
||||
-f target_repo=MervinPraison/praisonai-js
|
||||
|
||||
# Sync code praisonai-js → monorepo mirror
|
||||
gh workflow run "Sync to PraisonAI Monorepo" \
|
||||
--repo MervinPraison/praisonai-js \
|
||||
-f upstream_issue=1234
|
||||
```
|
||||
|
||||
## Related docs
|
||||
|
||||
- Monorepo: `CONTRIBUTING.md` (hub intake table)
|
||||
- praisonai-js: `AGENTS.md` §2.1.1 Hub intake
|
||||
- Security: `SECURITY.md` (npm canonical = praisonai-js)
|
||||
|
||||
## Cursor skill locations
|
||||
|
||||
| Repo | Path |
|
||||
|------|------|
|
||||
| PraisonAI monorepo | `.cursor/skills/praisonai-js-hub-intake/` |
|
||||
| praisonai-js | `.cursor/skills/praisonai-js-hub-intake/` |
|
||||
@@ -0,0 +1,72 @@
|
||||
# praisonai-js hub intake — file reference
|
||||
|
||||
## PraisonAI monorepo (`MervinPraison/PraisonAI`)
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `.github/workflows/claude.yml` | Issue triage labels; STEP 3-ALT to praisonai-js; npm test in external flow |
|
||||
| `.github/scripts/merge-gate.js` | `tsMirrorPathReasons()` blocks non-sync edits to `src/praisonai-ts/` |
|
||||
| `.github/workflows/claude-merge-gate.yml` | Assess checklist: TS → praisonai-js |
|
||||
| `.github/scripts/ci-failure-claude.js` | CI fix guardrails include TS routing |
|
||||
| `.github/actions/claude-code-action/action.yml` | Shared Claude routing (TS → praisonai-js) |
|
||||
| `.github/praisonai-issue-triage.yaml` | `@praisonai` triage: TS-external routing |
|
||||
| `.github/workflows/auto-issue-comment.yml` | Auto-triage comment mentions praisonai-js |
|
||||
| `.github/ISSUE_TEMPLATE/bug_report.md` | Python / TS / cross-language selector |
|
||||
| `CONTRIBUTING.md` | Hub intake table |
|
||||
| `SECURITY.md` | npm canonical source = praisonai-js |
|
||||
| `src/praisonai-ts/` | **Mirror only** — do not implement features here |
|
||||
| `.cursor/skills/praisonai-js-hub-intake/` | This skill (agent reference) |
|
||||
|
||||
### Commits (Option B rollout)
|
||||
|
||||
- `4ab1ed280` — Implement Option B hub intake for praisonai-js TypeScript issues
|
||||
|
||||
## praisonai-js (`MervinPraison/praisonai-js`)
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `.github/workflows/sync-to-praisonai.yml` | Rsync to `src/praisonai-ts/`; optional `upstream_issue` input |
|
||||
| `.github/pull_request_template.md` | `Fixes MervinPraison/PraisonAI#N` + sync checklist |
|
||||
| `.github/ISSUE_TEMPLATE/bug_report.md` | TS bug template; link to PraisonAI for cross-stack |
|
||||
| `.github/ISSUE_TEMPLATE/config.yml` | Contact links → PraisonAI for Python/cross-stack |
|
||||
| `.github/scripts/gate-config.js` | Scope + `externalRepos` hub-intake config |
|
||||
| `.github/workflows/claude.yml` | Hub caller (Claude automation) |
|
||||
| `AGENTS.md` §2.1.1 | Hub intake for agents |
|
||||
| `README.md` | Reporting issues table |
|
||||
| `.cursor/skills/praisonai-js-hub-intake/` | This skill (agent reference) |
|
||||
|
||||
### Commits
|
||||
|
||||
- `0c8cc3f` — Complete Option B reciprocal hub intake
|
||||
- `bfc5e34` — Add praisonai-js-hub-intake Cursor skill
|
||||
|
||||
## github-automation-template
|
||||
|
||||
| Item | Role |
|
||||
|------|------|
|
||||
| `install.sh --mode hub` | Thin callers on consumer repos |
|
||||
| `stacks/claude/profiles/sdk/gate-config.js.tmpl` | Base gate config (customise per repo) |
|
||||
| `AGENTS.md` | Install / rollout guide |
|
||||
|
||||
praisonai-js uses hub mode `@v1.1.0`.
|
||||
|
||||
## Decision tree for agents
|
||||
|
||||
```
|
||||
Is the issue about npm / TypeScript / praisonai-js / src/praisonai-ts?
|
||||
├─ YES → Fix in MervinPraison/praisonai-js (STEP 3-ALT)
|
||||
│ PR: Fixes MervinPraison/PraisonAI#N
|
||||
│ After merge: Sync to PraisonAI Monorepo
|
||||
└─ NO → Is it Python praisonaiagents / praisonai CLI?
|
||||
├─ YES → Fix in this monorepo
|
||||
└─ NO → Tools / Plugins / Docs external repos (see claude.yml STEP 2)
|
||||
```
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
| Wrong | Right |
|
||||
|-------|-------|
|
||||
| Edit `src/praisonai-ts/` for a new TS feature | Edit praisonai-js, then sync |
|
||||
| Tell user to re-file on praisonai-js only | Accept on PraisonAI; route fix externally |
|
||||
| Merge gate approves direct TS mirror PR without sync metadata | Title `sync:` or body references praisonai-js |
|
||||
| Skip `Fixes MervinPraison/PraisonAI#N` on external PR | Always cross-link hub issues |
|
||||
@@ -17,6 +17,7 @@ GROQ_API_KEY=gsk_... # Groq (fast inference)
|
||||
COHERE_API_KEY=... # Cohere models
|
||||
AZURE_OPENAI_API_KEY=... # Azure OpenAI
|
||||
AZURE_OPENAI_ENDPOINT=... # Azure OpenAI endpoint
|
||||
EVOLINK_API_KEY=... # EvoLink.AI OpenAI-compatible models. Get yours at https://evolink.ai/dashboard/keys
|
||||
|
||||
# === Optional: Memory backends ===
|
||||
MEM0_API_KEY=... # Mem0 memory service
|
||||
|
||||
@@ -6,6 +6,11 @@ labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## SDK / package
|
||||
- [ ] Python (`pip install praisonai` / `praisonaiagents`)
|
||||
- [ ] TypeScript / JavaScript (`npm install praisonai`) — fixes land in [praisonai-js](https://github.com/MervinPraison/praisonai-js); you can report here or there
|
||||
- [ ] Cross-language / parity (file here on PraisonAI)
|
||||
|
||||
## Environment
|
||||
- Provider (select one):
|
||||
- [ ] Anthropic
|
||||
@@ -14,6 +19,7 @@ assignees: ''
|
||||
- [ ] AWS Bedrock
|
||||
- [ ] Other: <!-- specify -->
|
||||
- PraisonAI version: <!-- if known -->
|
||||
- Node.js version (TS/JS only): <!-- e.g. 20.x -->
|
||||
- Operating System: <!-- e.g. macOS 14.3, Windows 11, Ubuntu 22.04 -->
|
||||
|
||||
## Full Code
|
||||
@@ -31,4 +37,4 @@ assignees: ''
|
||||
<!-- What actually happened -->
|
||||
|
||||
## Additional Context
|
||||
<!-- Add any other context about the problem here, such as screenshots, logs, etc. -->
|
||||
<!-- Add any other context about the problem here, such as screenshots, logs, etc. -->
|
||||
@@ -225,9 +225,10 @@ runs:
|
||||
- CORE vs WRAPPER vs TOOLS vs PLUGINS ROUTING:
|
||||
1. Core SDK (praisonaiagents/): Only core protocols, base classes, decorators. No heavy implementations.
|
||||
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
|
||||
3. Tools → MervinPraison/PraisonAI-Tools: agent-callable integrations at runtime (Composio, Slack, APIs). Do NOT add to praisonaiagents/.
|
||||
4. Plugins → MervinPraison/PraisonAI-Plugins: lifecycle extensions (tracing, logging, hooks, guardrails). Not agent tools.
|
||||
5. Sandbox backends → PraisonAI-Plugins via `praisonai.sandbox` entry point (core protocol only).
|
||||
3. TypeScript/JavaScript SDK → MervinPraison/praisonai-js: canonical npm repo. Do NOT edit src/praisonai-ts/ here.
|
||||
4. Tools → MervinPraison/PraisonAI-Tools: agent-callable integrations at runtime (Composio, Slack, APIs). Do NOT add to praisonaiagents/.
|
||||
5. Plugins → MervinPraison/PraisonAI-Plugins: lifecycle extensions (tracing, logging, hooks, guardrails). Not agent tools.
|
||||
6. Sandbox backends → PraisonAI-Plugins via `praisonai.sandbox` entry point (core protocol only).
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
|
||||
- Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
|
||||
|
||||
@@ -35,16 +35,17 @@ steps:
|
||||
Third, read architecture guidelines: execute_command 'cat src/praisonai-agents/AGENTS.md'.
|
||||
|
||||
Fourth, decide routing before planning:
|
||||
- TypeScript/JavaScript SDK → MervinPraison/praisonai-js: canonical npm repo. Do NOT edit src/praisonai-ts/ in this monorepo. Cross-link upstream issue in external PR body: Fixes MervinPraison/PraisonAI#N.
|
||||
- Tools → PraisonAI-Tools: agent-callable integrations at runtime (Composio, Slack, APIs).
|
||||
- Plugins → PraisonAI-Plugins: lifecycle extensions (tracing, logging, hooks, guardrails).
|
||||
- Sandbox backends → PraisonAI-Plugins via `praisonai.sandbox` entry point.
|
||||
Core SDK/wrapper only for protocols and CLI — external repos for Tools/Plugins.
|
||||
Core SDK/wrapper only for protocols and CLI — external repos for TS SDK/Tools/Plugins.
|
||||
|
||||
Fifth, decide if this issue requires code changes:
|
||||
- If YES: identify the exact files and lines that must change. Output a concrete file-level plan.
|
||||
- If NO (question, discussion, duplicate, invalid): say so clearly. No code changes needed.
|
||||
|
||||
Output: routing decision (Core / Wrapper / Tools-external / Plugins-external / No-change), validation result, and exact files/lines to change.
|
||||
Output: routing decision (Core / Wrapper / TS-external / Tools-external / Plugins-external / No-change), validation result, and exact files/lines to change.
|
||||
expected_output: A decision on whether code changes are needed, and if so a concrete file-level plan.
|
||||
|
||||
- name: Implement Fix
|
||||
|
||||
@@ -157,6 +157,7 @@ function buildCiFixComment({ headSha, failedChecks, failureSummaries }) {
|
||||
'3. **Pre-existing flake or unrelated failure** — say so explicitly; prefer fixing the root cause over masking it.',
|
||||
'',
|
||||
'**SDK guardrails (AGENTS.md):**',
|
||||
'- TypeScript/JavaScript SDK → MervinPraison/praisonai-js (not src/praisonai-ts/ in this monorepo).',
|
||||
'- Agent-callable tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins; sandbox backends → PraisonAI-Plugins (`praisonai.sandbox`) — not praisonaiagents/.',
|
||||
'- Do not disturb core SDK contracts to accommodate a wrapper/feature change.',
|
||||
'- Tests must continue to guard backward compatibility and hot-path behaviour — passing CI by lowering test standards is not acceptable.',
|
||||
|
||||
@@ -308,7 +308,7 @@ function finalClaudeCompletedOnSha(comments, headPushedAt) {
|
||||
}
|
||||
|
||||
const FINAL_CLAUDE_REVIEW_BODY =
|
||||
'@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n6. Repo routing: agent-callable tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins; optional sandbox backends → PraisonAI-Plugins (`praisonai.sandbox` entry point) — request changes if wrongly added to praisonaiagents/\n\n**Phase 2: FIX Valid Issues**\n7. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n8. Also independently identify and fix any gaps or issues you find in the changed code — do not rely only on prior reviewer feedback\n9. Push all code fixes directly to THIS branch (do NOT create a new PR)\n10. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n11. If all issues are resolved, approve the PR / close the Issue\n12. If blocking issues remain, request changes / leave clear action items';
|
||||
'@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify src/praisonai-rust or src/praisonai-ts in this repo — TypeScript SDK work belongs in MervinPraison/praisonai-js. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n6. Repo routing: TypeScript/JavaScript SDK → praisonai-js (not src/praisonai-ts/ here); agent-callable tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins; optional sandbox backends → PraisonAI-Plugins (`praisonai.sandbox` entry point) — request changes if wrongly added to praisonaiagents/\n\n**Phase 2: FIX Valid Issues**\n7. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n8. Also independently identify and fix any gaps or issues you find in the changed code — do not rely only on prior reviewer feedback\n9. Push all code fixes directly to THIS branch (do NOT create a new PR)\n10. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n11. If all issues are resolved, approve the PR / close the Issue\n12. If blocking issues remain, request changes / leave clear action items';
|
||||
|
||||
async function getMergeState(github, owner, repo, prNumber) {
|
||||
const query = `
|
||||
@@ -600,6 +600,24 @@ function hasManualOnlyLabel(labels) {
|
||||
return labels.some((l) => MANUAL_ONLY_LABELS.has(l));
|
||||
}
|
||||
|
||||
const TS_MIRROR_PREFIX = 'src/praisonai-ts/';
|
||||
|
||||
function tsMirrorPathReasons(files, pr) {
|
||||
const touchesTs = files.some((f) => f.filename.startsWith(TS_MIRROR_PREFIX));
|
||||
if (!touchesTs) return [];
|
||||
const title = (pr.title || '').toLowerCase();
|
||||
const body = (pr.body || '').toLowerCase();
|
||||
const isSyncPr =
|
||||
title.startsWith('sync:') ||
|
||||
body.includes('sync from praisonai-js') ||
|
||||
body.includes('praisonai-js') ||
|
||||
body.includes('mervinpraison/praisonai-js');
|
||||
if (isSyncPr) return [];
|
||||
return [
|
||||
'src/praisonai-ts/ changed outside praisonai-js sync — implement in MervinPraison/praisonai-js, then run Sync to PraisonAI Monorepo workflow',
|
||||
];
|
||||
}
|
||||
|
||||
function sensitivePathReasons(files, labels = []) {
|
||||
if (labels.includes(WORKFLOW_ONLY_LABEL) && isCiOnlyChange(files)) {
|
||||
return [];
|
||||
@@ -823,6 +841,7 @@ async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, op
|
||||
if (agentManual) reasons.push(agentManual);
|
||||
|
||||
reasons.push(...sensitivePathReasons(pullFiles, ctx.labels));
|
||||
reasons.push(...tsMirrorPathReasons(pullFiles, ctx.pr));
|
||||
reasons.push(...prSizeReasons(pullFiles));
|
||||
reasons.push(...secretScanReasons(pullFiles));
|
||||
|
||||
@@ -989,6 +1008,7 @@ module.exports = {
|
||||
touchesSdk,
|
||||
hasManualOnlyLabel,
|
||||
sensitivePathReasons,
|
||||
tsMirrorPathReasons,
|
||||
prSizeReasons,
|
||||
missingTestsReason,
|
||||
secretScanReasons,
|
||||
|
||||
@@ -42,5 +42,5 @@ jobs:
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '🤖 Auto-triaging with Claude. Analyzing issue and implementing a fix...\n\nClaude will:\n1. Route correctly: agent tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins\n2. Search codebase for root cause\n3. Implement a minimal fix following AGENTS.md\n4. Create a PR with the changes'
|
||||
body: '🤖 Auto-triaging with Claude. Analyzing issue and implementing a fix...\n\nClaude will:\n1. Route correctly: TypeScript/JavaScript SDK → praisonai-js; agent tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins\n2. Search codebase for root cause\n3. Implement a minimal fix following AGENTS.md\n4. Create a PR with the changes (external repo PRs link back to this issue)'
|
||||
});
|
||||
@@ -289,7 +289,7 @@ jobs:
|
||||
1. Read AGENTS.md and the PR diff on the current branch.
|
||||
2. Read all PR comments and reviews (FINAL architecture reviewer / Lead Engineer must have run).
|
||||
3. Confirm SDK value gate: change strengthens praisonaiagents (no scope creep). The aim is a LIGHTWEIGHT AND POWERFUL package — BLOCK changes that add params/modules/exports duplicating existing Agent capabilities (instructions/backstory/tools/hooks/memory), or add knobs/fields with no live consumer, i.e. scope creep for the sake of it.
|
||||
4. Repo routing: BLOCK if agent-callable tools (→ PraisonAI-Tools), lifecycle plugins (→ PraisonAI-Plugins), or optional sandbox backends (→ PraisonAI-Plugins via `praisonai.sandbox`) were added to praisonaiagents/ instead of the external repo.
|
||||
4. Repo routing: BLOCK if TypeScript/JavaScript SDK changes in src/praisonai-ts/ (→ praisonai-js + sync workflow); agent-callable tools (→ PraisonAI-Tools); lifecycle plugins (→ PraisonAI-Plugins); or optional sandbox backends (→ PraisonAI-Plugins via `praisonai.sandbox`) were added to praisonaiagents/ instead of the external repo.
|
||||
5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
6. If agent.py adds >100 lines or new Agent __init__ params, respond MERGE_GATE_VERDICT: BLOCK (manual review required).
|
||||
7. BLOCK if labels security/breaking-change/needs-manual-review/release, sensitive paths (.github/workflows, auth, pyproject.toml), secrets in diff, PR >800 lines or >30 files, or SDK changes without tests. Label `merge-gate-ci-only` exempts CI-only changes under `.github/workflows/`, `.github/actions/`, or merge-gate scripts.
|
||||
|
||||
@@ -70,9 +70,21 @@ jobs:
|
||||
if (content.includes('documentation') || content.includes('docs')) {
|
||||
labels.push('documentation');
|
||||
}
|
||||
if (content.includes('typescript') || content.includes('javascript') || content.includes('npm')) {
|
||||
const isPraisonaiJsFocus =
|
||||
content.includes('praisonai-js') ||
|
||||
content.includes('praisonai-ts') ||
|
||||
content.includes('src/praisonai-ts') ||
|
||||
content.includes('typescript sdk') ||
|
||||
content.includes('javascript sdk') ||
|
||||
(content.includes('typescript') && content.includes('praisonai')) ||
|
||||
(content.includes('javascript') && content.includes('praisonai')) ||
|
||||
(content.includes('npm') && content.includes('praisonai') && !content.includes('pypi'));
|
||||
if (isPraisonaiJsFocus || content.includes('typescript') || content.includes('javascript') || content.includes('npm')) {
|
||||
labels.push('javascript');
|
||||
}
|
||||
if (isPraisonaiJsFocus) {
|
||||
labels.push('praisonai-js');
|
||||
}
|
||||
|
||||
// Apply labels
|
||||
if (labels.length > 0) {
|
||||
@@ -100,7 +112,7 @@ jobs:
|
||||
'',
|
||||
'_Bug and enhancement reports are auto-triaged with Claude. Questions and other issues: a maintainer can comment `@claude` to trigger analysis._',
|
||||
'',
|
||||
'**Routing:** Agent-callable tools → [PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools); lifecycle plugins (tracing/hooks/guardrails) → [PraisonAI-Plugins](https://github.com/MervinPraison/PraisonAI-Plugins).'
|
||||
'**Routing:** TypeScript/JavaScript SDK → [praisonai-js](https://github.com/MervinPraison/praisonai-js) (canonical; monorepo `src/praisonai-ts/` is synced from there). Agent-callable tools → [PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools); lifecycle plugins (tracing/hooks/guardrails) → [PraisonAI-Plugins](https://github.com/MervinPraison/PraisonAI-Plugins).'
|
||||
].join('\n')
|
||||
});
|
||||
}
|
||||
@@ -293,7 +305,7 @@ jobs:
|
||||
|
||||
STEP 1 — READ GUIDELINES:
|
||||
Read AGENTS.md to understand the architecture rules.
|
||||
SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify or touch praisonai-rust or praisonai-ts packages.
|
||||
SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or src/praisonai-ts in this repo — TypeScript/JavaScript SDK work belongs in MervinPraison/praisonai-js (STEP 3-ALT).
|
||||
AIM: This package must stay LIGHTWEIGHT AND POWERFUL. Do a CRITICAL REVIEW at each stage (issue triage, fix planning, PR review/implementation) and reject scope creep for the sake of adding features. Prefer existing Agent capabilities (instructions/backstory/tools/hooks/memory) over new params/modules/exports. Even when fixing a raised issue, keep the fix minimal and lightweight — do NOT expand the surface area or add knobs with no live consumer.
|
||||
|
||||
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
|
||||
@@ -305,7 +317,8 @@ jobs:
|
||||
4. Documentation (PraisonAIDocs): Documentation pages, guides, API references.
|
||||
5. Plugins → MervinPraison/PraisonAI-Plugins: framework lifecycle extensions (tracing, logging, metrics, hooks, guardrails). Not agent tools — use STEP 3-ALT.
|
||||
6. Sandbox backends (optional/niche, e.g. Capsule) → PraisonAI-Plugins via `praisonai.sandbox` entry point; core keeps protocol/config/manager only.
|
||||
For items routed to an EXTERNAL repository (Tools, Docs, Plugins), follow STEP 3-ALT below.
|
||||
7. TypeScript/JavaScript SDK → MervinPraison/praisonai-js: canonical npm SDK repo. Do NOT implement in src/praisonai-ts/ here — clone praisonai-js and use STEP 3-ALT. After merge there, run **Sync to PraisonAI Monorepo** workflow to update src/praisonai-ts/.
|
||||
For items routed to an EXTERNAL repository (Tools, Docs, Plugins, praisonai-js), follow STEP 3-ALT below.
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory). If it does, reject the change as scope creep and prefer the existing capability — the aim is lightweight and powerful, not more knobs.
|
||||
- Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
|
||||
@@ -318,7 +331,7 @@ jobs:
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && (steps.check_fork.outputs.is_fork != 'true' || steps.check_fork.outputs.can_push_fork == 'true') && steps.check_fork.outputs.merge_state != 'DIRTY' && format('STEP 3 — IMPLEMENT ON EXISTING PR (branch: {0}, PR #{1}{2}):\n- DO NOT create a new branch or PR\n- git checkout {0}\n- Implement minimal, focused fixes from reviewer feedback\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description>"\n- Push: git push origin {0}\n- Comment a summary of exact files modified and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number, steps.check_fork.outputs.can_push_fork == 'true' && format(', fork: {0}', steps.check_fork.outputs.fork_repo) || '') || '' }}
|
||||
${{ steps.check_fork.outputs.is_pr != 'true' && 'STEP 3 — IMPLEMENT (for changes in THIS repo — PraisonAI):\n- Create a fix branch: git checkout -b claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\n- Implement a minimal, focused fix\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description> (fixes #$ISSUE_NUMBER)"\n- Push: git push origin claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\nCRITICAL: After pushing, you MUST immediately run: gh pr create --title "fix: <title>" --body "Fixes #$ISSUE_NUMBER" --head claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\nDo NOT stop after pushing. Do NOT say "manually create a PR". Always create the PR automatically.\n' || '' }}
|
||||
|
||||
STEP 3-ALT — IMPLEMENT IN EXTERNAL REPO (for PraisonAI-Tools, PraisonAIDocs, PraisonAI-Plugins):
|
||||
STEP 3-ALT — IMPLEMENT IN EXTERNAL REPO (for PraisonAI-Tools, PraisonAIDocs, PraisonAI-Plugins, praisonai-js):
|
||||
When work must happen in a different repository, follow these steps EXACTLY. Do NOT attempt to use `cd`, as directory state is not preserved. Use `git -C` for all commands.
|
||||
a) Clone the repository:
|
||||
gh repo clone MervinPraison/<REPO_NAME> /tmp/<REPO_NAME>
|
||||
@@ -336,9 +349,11 @@ jobs:
|
||||
gh pr create -R MervinPraison/<REPO_NAME> --head claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d) --title "feat: <title>" --body "Fixes MervinPraison/PraisonAI#$ISSUE_NUMBER
|
||||
|
||||
<body>"
|
||||
h) Comment on THIS PraisonAI issue with the external PR URL. For praisonai-js: note that after merge, a maintainer runs **Sync to PraisonAI Monorepo** on praisonai-js (Actions → sync workflow) to update src/praisonai-ts/.
|
||||
|
||||
STEP 4 — TEST:
|
||||
- For SDK changes: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
|
||||
- For praisonai-js: cd /tmp/praisonai-js && npm install && npm run build && npm test
|
||||
- For docs changes: verify files exist and are valid markdown/MDX
|
||||
- Ensure no regressions
|
||||
|
||||
@@ -351,6 +366,8 @@ jobs:
|
||||
Bash(conda:*)
|
||||
Bash(pytest:*)
|
||||
Bash(gh:*)
|
||||
Bash(npm:*)
|
||||
Bash(node:*)
|
||||
Bash(python -m pytest:*)
|
||||
Bash(python -m pip:*)
|
||||
Bash(poetry:*)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Build the desktop app for macOS, Windows and Linux, and attach it to the
|
||||
# release.
|
||||
#
|
||||
# Releases carried no assets at all -- v4.7.1 shipped zero -- so there was
|
||||
# nothing for anyone to download. The macOS half of this shipped first; this
|
||||
# adds the other two, which were not merely missing from CI but impossible:
|
||||
# the bundle targets were `["app", "dmg"]`, both macOS-only, so a Windows or
|
||||
# Linux build produced zero artifacts and still exited zero.
|
||||
#
|
||||
# Unsigned, deliberately, on every platform. macOS signing needs a Developer ID
|
||||
# and Windows signing now needs a hardware-backed certificate or a hosted
|
||||
# signing service -- neither is in secrets. Shipping builds people can open
|
||||
# past the warning is better than shipping none, and the release notes say so
|
||||
# rather than letting Gatekeeper and SmartScreen say it for us.
|
||||
name: Desktop release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Existing release tag to attach the build to'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
# Queue rather than cancel: a cancelled release build looks identical to a
|
||||
# release that simply never produced assets, which is the bug being fixed.
|
||||
group: desktop-release-${{ github.event.release.tag_name || inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# macos-15, not macos-14: that image entered deprecation on
|
||||
# 2026-07-06 and is fully unsupported from 2026-11-02, with brownout
|
||||
# periods failing jobs before then. This is the only Apple-silicon
|
||||
# leg, so losing it means no arm64 DMG at all.
|
||||
- runner: macos-15 # Apple silicon
|
||||
target: aarch64-apple-darwin
|
||||
bundles: dmg
|
||||
ext: dmg
|
||||
asset: macos-apple-silicon
|
||||
# macos-15-intel, not macos-13: that image was retired on
|
||||
# 2025-12-04, so the leg would never schedule and the verify job
|
||||
# below would correctly report a missing asset on every release,
|
||||
# forever. macos-15-intel is GitHub's named x86_64 replacement.
|
||||
- runner: macos-15-intel # Intel
|
||||
target: x86_64-apple-darwin
|
||||
bundles: dmg
|
||||
ext: dmg
|
||||
asset: macos-intel
|
||||
- runner: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
bundles: nsis
|
||||
ext: exe
|
||||
asset: windows-x64-setup
|
||||
# ubuntu-22.04 rather than -latest on purpose: glibc is forward
|
||||
# compatible only, so the build host sets the *oldest* distribution
|
||||
# the .deb can run on. Building on 24.04 would produce a package that
|
||||
# refuses to start on 22.04 and Debian 12 with a "GLIBC_2.38 not
|
||||
# found" that no runtime flag can fix.
|
||||
#
|
||||
# This image begins deprecation on 2026-09-17 and is retired on
|
||||
# 2027-04-17. When it goes, move to `ubuntu-latest` with
|
||||
# `container: debian:12` rather than bumping the runner -- that keeps
|
||||
# the glibc floor decided by us instead of by GitHub's image
|
||||
# lifecycle.
|
||||
- runner: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
bundles: deb
|
||||
ext: deb
|
||||
asset: linux-x64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install the Linux build dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Retried: a degraded mirror otherwise burns the whole job and
|
||||
# reports "cancelled", which reads as our failure rather than apt's.
|
||||
for attempt in 1 2 3; do
|
||||
sudo apt-get update && break || sleep 15
|
||||
done
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
patchelf \
|
||||
xdg-utils
|
||||
# libappindicator3-dev, not the ayatana package: the .deb's generated
|
||||
# Depends: follows whichever one the binary linked against, so the
|
||||
# two must not be mixed between builds.
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src/praisonai-desktop/src-tauri -> target
|
||||
# Saving the Windows target directory costs more minutes than the
|
||||
# rebuild it saves.
|
||||
save-if: ${{ runner.os != 'Windows' }}
|
||||
|
||||
- name: Install the Tauri CLI
|
||||
shell: bash
|
||||
# `|| true` swallowed a genuine install failure and the next step then
|
||||
# died with an unrelated "no such command: tauri". It also behaved
|
||||
# differently per leg, since Windows defaults to pwsh where `true` is
|
||||
# not a command. Skip the install if it is already there; otherwise
|
||||
# let a failure be a failure.
|
||||
run: command -v cargo-tauri >/dev/null || cargo install tauri-cli --version '^2' --locked
|
||||
|
||||
- name: Build the bundle
|
||||
working-directory: src/praisonai-desktop/src-tauri
|
||||
run: cargo tauri build --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
|
||||
|
||||
- name: Name the artifact for the platform it runs on
|
||||
id: artifact
|
||||
shell: bash
|
||||
working-directory: src/praisonai-desktop/src-tauri
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${{ github.event.release.tag_name || inputs.tag }}"
|
||||
root="target/${{ matrix.target }}/release/bundle"
|
||||
found="$(find "$root" -name "*.${{ matrix.ext }}" -print -quit || true)"
|
||||
# Fail loudly rather than uploading nothing: an empty release asset
|
||||
# list is exactly the state this workflow exists to fix, and a bundler
|
||||
# given a target it does not support exits zero having written none.
|
||||
if [ -z "$found" ]; then
|
||||
echo "no .${{ matrix.ext }} was produced under $root"
|
||||
find "$root" -type f -print || echo "(the bundle directory does not exist)"
|
||||
exit 1
|
||||
fi
|
||||
out="PraisonAI-${tag}-${{ matrix.asset }}.${{ matrix.ext }}"
|
||||
mv "$found" "../$out"
|
||||
echo "path=src/praisonai-desktop/$out" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Attach it to the release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release upload \
|
||||
"${{ github.event.release.tag_name || inputs.tag }}" \
|
||||
"${{ steps.artifact.outputs.path }}" --clobber
|
||||
|
||||
verify:
|
||||
# A release with a missing platform is worse than an obviously failed
|
||||
# build, because nobody notices. This says which one is absent.
|
||||
needs: build
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check every platform produced an asset
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.event.release.tag_name || inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
assets="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json assets -q '.assets[].name')"
|
||||
echo "$assets"
|
||||
missing=""
|
||||
for want in macos-apple-silicon macos-intel windows-x64-setup linux-x64; do
|
||||
echo "$assets" | grep -q -- "$want" || missing="$missing $want"
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error::the release is missing:$missing"
|
||||
exit 1
|
||||
fi
|
||||
echo "all four platform builds are attached"
|
||||
@@ -0,0 +1,115 @@
|
||||
# The desktop app's tests.
|
||||
#
|
||||
# These existed and ran nowhere: no workflow referenced praisonai-desktop, and
|
||||
# the frontend suite could not even be installed -- jsdom was undeclared, so it
|
||||
# ran only on machines that happened to have one further up the tree, at
|
||||
# whatever version that happened to be.
|
||||
name: desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['src/praisonai-desktop/**', '.github/workflows/desktop.yml',
|
||||
'.github/workflows/desktop-release.yml']
|
||||
pull_request:
|
||||
paths: ['src/praisonai-desktop/**', '.github/workflows/desktop.yml',
|
||||
'.github/workflows/desktop-release.yml']
|
||||
|
||||
concurrency:
|
||||
group: desktop-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/praisonai-desktop
|
||||
|
||||
jobs:
|
||||
engine:
|
||||
name: engine (${{ matrix.os }})
|
||||
# The engine is the part that has to behave the same on all three, which is
|
||||
# the entire point of the portability suite -- so it is run on all three.
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-22.04, macos-15, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Run the engine tests
|
||||
shell: bash
|
||||
env:
|
||||
# Never touch a real keychain on a shared runner.
|
||||
PRAISONAI_KEYCHAIN_SERVICE: ai.praison.desktop.ci
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
for suite in engine/test_*.py; do
|
||||
echo "::group::$suite"
|
||||
python "$suite" -v || failed=1
|
||||
echo "::endgroup::"
|
||||
done
|
||||
exit $failed
|
||||
|
||||
frontend:
|
||||
name: frontend
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- run: npm ci
|
||||
# `test:ci` is every suite except lifecycle, which drives real
|
||||
# conversations against a live engine and a real model -- neither of
|
||||
# which exists on a runner. It is excluded by name rather than left to
|
||||
# skip quietly, so that a skipped test here is a genuine problem.
|
||||
- name: Run the UI tests
|
||||
# A suite that skips everything still exits 0 -- that is how thirteen
|
||||
# tests came to look like a pass while proving nothing. So the skip
|
||||
# count is asserted, not just the exit status.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set -o pipefail
|
||||
npm run test:ci 2>&1 | tee "$RUNNER_TEMP/ui.log"
|
||||
skipped=$(grep -E '^. skipped ' "$RUNNER_TEMP/ui.log" | grep -oE '[0-9]+$' | head -1)
|
||||
echo "skipped: ${skipped:-unknown}"
|
||||
test "${skipped:-1}" -eq 0
|
||||
|
||||
shell:
|
||||
name: shell (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-22.04, macos-15, windows-latest]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/praisonai-desktop/src-tauri
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src/praisonai-desktop/src-tauri
|
||||
# The Windows cache is large and slow enough to cost more than it saves.
|
||||
save-if: ${{ runner.os != 'Windows' }}
|
||||
- name: Install the Linux build dependencies
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: .
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
|
||||
libxdo-dev libssl-dev patchelf
|
||||
- name: Test
|
||||
# Debug info is the bulk of a Tauri target directory and nothing here
|
||||
# reads a backtrace, so building without it keeps the runner's disk
|
||||
# from filling on the Windows leg.
|
||||
run: cargo test --config 'profile.test.debug=false' --config 'profile.dev.debug=false'
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
@@ -101,8 +101,9 @@ jobs:
|
||||
${{ github.workspace }}/src/praisonai-agents:${{ github.workspace }}/src/praisonai-code
|
||||
- shard: train
|
||||
workdir: src/praisonai-train
|
||||
# tests/unit/data/ (96 tests) was collected by no workflow at all.
|
||||
paths: >-
|
||||
tests/unit/train/
|
||||
tests/unit/train/ tests/unit/data/
|
||||
extra_ignore: ""
|
||||
pythonpath: >-
|
||||
${{ github.workspace }}/src/praisonai-agents:${{ github.workspace }}/src/praisonai-code
|
||||
|
||||
@@ -7,8 +7,21 @@ Thank you for your interest! Here's how to get started:
|
||||
3. Make your changes
|
||||
4. Submit a pull request
|
||||
|
||||
## Where to report issues (hub intake)
|
||||
|
||||
| Topic | Report here | Where fixes land |
|
||||
|-------|-------------|------------------|
|
||||
| Python SDK, CLI, wrapper | **This repo** ([PraisonAI](https://github.com/MervinPraison/PraisonAI)) | `src/praisonai-agents/`, `src/praisonai/` |
|
||||
| TypeScript / JavaScript SDK (`npm install praisonai`) | **This repo** (preferred) or [praisonai-js](https://github.com/MervinPraison/praisonai-js) | [praisonai-js](https://github.com/MervinPraison/praisonai-js) → synced to `src/praisonai-ts/` |
|
||||
| Cross-language parity | **This repo** | Often both Python and praisonai-js PRs |
|
||||
| Agent-callable tools | **This repo** | [PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools) |
|
||||
| Lifecycle plugins (tracing, hooks, guardrails) | **This repo** | [PraisonAI-Plugins](https://github.com/MervinPraison/PraisonAI-Plugins) |
|
||||
|
||||
You do **not** need to re-file TypeScript issues on praisonai-js — report on PraisonAI and automation routes fixes to the canonical TS repo.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Follow existing code style
|
||||
- Add tests for new features
|
||||
- Update documentation as needed
|
||||
- TypeScript changes: implement in [praisonai-js](https://github.com/MervinPraison/praisonai-js), then run **Sync to PraisonAI Monorepo** workflow to update `src/praisonai-ts/`
|
||||
+3
-1
@@ -8,7 +8,9 @@ PraisonAI takes security seriously. We appreciate responsible disclosure from re
|
||||
|---------|----------|-------------|
|
||||
| `praisonaiagents` | [PyPI](https://pypi.org/project/praisonaiagents/) | `src/praisonai-agents/` |
|
||||
| `praisonai` | [PyPI](https://pypi.org/project/praisonai/) | `src/praisonai/` |
|
||||
| `praisonai` | [npm](https://www.npmjs.com/package/praisonai) | `src/praisonai-ts/` |
|
||||
| `praisonai` | [npm](https://www.npmjs.com/package/praisonai) | [praisonai-js](https://github.com/MervinPraison/praisonai-js) (canonical); mirrored at `src/praisonai-ts/` |
|
||||
|
||||
Report npm/TypeScript vulnerabilities on **PraisonAI** (hub intake) or [praisonai-js](https://github.com/MervinPraison/praisonai-js). Fixes land in praisonai-js first.
|
||||
|
||||
Report issues against the **specific package** where the vulnerability exists. A platform-only issue does not necessarily affect the core SDK, and vice versa.
|
||||
|
||||
|
||||
@@ -240,10 +240,12 @@ from praisonaiagents import Memory, MemoryBackend, MemoryConfig
|
||||
|
||||
Methods:
|
||||
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">acommit_memory_batch</a>(writes: List[tuple]) -> None</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_cache_optimized_context</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_cache_boundary: bool = True, include_in_output: Optional[bool] = None) -> Dict[str, str]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_context_for_task</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_in_output: Optional[bool] = None) -> str</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">calculate_quality_metrics</a>(output: str, expected_output: str, llm: Optional[str] = None, custom_prompt: Optional[str] = None) -> Dict[str, float]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">close_connections</a>()</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">commit_memory_batch</a>(writes: List[tuple]) -> None</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">compute_quality_score</a>(completeness: float, relevance: float, clarity: float, accuracy: float, weights: Dict[str, float] = None) -> float</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_long_term</a>(memory_id: str) -> bool</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_memories</a>(memory_ids: List[str]) -> int</code>
|
||||
@@ -253,6 +255,7 @@ Methods:
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">finalize_task_output</a>(content: str, agent_name: str, quality_score: float, threshold: float = 0.7, metrics: Dict[str, Any] = None, task_id: str = None)</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">forget</a>(**kwargs) -> int</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_all_memories</a>() -> List[Dict[str, Any]]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_context</a>(query: Optional[str] = None, **kwargs) -> str</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_learn_context</a>() -> str</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">learn</a>()</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">recall</a>(query: str, **kwargs) -> List[Dict[str, Any]]</code>
|
||||
|
||||
@@ -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.0" \
|
||||
"praisonai>=4.7.1" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.7.0" \
|
||||
"praisonai>=4.7.1" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.7.0" \
|
||||
"praisonai>=4.7.1" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -178,19 +178,19 @@ def example_url_parsing():
|
||||
print("\n=== Example 9: URL Parsing ===")
|
||||
|
||||
# Memory supports URL connection strings
|
||||
# Supported: postgresql://, redis://, sqlite:///, mongodb://
|
||||
# Supported schemes: sqlite://, mongodb://, mongodb+srv://
|
||||
agent = Agent(
|
||||
instructions="You are a database-backed assistant",
|
||||
memory="postgresql://postgres:password@localhost:5432/praisonai",
|
||||
memory="mongodb://user:password@localhost:27017/praisonai",
|
||||
)
|
||||
print("Memory URL parsed: memory enabled")
|
||||
|
||||
# Also works with presets
|
||||
agent2 = Agent(
|
||||
instructions="You are a redis-backed assistant",
|
||||
memory="redis", # Preset name
|
||||
instructions="You are a sqlite-backed assistant",
|
||||
memory="sqlite", # Preset name
|
||||
)
|
||||
print("Memory preset 'redis': configured")
|
||||
print("Memory preset 'sqlite': configured")
|
||||
|
||||
|
||||
def example_knowledge_sources():
|
||||
|
||||
@@ -8,15 +8,15 @@ agent = Agent(
|
||||
)
|
||||
|
||||
# With preset
|
||||
agent_redis = Agent(
|
||||
agent_sqlite = Agent(
|
||||
instructions="You are a helpful assistant.",
|
||||
memory="redis", # Uses redis preset
|
||||
memory="sqlite", # Uses sqlite preset
|
||||
)
|
||||
|
||||
# With URL
|
||||
agent_postgres = Agent(
|
||||
agent_mongo = Agent(
|
||||
instructions="You are a helpful assistant.",
|
||||
memory="postgresql://localhost/mydb", # URL auto-detected
|
||||
memory="mongodb://localhost/mydb", # URL auto-detected
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
# MCP Server Examples
|
||||
|
||||
## Install (SHA-256)
|
||||
|
||||
Pin GitHub Release **v0.6.0** and verify `SHA256SUMS`. Website `install.sh` / `install.ps1` abort on mismatch.
|
||||
|
||||
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/releases/tag/v0.6.0
|
||||
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/blob/main/docs/PINNED-INSTALL.md
|
||||
|
||||
```
|
||||
96cef605d3e030ccef99d27ea6240e0d3b668dd045e6b5b9e585c9fd03c6ef23 gbr-agent-darwin-amd64
|
||||
de7e065ef2cf6877b3b2cd04679a67b627f876337f529247e236204543e4062c gbr-agent-darwin-arm64
|
||||
a50a5c41993e6531a3b477eb409ccc845212bf541384dc803061c80657f86719 gbr-agent-linux-amd64
|
||||
5bfd22c7110234942c4c02ff8154b836d0af45a9422c178a4f52010187d40061 gbr-agent-linux-arm64
|
||||
f773b89fd31310172b756e0593e0f3b2382b0a3440af2a7d0a8b3073b0c23e27 gbr-agent-windows-amd64.exe
|
||||
8fb9efcbc7e2ac91c11964944bf0f45e31bb23f4356d9dcb4b305d7cb9b0fe8c gbr-agent-windows-arm64.exe
|
||||
```
|
||||
|
||||
```bash
|
||||
VER=v0.6.0
|
||||
BASE=https://github.com/LinespottingOrg/GrokBuildRemote-Agents/releases/download/$VER
|
||||
# swap darwin-arm64 for your OS/arch
|
||||
curl -fsSL -o gbr-agent-darwin-arm64 "$BASE/gbr-agent-darwin-arm64"
|
||||
curl -fsSL -o SHA256SUMS "$BASE/SHA256SUMS"
|
||||
shasum -a 256 -c SHA256SUMS --ignore-missing
|
||||
gbr-agent pair && gbr-agent run
|
||||
```
|
||||
|
||||
|
||||
Examples demonstrating the MCP Server v2 features per MCP Protocol Version 2025-11-25.
|
||||
|
||||
## Features Demonstrated
|
||||
@@ -130,3 +157,19 @@ These examples comply with MCP Protocol Version 2025-11-25:
|
||||
- `destructiveHint`: true
|
||||
- `idempotentHint`: false
|
||||
- `openWorldHint`: true
|
||||
|
||||
|
||||
## Build Remote Agent (`gbr_pair.py`)
|
||||
|
||||
Pair a phone running [Build Remote Agent](https://grokbuildremote.com/) to a PraisonAI agent via MCP stdio `gbr-mcp` (protocol `gbr/1`). Requires `gbr-agent pair && gbr-agent run`. Phone is spectator. Never put mailbox keys in the example.
|
||||
|
||||
```bash
|
||||
python gbr_pair.py
|
||||
```
|
||||
|
||||
## What the phone sees
|
||||
|
||||
**Terminal windows** on this PC (machine-wide mailbox). Not headless OpenCode / CodeNomad sidecar / Electron. `:8788` in a sidecar is Bot API JSON, not a transcript.
|
||||
|
||||
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/blob/main/docs/WHAT-THE-PHONE-SEES.md
|
||||
https://grokbuildremote.com/integrations.html
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Build Remote Agent pairing example.
|
||||
|
||||
Pair a phone (Build Remote Agent) to this PraisonAI session through the
|
||||
free MIT gbr-agent. Protocol gbr/1. Phone is spectator + veto.
|
||||
|
||||
Requirements:
|
||||
- gbr-agent v0.6.0+ (`gbr-agent pair && gbr-agent run`)
|
||||
- GrokBuildRemote-Agents cloned; `npm install` in mcp/gbr-mcp
|
||||
- pip install praisonaiagents[mcp]
|
||||
|
||||
Usage:
|
||||
python gbr_pair.py
|
||||
|
||||
Never put mailbox keys in this file. Phone Settings → Bot API is the
|
||||
only place the relay key is copied.
|
||||
|
||||
Independent product by Linespotting AB. Not affiliated with xAI or SpaceX.
|
||||
https://grokbuildremote.com/
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, MCP
|
||||
|
||||
# Clone https://github.com/LinespottingOrg/GrokBuildRemote-Agents.git
|
||||
|
||||
## Install (SHA-256)
|
||||
|
||||
Pin GitHub Release **v0.6.0** and verify `SHA256SUMS`. Website `install.sh` / `install.ps1` abort on mismatch.
|
||||
|
||||
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/releases/tag/v0.6.0
|
||||
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/blob/main/docs/PINNED-INSTALL.md
|
||||
|
||||
```
|
||||
96cef605d3e030ccef99d27ea6240e0d3b668dd045e6b5b9e585c9fd03c6ef23 gbr-agent-darwin-amd64
|
||||
de7e065ef2cf6877b3b2cd04679a67b627f876337f529247e236204543e4062c gbr-agent-darwin-arm64
|
||||
a50a5c41993e6531a3b477eb409ccc845212bf541384dc803061c80657f86719 gbr-agent-linux-amd64
|
||||
5bfd22c7110234942c4c02ff8154b836d0af45a9422c178a4f52010187d40061 gbr-agent-linux-arm64
|
||||
f773b89fd31310172b756e0593e0f3b2382b0a3440af2a7d0a8b3073b0c23e27 gbr-agent-windows-amd64.exe
|
||||
8fb9efcbc7e2ac91c11964944bf0f45e31bb23f4356d9dcb4b305d7cb9b0fe8c gbr-agent-windows-arm64.exe
|
||||
```
|
||||
|
||||
```bash
|
||||
VER=v0.6.0
|
||||
BASE=https://github.com/LinespottingOrg/GrokBuildRemote-Agents/releases/download/$VER
|
||||
# swap darwin-arm64 for your OS/arch
|
||||
curl -fsSL -o gbr-agent-darwin-arm64 "$BASE/gbr-agent-darwin-arm64"
|
||||
curl -fsSL -o SHA256SUMS "$BASE/SHA256SUMS"
|
||||
shasum -a 256 -c SHA256SUMS --ignore-missing
|
||||
gbr-agent pair && gbr-agent run
|
||||
```
|
||||
|
||||
# then point args at mcp/gbr-mcp/bin/gbr-mcp.js (loopback stdio).
|
||||
gbr = Agent(
|
||||
instructions=(
|
||||
"You can attach a phone spectator via Build Remote Agent. "
|
||||
"Use GBR tools only to diagnose or attach the local Bot API "
|
||||
"at 127.0.0.1:8788. Do not request mailbox keys."
|
||||
),
|
||||
llm="gpt-4o-mini",
|
||||
tools=MCP(
|
||||
"node",
|
||||
args=["GrokBuildRemote-Agents/mcp/gbr-mcp/bin/gbr-mcp.js"],
|
||||
),
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
gbr.start(
|
||||
"Check Build Remote Agent health on 127.0.0.1:8788 and list sessions."
|
||||
)
|
||||
|
||||
## What the phone sees
|
||||
|
||||
**Terminal windows** on this PC (machine-wide mailbox). Not headless OpenCode / CodeNomad sidecar / Electron. `:8788` in a sidecar is Bot API JSON, not a transcript.
|
||||
|
||||
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/blob/main/docs/WHAT-THE-PHONE-SEES.md
|
||||
https://grokbuildremote.com/integrations.html
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
HOL Guard Tool Policy Example - PraisonAI Agents
|
||||
|
||||
Demonstrates using HOL Guard (an optional, external runtime security engine)
|
||||
at the existing `wrap_tool_call` middleware boundary to inspect command-bearing
|
||||
tool calls *before* they execute.
|
||||
|
||||
HOL Guard is installed and versioned separately. Its side-effect-free CLI can
|
||||
classify a shell command before execution:
|
||||
|
||||
hol-guard command test '<command>' --json
|
||||
|
||||
Fail-closed policy: the wrapped tool only runs when Guard returns an explicit
|
||||
allow/benign classification. Review/block/unknown verdicts, malformed output,
|
||||
timeouts, or CLI failures all short-circuit the call so the tool never runs.
|
||||
|
||||
pip install hol-guard # optional, external dependency
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from praisonaiagents import Agent, tool
|
||||
from praisonaiagents.hooks import wrap_tool_call, ToolRequest, ToolResponse
|
||||
|
||||
|
||||
COMMAND_BEARING_TOOLS = {"run_shell"}
|
||||
GUARD_TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
@tool
|
||||
def run_shell(command: str) -> str:
|
||||
"""Run a shell command and return its output."""
|
||||
result = subprocess.run(
|
||||
command, shell=True, capture_output=True, text=True
|
||||
)
|
||||
return result.stdout or result.stderr
|
||||
|
||||
|
||||
def _inspect_with_hol_guard(command: str) -> bool:
|
||||
"""Return True only if HOL Guard explicitly allows the command.
|
||||
|
||||
Fails closed (returns False) on any ambiguity: missing CLI, non-zero exit,
|
||||
timeout, malformed JSON, or a non-allow verdict.
|
||||
"""
|
||||
if not shutil.which("hol-guard"):
|
||||
return False
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["hol-guard", "command", "test", command, "--json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=GUARD_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return False
|
||||
|
||||
if completed.returncode != 0:
|
||||
return False
|
||||
|
||||
try:
|
||||
verdict = json.loads(completed.stdout)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return False
|
||||
|
||||
if not isinstance(verdict, dict):
|
||||
return False
|
||||
|
||||
decision = str(verdict.get("decision", "")).lower()
|
||||
return decision in ("allow", "benign")
|
||||
|
||||
|
||||
@wrap_tool_call
|
||||
def hol_guard_tool_policy(request: ToolRequest, call_next):
|
||||
"""Gate command-bearing tools through HOL Guard before execution."""
|
||||
if request.tool_name not in COMMAND_BEARING_TOOLS:
|
||||
return call_next(request)
|
||||
|
||||
command = request.arguments.get("command", "")
|
||||
|
||||
if _inspect_with_hol_guard(command):
|
||||
return call_next(request)
|
||||
|
||||
blocked_message = "Blocked by HOL Guard: command was not explicitly allowed."
|
||||
# Surface the denial in `result` too: the agent tool executor returns
|
||||
# `ToolResponse.result` to the model and discards `error`, so a denial with
|
||||
# only `error` set would reach the model as a null result. Keeping `error`
|
||||
# populated preserves the signal for after_tool hooks and logging.
|
||||
return ToolResponse(
|
||||
tool_name=request.tool_name,
|
||||
result=blocked_message,
|
||||
error=blocked_message,
|
||||
context=request.context,
|
||||
)
|
||||
|
||||
|
||||
agent = Agent(
|
||||
name="GuardedShellBot",
|
||||
instructions="You run shell commands only when they are safe.",
|
||||
tools=[run_shell],
|
||||
hooks=[hol_guard_tool_policy],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("HOL Guard tool policy registered on 'run_shell'.")
|
||||
print("Commands are only executed when Guard returns an explicit allow.")
|
||||
print("\n✓ HOL Guard example complete")
|
||||
+12
-11
@@ -1,16 +1,18 @@
|
||||
"""
|
||||
Qdrant Knowledge Store - Agent-First Example
|
||||
Chroma Knowledge Store - Agent-First Example
|
||||
|
||||
Demonstrates using Qdrant for knowledge-based RAG with an Agent.
|
||||
Demonstrates using Chroma (a shipped vector store) for knowledge-based RAG
|
||||
with an Agent.
|
||||
|
||||
To use a provider that is not shipped (e.g. Qdrant), register an adapter
|
||||
first with ``register_knowledge_adapter("qdrant", MyQdrantAdapter)`` and then
|
||||
pass ``"provider": "qdrant"``.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
|
||||
Docker Setup:
|
||||
docker run -d --name praison-qdrant -p 6333:6333 qdrant/qdrant
|
||||
|
||||
Run:
|
||||
python knowledge_qdrant.py
|
||||
python knowledge_chroma.py
|
||||
|
||||
Expected Output:
|
||||
Agent responds using knowledge from documents
|
||||
@@ -44,17 +46,16 @@ doc_path = os.path.join(temp_dir, "guide.txt")
|
||||
with open(doc_path, "w") as f:
|
||||
f.write(sample_doc)
|
||||
|
||||
print("=== Qdrant Knowledge Store Demo (Agent-First) ===")
|
||||
print("=== Chroma Knowledge Store Demo (Agent-First) ===")
|
||||
|
||||
# Agent-first approach: use knowledge parameter with Qdrant
|
||||
# Agent-first approach: use knowledge parameter with a shipped vector store
|
||||
agent = Agent(
|
||||
name="KnowledgeAssistant",
|
||||
instructions="You are a helpful assistant with access to technical documentation.",
|
||||
knowledge={
|
||||
"sources": [doc_path],
|
||||
"vector_store": {
|
||||
"provider": "qdrant",
|
||||
"url": "http://localhost:6333",
|
||||
"provider": "chroma",
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -70,4 +71,4 @@ print("\n=== Demo Complete ===")
|
||||
|
||||
# --- Advanced: Direct Store Usage ---
|
||||
# from praisonai.persistence.factory import create_knowledge_store
|
||||
# store = create_knowledge_store("qdrant", url="http://localhost:6333")
|
||||
# store = create_knowledge_store("chroma")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Basic example of using EvoLink.AI with PraisonAI.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
api_key = os.getenv("EVOLINK_API_KEY")
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
"EVOLINK_API_KEY is not set. Set it before running this example: "
|
||||
"export EVOLINK_API_KEY=your_api_key"
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant",
|
||||
llm={
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": api_key,
|
||||
"base_url": "https://direct.evolink.ai/v1",
|
||||
},
|
||||
)
|
||||
|
||||
response = agent.start("Hello! Share one practical AI automation idea.")
|
||||
print(response)
|
||||
|
||||
coding_task = """
|
||||
Write a Python function that validates whether a string is a palindrome.
|
||||
Include a short docstring and ignore spaces and capitalization.
|
||||
"""
|
||||
|
||||
response = agent.start(coding_task)
|
||||
print(response)
|
||||
@@ -1,17 +1,25 @@
|
||||
"""Qdrant Vector Store - Agent-First Example (requires Docker)
|
||||
"""Qdrant Vector Store - Agent-First Example
|
||||
|
||||
Docker: docker run -d --name qdrant -p 6333:6333 qdrant/qdrant
|
||||
Qdrant is not a shipped adapter. Register one first, then use it via the
|
||||
``knowledge`` parameter. If no adapter is registered this example skips
|
||||
cleanly instead of crashing.
|
||||
|
||||
from praisonaiagents.knowledge.adapters import register_knowledge_adapter
|
||||
register_knowledge_adapter("qdrant", MyQdrantAdapter)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.knowledge.adapters import list_knowledge_adapters
|
||||
|
||||
# Agent-first approach: use knowledge parameter with Qdrant
|
||||
url = os.getenv("QDRANT_URL", "http://localhost:6333")
|
||||
if "qdrant" not in list_knowledge_adapters():
|
||||
print("SKIPPED: Qdrant - no 'qdrant' adapter registered")
|
||||
sys.exit(0)
|
||||
|
||||
# Agent-first approach: use knowledge parameter with a registered Qdrant adapter
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
instructions="You are a helpful assistant with access to documents.",
|
||||
knowledge={"sources": ["./docs/guide.pdf"], "vector_store": {"provider": "qdrant", "url": url}}
|
||||
knowledge={"sources": ["./docs/guide.pdf"], "vector_store": {"provider": "qdrant"}}
|
||||
)
|
||||
|
||||
# Chat - agent uses knowledge for RAG
|
||||
@@ -19,7 +27,3 @@ response = agent.chat("What information do you have?")
|
||||
print(f"Response: {response}")
|
||||
|
||||
print("PASSED: Qdrant with Agent")
|
||||
|
||||
# --- Advanced: Direct Store Usage ---
|
||||
# from praisonai.persistence import create_knowledge_store
|
||||
# store = create_knowledge_store("qdrant", url=url)
|
||||
@@ -18,45 +18,45 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 22 | 53 | 16965 |
|
||||
| ✅ Agent | 22 | 53 | 17107 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
|
||||
| ✅ Approval | 1 | 5 | 2658 |
|
||||
| ✅ Approval | 1 | 5 | 2764 |
|
||||
| ✅ Audio | 2 | 12 | 1000 |
|
||||
| ✅ Auto Generation | 5 | 11 | 3542 |
|
||||
| ✅ Autonomy | 3 | 7 | 2763 |
|
||||
| ✅ Bots | 7 | 31 | 13403 |
|
||||
| ✅ Autonomy | 3 | 7 | 2790 |
|
||||
| ✅ Bots | 7 | 32 | 13616 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 122 | 36654 |
|
||||
| ✅ CLI | 5 | 122 | 36991 |
|
||||
| ✅ Chunking | 2 | 2 | 420 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 2 | 14 | 4851 |
|
||||
| ✅ Conditions | 1 | 3 | 1321 |
|
||||
| ✅ Configuration | 3 | 8 | 3140 |
|
||||
| ✅ Context Management | 16 | 36 | 13890 |
|
||||
| ✅ Code Execution | 2 | 14 | 4855 |
|
||||
| ✅ Conditions | 1 | 3 | 1347 |
|
||||
| ✅ Configuration | 3 | 8 | 3173 |
|
||||
| ✅ Context Management | 16 | 36 | 13988 |
|
||||
| ✅ Deep Research | 8 | 2 | 587 |
|
||||
| ✅ Display | 6 | 3 | 741 |
|
||||
| ✅ Embeddings | 6 | 23 | 2049 |
|
||||
| ✅ Evaluation | 1 | 7 | 2905 |
|
||||
| ✅ Evaluation | 1 | 7 | 2920 |
|
||||
| ✅ Events | 1 | 2 | 749 |
|
||||
| ✅ Execution | 3 | 4 | 1586 |
|
||||
| ✅ Failover | 2 | 1 | 432 |
|
||||
| ✅ Execution | 3 | 4 | 1606 |
|
||||
| ✅ Failover | 2 | 1 | 452 |
|
||||
| ✅ Files | 2 | 7 | 2275 |
|
||||
| ✅ Flow | 1 | 3 | 788 |
|
||||
| ✅ Gateway | 7 | 78 | 28577 |
|
||||
| ✅ Guardrails | 4 | 4 | 2205 |
|
||||
| ✅ Handoffs | 11 | 6 | 2567 |
|
||||
| ✅ Hooks | 2 | 9 | 4488 |
|
||||
| ✅ Gateway | 7 | 82 | 30269 |
|
||||
| ✅ Guardrails | 4 | 4 | 2228 |
|
||||
| ✅ Handoffs | 11 | 6 | 2626 |
|
||||
| ✅ Hooks | 2 | 9 | 4509 |
|
||||
| ✅ Image | 1 | 11 | 1255 |
|
||||
| ✅ Knowledge | 4 | 16 | 4942 |
|
||||
| ✅ LLM | 3 | 15 | 5296 |
|
||||
| ✅ Knowledge | 4 | 16 | 4967 |
|
||||
| ✅ LLM | 3 | 15 | 5372 |
|
||||
| ✅ Loops | 4 | 5 | 1475 |
|
||||
| ✅ MCP | 1 | 61 | 14135 |
|
||||
| ✅ Memory | 6 | 18 | 7133 |
|
||||
| ✅ MCP | 1 | 61 | 14160 |
|
||||
| ✅ Memory | 6 | 18 | 7193 |
|
||||
| ✅ OCR | 2 | 1 | 237 |
|
||||
| ✅ Observability | 2 | 23 | 2814 |
|
||||
| ✅ Observability | 2 | 23 | 2811 |
|
||||
| ✅ Optimizer | 1 | 2 | 792 |
|
||||
| ✅ Output | 3 | 5 | 1246 |
|
||||
| ✅ Output | 3 | 5 | 1265 |
|
||||
| ✅ Parallel Execution | 3 | 2 | 497 |
|
||||
| ✅ Planning | 6 | 6 | 1605 |
|
||||
| ✅ Plugins | 8 | 5 | 3120 |
|
||||
@@ -65,29 +65,29 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| ✅ Query | 1 | 2 | 736 |
|
||||
| ✅ RAG | 5 | 15 | 3118 |
|
||||
| ✅ Realtime | 2 | 5 | 668 |
|
||||
| ✅ Reflection | 3 | 3 | 683 |
|
||||
| ✅ Retrieval | 2 | 5 | 1054 |
|
||||
| ✅ Reflection | 3 | 3 | 730 |
|
||||
| ✅ Retrieval | 2 | 5 | 1042 |
|
||||
| ✅ Routing | 1 | 2 | 430 |
|
||||
| ✅ Sandbox | 5 | 7 | 3483 |
|
||||
| ✅ Security | 1 | 3 | 3415 |
|
||||
| ✅ Sessions | 4 | 19 | 7576 |
|
||||
| ✅ Sandbox | 5 | 7 | 3556 |
|
||||
| ✅ Security | 1 | 3 | 3529 |
|
||||
| ✅ Sessions | 4 | 19 | 7644 |
|
||||
| ✅ Skills | 6 | 15 | 5638 |
|
||||
| ✅ Tasks | 2 | 6 | 2819 |
|
||||
| ✅ Telemetry | 1 | 2 | 604 |
|
||||
| ✅ Tasks | 2 | 6 | 2897 |
|
||||
| ✅ Telemetry | 1 | 2 | 648 |
|
||||
| ✅ Templates | 1 | 8 | 1663 |
|
||||
| ✅ Tools | 12 | 144 | 42732 |
|
||||
| ✅ Tools | 12 | 144 | 43251 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Vector Store | 1 | 12 | 1215 |
|
||||
| ✅ Video | 2 | 6 | 649 |
|
||||
| ✅ Vision | 2 | 1 | 329 |
|
||||
| ✅ Web | 3 | 9 | 2484 |
|
||||
| ✅ Workflows | 5 | 17 | 7049 |
|
||||
| ✅ Web | 3 | 9 | 2487 |
|
||||
| ✅ Workflows | 5 | 18 | 7406 |
|
||||
|
||||
## Documentation Without Features
|
||||
|
||||
These docs exist but don't match any implemented feature category:
|
||||
|
||||
- ℹ️ Database (42 docs, 6766 lines)
|
||||
- ℹ️ Database (42 docs, 6965 lines)
|
||||
- ℹ️ Documents (1 docs, 787 lines)
|
||||
|
||||
---
|
||||
|
||||
@@ -92,20 +92,6 @@ from ._lazy import lazy_import, create_lazy_getattr_with_fallback
|
||||
# Thread-safe cache for lazy-loaded values
|
||||
_lazy_cache = {}
|
||||
|
||||
# Backward compatibility: _get_lazy_cache function for tests
|
||||
import threading
|
||||
_lazy_cache_local = threading.local()
|
||||
|
||||
def _get_lazy_cache():
|
||||
"""Get thread-local lazy cache dict. Thread-safe for concurrent access.
|
||||
|
||||
Note: This is kept for backward compatibility with tests.
|
||||
The main lazy loading now uses the centralized _lazy.py utility.
|
||||
"""
|
||||
if not hasattr(_lazy_cache_local, 'cache'):
|
||||
_lazy_cache_local.cache = {}
|
||||
return _lazy_cache_local.cache
|
||||
|
||||
# ============================================================================
|
||||
# LAZY IMPORT MAPPING
|
||||
# ============================================================================
|
||||
|
||||
@@ -27,7 +27,7 @@ SUPPRESSED_PATTERNS = [
|
||||
"The `dict` method is deprecated; use `model_dump` instead",
|
||||
"Pydantic serializer warnings",
|
||||
"PydanticSerializationUnexpectedValue",
|
||||
"Expected", # Catches all "Expected N fields but got M" patterns
|
||||
"fields but got", # litellm/pydantic "Expected N fields but got M"
|
||||
"Expected `StreamingChoices` but got `Choices`",
|
||||
"serialized value may not be as expected",
|
||||
"Mixing V1 models and V2 models",
|
||||
|
||||
@@ -197,75 +197,6 @@ def _validate_preset_params(params):
|
||||
validate_preset_string(name, value, _PRESET_STRING_PARAMS[name]())
|
||||
|
||||
|
||||
class ServerRegistry:
|
||||
"""Registry for API server state per-port."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._server_started = {} # Dict of port -> started boolean
|
||||
self._registered_agents = {} # Dict of port -> Dict of path -> agent_id
|
||||
self._shared_apps = {} # Dict of port -> FastAPI app
|
||||
|
||||
# Class-level lock for thread-safe singleton creation
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_default_instance():
|
||||
"""Get default global registry for backward compatibility."""
|
||||
if not hasattr(ServerRegistry, '_default_instance'):
|
||||
# Double-checked locking pattern with shared class-level lock
|
||||
with ServerRegistry._instance_lock:
|
||||
if not hasattr(ServerRegistry, '_default_instance'):
|
||||
ServerRegistry._default_instance = ServerRegistry()
|
||||
return ServerRegistry._default_instance
|
||||
|
||||
def is_server_started(self, port: int) -> bool:
|
||||
with self._lock:
|
||||
return self._server_started.get(port, False)
|
||||
|
||||
def set_server_started(self, port: int, started: bool) -> None:
|
||||
with self._lock:
|
||||
self._server_started[port] = started
|
||||
|
||||
def get_shared_app(self, port: int):
|
||||
with self._lock:
|
||||
return self._shared_apps.get(port)
|
||||
|
||||
def set_shared_app(self, port: int, app) -> None:
|
||||
with self._lock:
|
||||
self._shared_apps[port] = app
|
||||
|
||||
def register_agent(self, port: int, path: str, agent_id: str) -> None:
|
||||
with self._lock:
|
||||
if port not in self._registered_agents:
|
||||
self._registered_agents[port] = {}
|
||||
self._registered_agents[port][path] = agent_id
|
||||
|
||||
def get_registered_agents(self, port: int) -> dict:
|
||||
with self._lock:
|
||||
return self._registered_agents.get(port, {}).copy()
|
||||
|
||||
def cleanup_agent_registrations(self, agent_id: str) -> None:
|
||||
"""Remove all registrations for an agent ID and clean empty port state."""
|
||||
with self._lock:
|
||||
ports_to_clean = []
|
||||
for port, path_dict in self._registered_agents.items():
|
||||
paths_to_remove = [path for path, registered_id in path_dict.items() if registered_id == agent_id]
|
||||
for path in paths_to_remove:
|
||||
del path_dict[path]
|
||||
if not path_dict:
|
||||
ports_to_clean.append(port)
|
||||
|
||||
for port in ports_to_clean:
|
||||
self._registered_agents.pop(port, None)
|
||||
self._server_started.pop(port, None)
|
||||
|
||||
# Backward compatibility - use default instance
|
||||
def _get_default_server_registry() -> ServerRegistry:
|
||||
return ServerRegistry.get_default_instance()
|
||||
|
||||
# Don't import FastAPI dependencies here - use lazy loading instead
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -870,9 +801,28 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
_cloned_fallback_models = legacy_kwargs.pop("fallback_models", None)
|
||||
_unknown = set(legacy_kwargs) - _legacy_defaults.keys()
|
||||
if _unknown:
|
||||
# Unknown kwargs are rejected rather than swallowed, so a typo can
|
||||
# never silently disable the behaviour it was meant to configure.
|
||||
# Some names are near-universal in this ecosystem, though -- and
|
||||
# `verbose` is accepted by six sibling classes here -- so name the
|
||||
# supported alternative instead of leaving the user to search a
|
||||
# 41-parameter signature for something that is not in it.
|
||||
_HINTS = {
|
||||
"verbose": (
|
||||
"verbosity moved into output=; use output='verbose' "
|
||||
"(or output=OutputConfig(verbose=True))."
|
||||
),
|
||||
"stream": (
|
||||
"streaming moved into output=; use "
|
||||
"output=OutputConfig(stream=True)."
|
||||
),
|
||||
}
|
||||
_hint = "".join(
|
||||
f"\n {name}: {_HINTS[name]}" for name in sorted(_unknown) if name in _HINTS
|
||||
)
|
||||
raise TypeError(
|
||||
f"Agent.__init__() got unexpected keyword argument(s): "
|
||||
f"{', '.join(sorted(_unknown))}"
|
||||
f"{', '.join(sorted(_unknown))}{_hint}"
|
||||
)
|
||||
allow_delegation = legacy_kwargs.get("allow_delegation", _legacy_defaults["allow_delegation"])
|
||||
allow_code_execution = legacy_kwargs.get("allow_code_execution", _legacy_defaults["allow_code_execution"])
|
||||
@@ -1856,6 +1806,12 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
elif isinstance(_guardrails_config, str):
|
||||
# LLM validator prompt
|
||||
guardrail = _guardrails_config
|
||||
|
||||
# Persist the resolved value so clone_for_channel() can forward it.
|
||||
# Without this the attribute never exists, its getattr(..., None) in
|
||||
# clone_for_channel() yields None, and every served clone runs with no
|
||||
# output guardrail at all (one clone per gateway channel / HTTP call).
|
||||
self._guardrails_config = _guardrails_config
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Resolve WEB param - FAST PATH
|
||||
@@ -2461,6 +2417,11 @@ Your Goal: {self.goal}
|
||||
# ApprovalConfig = full control (all_tools, timeout, etc.)
|
||||
# str = permission preset ("safe", "read_only", "full")
|
||||
from ..approval.protocols import ApprovalConfig
|
||||
# Persist the approval kwarg exactly as given: it round-trips through the
|
||||
# constructor (str | bool | ApprovalConfig | dict | backend object).
|
||||
# clone_for_channel() reads this; without it the attribute never exists
|
||||
# and every served clone loses its approval policy.
|
||||
self._approval_config = approval
|
||||
self._perm_deny = frozenset() # Permission tier deny set (empty = no denials)
|
||||
self._perm_allow = None # Permission tier allow set (None = allow all)
|
||||
# Optional pattern-based PermissionManager (rules from
|
||||
@@ -2563,6 +2524,17 @@ Your Goal: {self.goal}
|
||||
# Store permissions if provided (for CI-safe declarative policies)
|
||||
self._approval_permissions = getattr(approval, 'permissions', None)
|
||||
self._permission_mode = getattr(approval, 'permission_mode', None)
|
||||
# Configuring approval must never be a WEAKER posture than passing
|
||||
# nothing. When no declarative ``permissions`` policy is supplied,
|
||||
# fall back to the same env-driven default deny set the bare
|
||||
# ``approval is None`` path uses, so e.g. ``ApprovalConfig(timeout=30)``
|
||||
# keeps the default denials (execute_command, delete_file, …) instead
|
||||
# of silently dropping all of them. ``is None`` (not falsiness) so an
|
||||
# explicit empty policy (``permissions={}``) is still an intentional
|
||||
# policy that owns denial — only a genuinely absent policy inherits
|
||||
# the env-driven default deny set.
|
||||
if self._approval_permissions is None:
|
||||
self._perm_deny = self._resolve_default_deny_set()
|
||||
elif isinstance(approval, dict):
|
||||
# Dict config: convert to ApprovalConfig
|
||||
approval_config = ApprovalConfig(**approval)
|
||||
@@ -2571,6 +2543,12 @@ Your Goal: {self.goal}
|
||||
self._approval_timeout = approval_config.timeout
|
||||
self._approval_permissions = getattr(approval_config, 'permissions', None)
|
||||
self._permission_mode = getattr(approval_config, 'permission_mode', None)
|
||||
# See the ApprovalConfig branch above: never weaken the default
|
||||
# posture just because a config object was passed. ``is None`` so an
|
||||
# explicit empty policy (``approval={"permissions": {}}``) still owns
|
||||
# denial rather than inheriting the default deny set.
|
||||
if self._approval_permissions is None:
|
||||
self._perm_deny = self._resolve_default_deny_set()
|
||||
else:
|
||||
# Plain backend object — dangerous tools only, backend default timeout
|
||||
self._approval_backend = approval
|
||||
@@ -5812,21 +5790,41 @@ Summary:"""
|
||||
try:
|
||||
from ..memory.memory import Memory
|
||||
config = {"provider": _resolve_memory_adapter_name(memory)}
|
||||
# Scope the store to this agent's isolation id (same id the
|
||||
# FileMemory branches use) so two agents in one process/dir do
|
||||
# not silently share one on-disk store when no user_id is given.
|
||||
config.setdefault("user_id", mem_user_id)
|
||||
config.setdefault("collection_name", f"memory_{mem_user_id}")
|
||||
self._memory_instance = Memory(config)
|
||||
except ImportError:
|
||||
logging.warning(f"Memory provider '{memory}' requires additional dependencies. Falling back to FileMemory.")
|
||||
from ..memory.file_memory import FileMemory
|
||||
self._memory_instance = FileMemory(user_id=mem_user_id)
|
||||
except Exception as e:
|
||||
# The backend package is installed but could not initialise
|
||||
# (e.g. a SaaS backend like mem0 needs credentials/network).
|
||||
# Honour the same "Falling back to FileMemory" contract used for
|
||||
# missing dependencies so construction never hard-crashes.
|
||||
logging.warning(f"Memory provider '{memory}' could not initialise ({e}). Falling back to FileMemory.")
|
||||
from ..memory.file_memory import FileMemory
|
||||
self._memory_instance = FileMemory(user_id=mem_user_id)
|
||||
elif isinstance(memory, dict):
|
||||
# Configuration dict
|
||||
provider = memory.get("provider", memory.get("backend", "file"))
|
||||
learn_enabled = memory.get("learn", False)
|
||||
|
||||
# Build a scoped config so an explicit user_id in the dict wins but a
|
||||
# missing one falls back to this agent's isolation id (matching the
|
||||
# FileMemory branches) instead of a process-wide shared store.
|
||||
scoped_memory = dict(memory)
|
||||
scoped_memory.setdefault("user_id", mem_user_id)
|
||||
scoped_memory.setdefault("collection_name", f"memory_{scoped_memory['user_id']}")
|
||||
|
||||
# Use full Memory class if learn is enabled (requires LearnManager)
|
||||
if learn_enabled:
|
||||
try:
|
||||
from ..memory.memory import Memory
|
||||
self._memory_instance = Memory(memory)
|
||||
self._memory_instance = Memory(scoped_memory)
|
||||
except ImportError:
|
||||
logging.warning("Memory with learn requires additional dependencies. Falling back to FileMemory (learn disabled).")
|
||||
from ..memory.file_memory import FileMemory
|
||||
@@ -5840,7 +5838,7 @@ Summary:"""
|
||||
else:
|
||||
try:
|
||||
from ..memory.memory import Memory
|
||||
self._memory_instance = Memory(memory)
|
||||
self._memory_instance = Memory(scoped_memory)
|
||||
except ImportError:
|
||||
logging.warning("Full Memory class requires additional dependencies. Falling back to FileMemory.")
|
||||
from ..memory.file_memory import FileMemory
|
||||
@@ -6408,6 +6406,31 @@ Answer:"""
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _resolve_default_deny_set() -> frozenset:
|
||||
"""Return the env-driven default permission deny set.
|
||||
|
||||
Mirrors the ``approval is None`` path: honour ``PRAISONAI_TOOL_SAFETY``
|
||||
(``off``/``full``/``none``/``0``/``false`` → no denials), otherwise use
|
||||
the named preset (defaulting to ``"default"``, which blocks destructive
|
||||
ops like ``execute_command``/``delete_file``/``kill_process``/
|
||||
``execute_code``). An unknown env value falls back to ``"default"``.
|
||||
|
||||
Shared by the ``ApprovalConfig``/``dict`` branches so configuring
|
||||
approval never silently drops the default denials — passing a config
|
||||
object must not be a weaker posture than passing nothing.
|
||||
"""
|
||||
_raw_safety_env = os.environ.get("PRAISONAI_TOOL_SAFETY")
|
||||
_safety_env = (_raw_safety_env or "").strip().lower()
|
||||
if _safety_env in ("off", "full", "none", "0", "false"):
|
||||
return frozenset()
|
||||
from ..approval.registry import PERMISSION_PRESETS
|
||||
_resolved = _safety_env or "default"
|
||||
_preset = PERMISSION_PRESETS.get(_resolved)
|
||||
if _preset is None:
|
||||
_preset = PERMISSION_PRESETS.get("default")
|
||||
return _preset if _preset is not None else frozenset()
|
||||
|
||||
def _setup_guardrail(self):
|
||||
"""Setup the guardrail function based on the provided guardrail parameter."""
|
||||
# ``tool_execution._check_tool_policy_and_guardrails`` reads
|
||||
@@ -6625,7 +6648,7 @@ Answer:"""
|
||||
else:
|
||||
return False, None, guardrail_result.error
|
||||
|
||||
def _apply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None):
|
||||
def _apply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None, cancel_token=None):
|
||||
"""Apply guardrail validation with retry logic (sync version)."""
|
||||
retry_count = 0
|
||||
current_response = response_text
|
||||
@@ -6666,7 +6689,7 @@ Answer:"""
|
||||
# Regenerate response for retry
|
||||
try:
|
||||
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {error}. Please provide an improved response."
|
||||
response = self._chat_completion([{"role": "user", "content": retry_prompt}], temperature, tools, task_name=task_name, task_description=task_description, task_id=task_id)
|
||||
response = self._chat_completion([{"role": "user", "content": retry_prompt}], temperature, tools, task_name=task_name, task_description=task_description, task_id=task_id, cancel_token=cancel_token)
|
||||
if response and response.choices:
|
||||
content = response.choices[0].message.content
|
||||
current_response = content.strip() if content else ""
|
||||
@@ -6841,8 +6864,12 @@ Answer:"""
|
||||
if AgentRuntimeConfig and isinstance(runtime, AgentRuntimeConfig):
|
||||
return runtime
|
||||
|
||||
# If already a RuntimeConfig instance, return as-is
|
||||
# If already a RuntimeConfig instance, normalise its runtime name so a
|
||||
# typo in preferred_runtime (e.g. "nativ") raises instead of silently
|
||||
# dropping capabilities. resolve_runtime returns a normalised copy.
|
||||
if RuntimeConfig and hasattr(RuntimeConfig, '__name__') and isinstance(runtime, RuntimeConfig):
|
||||
if resolve_runtime:
|
||||
return resolve_runtime(runtime)
|
||||
return runtime
|
||||
|
||||
# Handle capability validation style (bool, RuntimeConfig)
|
||||
@@ -6851,8 +6878,13 @@ Answer:"""
|
||||
result = resolve_runtime(runtime)
|
||||
if result is not None:
|
||||
return result
|
||||
except (TypeError, ValueError):
|
||||
pass # Try AgentRuntimeConfig path
|
||||
except ValueError:
|
||||
# An invalid runtime *name* (e.g. a typo) must surface rather
|
||||
# than falling through and being stored verbatim, which would
|
||||
# silently drop most capabilities.
|
||||
raise
|
||||
except TypeError:
|
||||
pass # Not this style; try AgentRuntimeConfig path
|
||||
|
||||
# Handle turn-based style (AgentRuntimeConfig)
|
||||
if AgentRuntimeConfig:
|
||||
@@ -6907,16 +6939,17 @@ Answer:"""
|
||||
else:
|
||||
raise TypeError(f"Invalid capability type: {type(cap)}")
|
||||
|
||||
# Determine runtime name
|
||||
runtime_name = getattr(self._runtime_config, 'preferred_runtime', 'native')
|
||||
# Determine runtime name. Normalise so spelling variants (NATIVE,
|
||||
# " native ", native) map to the same matrix instead of silently
|
||||
# dropping capabilities; an unknown name raises rather than degrading.
|
||||
raw_runtime = getattr(self._runtime_config, 'preferred_runtime', None) or 'native'
|
||||
from ..config.feature_configs import canonical_runtime_name
|
||||
runtime_name = canonical_runtime_name(raw_runtime)
|
||||
|
||||
# Get runtime capabilities based on runtime type
|
||||
if runtime_name == 'native':
|
||||
runtime_matrix = get_native_runtime_capabilities()
|
||||
elif runtime_name in ('plugin-harness', 'harness', 'plugin', 'reduced'):
|
||||
runtime_matrix = get_reduced_harness_capabilities()
|
||||
else:
|
||||
# Unknown runtime, use reduced capabilities as safe default
|
||||
runtime_matrix = get_reduced_harness_capabilities()
|
||||
|
||||
# Validate capabilities
|
||||
@@ -7419,8 +7452,7 @@ Answer:"""
|
||||
|
||||
try:
|
||||
# Tear down the routes launch() actually registered (module-level
|
||||
# state in execution_mixin.py). ServerRegistry above is a separate,
|
||||
# unpopulated structure and cleaning it up is a no-op.
|
||||
# state in execution_mixin.py).
|
||||
from .execution_mixin import cleanup_launch_registration
|
||||
cleanup_launch_registration(self._agent_id)
|
||||
|
||||
|
||||
@@ -1211,7 +1211,15 @@ Your Goal: {self.goal}"""
|
||||
return content
|
||||
|
||||
def _extract_llm_response_content(self, response) -> Optional[str]:
|
||||
"""Return assistant message text, a tool-call summary, or str(response) as fallback."""
|
||||
"""Return assistant message text, or a tool-call summary.
|
||||
|
||||
A well-formed response that simply carries no content -- a content filter,
|
||||
a refusal, or ``finish_reason="length"`` with zero output tokens -- yields
|
||||
the empty string. ``str(response)`` is reserved for responses this method
|
||||
could not parse at all, where the repr is a diagnostic rather than an
|
||||
answer; returning it for a merely empty message put the raw SDK object's
|
||||
repr in front of the user and into ``chat_history``.
|
||||
"""
|
||||
if not response:
|
||||
return None
|
||||
try:
|
||||
@@ -1226,6 +1234,17 @@ Your Goal: {self.goal}"""
|
||||
if tool_calls:
|
||||
names = [getattr(tc.function, "name", "?") for tc in tool_calls]
|
||||
return f"[tool_calls: {', '.join(names)}]"
|
||||
# Parsed cleanly; the model returned no content. A refusal
|
||||
# (safety) is carried in ``message.refusal`` independently of
|
||||
# ``finish_reason`` -- surface it so an empty answer isn't silent.
|
||||
finish_reason = getattr(choice, "finish_reason", None)
|
||||
refusal = getattr(msg, "refusal", None)
|
||||
if refusal or finish_reason not in (None, "stop"):
|
||||
logging.warning(
|
||||
f"Agent {self.name}: model returned no content "
|
||||
f"(finish_reason={finish_reason!r}, refused={bool(refusal)})"
|
||||
)
|
||||
return ""
|
||||
except (AttributeError, IndexError, TypeError) as e:
|
||||
logging.warning(
|
||||
f"Failed to extract LLM response content (falling back to str): {e}"
|
||||
@@ -1725,7 +1744,7 @@ Your Goal: {self.goal}"""
|
||||
return max_retries
|
||||
return 2
|
||||
|
||||
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0, _fallback_index=0):
|
||||
def _chat_completion(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0, _fallback_index=0, cancel_token=None):
|
||||
start_time = time.time()
|
||||
|
||||
# --- Proactive Context Budget Management (default-on) ---
|
||||
@@ -1830,6 +1849,7 @@ Your Goal: {self.goal}"""
|
||||
# First attempt: try with streaming enabled for better user experience
|
||||
stream_callback = self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
|
||||
streaming_response = self._chat_completion_with_retry(
|
||||
cancel_token=cancel_token,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
@@ -1849,6 +1869,11 @@ Your Goal: {self.goal}"""
|
||||
stream = False # Set for the main execution below
|
||||
else:
|
||||
raise # Re-raise if it's a different ValueError
|
||||
except ToolExecutionError:
|
||||
# A tool failed during the streaming attempt. Re-raise so it is
|
||||
# not relabelled as an LLM error and so the tool is not executed
|
||||
# a second time by the non-streaming fallback below.
|
||||
raise
|
||||
except Exception as e:
|
||||
from ..errors import LLMError
|
||||
# Don't retry if it's an LLMError that has exhausted retries
|
||||
@@ -1868,6 +1893,7 @@ Your Goal: {self.goal}"""
|
||||
final_response = streaming_response
|
||||
else:
|
||||
final_response = self._chat_completion_with_retry(
|
||||
cancel_token=cancel_token,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
@@ -1953,6 +1979,8 @@ Your Goal: {self.goal}"""
|
||||
|
||||
except BudgetExceededError:
|
||||
raise
|
||||
except ToolExecutionError:
|
||||
raise
|
||||
except Exception as e:
|
||||
from ..errors import LLMError
|
||||
|
||||
@@ -2017,7 +2045,8 @@ Your Goal: {self.goal}"""
|
||||
truncated_messages, temperature, tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id, response_format,
|
||||
_retry_depth=_retry_depth + 1,
|
||||
_fallback_index=_fallback_index
|
||||
_fallback_index=_fallback_index,
|
||||
cancel_token=cancel_token
|
||||
)
|
||||
except Exception as compression_error:
|
||||
logging.error(f"[{self.name}] Context compression failed: {compression_error}")
|
||||
@@ -2063,7 +2092,8 @@ Your Goal: {self.goal}"""
|
||||
messages, temperature, tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id, response_format,
|
||||
_retry_depth=_retry_depth + 1,
|
||||
_fallback_index=_fallback_index + 1
|
||||
_fallback_index=_fallback_index + 1,
|
||||
cancel_token=cancel_token
|
||||
)
|
||||
finally:
|
||||
self.llm = original_llm
|
||||
@@ -2080,7 +2110,8 @@ Your Goal: {self.goal}"""
|
||||
messages, temperature, tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id, response_format,
|
||||
_retry_depth=_retry_depth + 1,
|
||||
_fallback_index=_fallback_index
|
||||
_fallback_index=_fallback_index,
|
||||
cancel_token=cancel_token
|
||||
)
|
||||
|
||||
# Include remediation hints for unimplemented recovery actions
|
||||
@@ -2119,7 +2150,7 @@ Your Goal: {self.goal}"""
|
||||
self,
|
||||
exc: Exception,
|
||||
messages,
|
||||
temperature=1.0,
|
||||
temperature=None,
|
||||
tools=None,
|
||||
stream=True,
|
||||
reasoning_steps=False,
|
||||
@@ -2309,7 +2340,7 @@ Your Goal: {self.goal}"""
|
||||
def _execute_unified_chat_completion(
|
||||
self,
|
||||
messages,
|
||||
temperature=1.0,
|
||||
temperature=None,
|
||||
tools=None,
|
||||
stream=None,
|
||||
reasoning_steps=False,
|
||||
@@ -2319,6 +2350,7 @@ Your Goal: {self.goal}"""
|
||||
response_format=None,
|
||||
stream_callback=None,
|
||||
emit_events=True,
|
||||
cancel_token=None,
|
||||
):
|
||||
"""
|
||||
Execute unified chat completion using composition instead of runtime class mutation.
|
||||
@@ -2351,6 +2383,7 @@ Your Goal: {self.goal}"""
|
||||
if stream_callback is None and hasattr(self, 'stream_emitter'):
|
||||
stream_callback = getattr(self.stream_emitter, 'emit', None)
|
||||
final_response = self._unified_dispatcher.chat_completion(
|
||||
cancel_token=cancel_token,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
tool_choice=getattr(self, 'tool_choice', None),
|
||||
@@ -2396,6 +2429,7 @@ Your Goal: {self.goal}"""
|
||||
if stream_callback is None and hasattr(self, 'stream_emitter'):
|
||||
stream_callback = getattr(self.stream_emitter, 'emit', None)
|
||||
final_response = self._unified_dispatcher.chat_completion(
|
||||
cancel_token=cancel_token,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
tool_choice=getattr(self, 'tool_choice', None),
|
||||
@@ -2443,7 +2477,7 @@ Your Goal: {self.goal}"""
|
||||
async def _execute_unified_achat_completion(
|
||||
self,
|
||||
messages,
|
||||
temperature=1.0,
|
||||
temperature=None,
|
||||
tools=None,
|
||||
stream=True, # Async methods keep stream=True default (async adapters support streaming vs sync smart fallback)
|
||||
reasoning_steps=False,
|
||||
@@ -2781,7 +2815,11 @@ Your Goal: {self.goal}"""
|
||||
|
||||
model_name = self.llm if isinstance(self.llm, str) else "gpt-4o-mini"
|
||||
model_limit = get_model_limit(model_name)
|
||||
current_tokens = estimate_messages_tokens(optimized)
|
||||
# Count the whole request (history + system prompt + tool schemas),
|
||||
# otherwise a large prompt/tool block can push the call over the
|
||||
# window while the history alone stays under the threshold.
|
||||
overhead = self._estimate_request_overhead_tokens(system_prompt, tools)
|
||||
current_tokens = estimate_messages_tokens(optimized) + overhead
|
||||
|
||||
# If over 95% of limit, apply emergency truncation
|
||||
if current_tokens > model_limit * 0.95:
|
||||
@@ -2789,20 +2827,67 @@ Your Goal: {self.goal}"""
|
||||
f"[{self.name}] Context at {current_tokens} tokens (limit: {model_limit}), "
|
||||
f"applying emergency truncation"
|
||||
)
|
||||
target = int(model_limit * 0.8) # Target 80% of limit
|
||||
# Leave room for the fixed overhead so the full request lands
|
||||
# near 80% of the window rather than the history alone.
|
||||
target = max(1, int(model_limit * 0.8) - overhead)
|
||||
optimized = self.context_manager.emergency_truncate(optimized, target)
|
||||
result["emergency_truncated"] = True
|
||||
result["tokens_after"] = estimate_messages_tokens(optimized)
|
||||
result["tokens_after"] = estimate_messages_tokens(optimized) + overhead
|
||||
except Exception as e:
|
||||
logging.debug(f"Hard limit check skipped: {e}")
|
||||
|
||||
return optimized, result
|
||||
|
||||
except Exception as e:
|
||||
# Context management should never break the chat flow
|
||||
logging.warning(f"Context management error (continuing without): {e}")
|
||||
# Context management should never break the chat flow, but silently
|
||||
# returning the unchanged history disables the hard-limit truncation
|
||||
# too and sends an over-budget request. Fall back to emergency
|
||||
# truncation so we still fit within the model window.
|
||||
logging.error(f"Context management failed: {e}")
|
||||
try:
|
||||
from ..context.budgeter import get_model_limit
|
||||
from ..context.tokens import estimate_messages_tokens
|
||||
|
||||
model_name = self.llm if isinstance(self.llm, str) else "gpt-4o-mini"
|
||||
model_limit = get_model_limit(model_name)
|
||||
# Include the system prompt and tool schemas in the budget: the
|
||||
# request can exceed the window on those alone even when the
|
||||
# history is under the threshold.
|
||||
overhead = self._estimate_request_overhead_tokens(system_prompt, tools)
|
||||
if estimate_messages_tokens(messages) + overhead > model_limit * 0.95:
|
||||
target = max(1, int(model_limit * 0.8) - overhead)
|
||||
truncated = self.context_manager.emergency_truncate(messages, target)
|
||||
return truncated, {"emergency_truncated": True}
|
||||
except Exception as inner:
|
||||
logging.debug(f"Emergency truncation fallback skipped: {inner}")
|
||||
return messages, None
|
||||
|
||||
def _estimate_request_overhead_tokens(
|
||||
self,
|
||||
system_prompt: str = "",
|
||||
tools: Optional[list] = None,
|
||||
) -> int:
|
||||
"""Estimate non-history request tokens (system prompt + tool schemas).
|
||||
|
||||
Emergency-truncation thresholds must budget the full request, not just
|
||||
the message history, or a large system prompt / tool block can push the
|
||||
call over the model window undetected.
|
||||
"""
|
||||
try:
|
||||
from ..context.tokens import (
|
||||
estimate_tokens_heuristic,
|
||||
estimate_tool_schema_tokens,
|
||||
)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
overhead = 0
|
||||
if system_prompt:
|
||||
overhead += estimate_tokens_heuristic(system_prompt)
|
||||
if tools:
|
||||
overhead += estimate_tool_schema_tokens(tools)
|
||||
return overhead
|
||||
|
||||
def _truncate_tool_output(self, tool_name: str, output: str, tool_call_id: str | None = None) -> str:
|
||||
"""
|
||||
Truncate tool output according to configured budget.
|
||||
@@ -2888,7 +2973,7 @@ Your Goal: {self.goal}"""
|
||||
)
|
||||
return rendered
|
||||
|
||||
def chat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None) -> Optional[str]:
|
||||
def chat(self, prompt: str, temperature: Optional[float] = None, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None) -> Optional[str]:
|
||||
"""
|
||||
Chat with the agent.
|
||||
|
||||
@@ -2935,6 +3020,19 @@ Your Goal: {self.goal}"""
|
||||
|
||||
# Check if external managed backend is configured
|
||||
if hasattr(self, 'backend') and self.backend is not None:
|
||||
# Honour configured throttling and cooperative cancellation before
|
||||
# handing off to the managed backend, so backend requests obey the
|
||||
# same request limits and cancel token as direct-LLM calls instead
|
||||
# of bypassing both guards (parity with the async achat() path).
|
||||
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
|
||||
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
|
||||
reason = getattr(_cancel, "reason", None) or "cancelled before backend call"
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
if self._rate_limiter is not None:
|
||||
self._rate_limiter.acquire()
|
||||
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
|
||||
reason = getattr(_cancel, "reason", None) or "cancelled before backend call"
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
# Extract kwargs for delegation, excluding 'self' and function locals
|
||||
delegation_kwargs = {
|
||||
'temperature': temperature,
|
||||
@@ -3306,7 +3404,7 @@ Your Goal: {self.goal}"""
|
||||
|
||||
# Apply guardrail validation for custom LLM response
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id)
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
# Execute callback and display after validation
|
||||
self._execute_callback_and_display(prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(prompt, validated_response, start_time)
|
||||
@@ -3406,7 +3504,12 @@ Your Goal: {self.goal}"""
|
||||
agent_tools=agent_tools
|
||||
)
|
||||
|
||||
response = self._chat_completion(messages, temperature=temperature, tools=tools, reasoning_steps=reasoning_steps, stream=stream, task_name=task_name, task_description=task_description, task_id=task_id, response_format=response_format)
|
||||
# G2 - thread the cancel token into the OpenAI-native tool
|
||||
# loop too. OpenAIClient.{a,}chat_completion_with_tools already
|
||||
# implements the between-iteration checks; without this hop they
|
||||
# are never armed, so /stop reports success while the remaining
|
||||
# tool calls keep executing.
|
||||
response = self._chat_completion(messages, temperature=temperature, tools=tools, reasoning_steps=reasoning_steps, stream=stream, task_name=task_name, task_description=task_description, task_id=task_id, response_format=response_format, cancel_token=cancel_token)
|
||||
if not response:
|
||||
# Rollback chat history on response failure
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
@@ -3425,7 +3528,7 @@ Your Goal: {self.goal}"""
|
||||
self._persist_message("assistant", response_text)
|
||||
# Apply guardrail validation even for JSON output
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
@@ -3446,7 +3549,7 @@ Your Goal: {self.goal}"""
|
||||
if reasoning_steps and hasattr(response.choices[0].message, 'reasoning_content') and response.choices[0].message.reasoning_content:
|
||||
# Apply guardrail to reasoning content
|
||||
try:
|
||||
validated_reasoning = self._apply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
validated_reasoning = self._apply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_reasoning, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_reasoning, start_time)
|
||||
@@ -3457,7 +3560,7 @@ Your Goal: {self.goal}"""
|
||||
return None
|
||||
# Apply guardrail to regular response
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
@@ -3481,7 +3584,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
if self._using_custom_llm or self._openai_client is None:
|
||||
# For custom LLMs, we need to handle reflection differently
|
||||
# Use non-streaming to get complete JSON response
|
||||
reflection_response = self._chat_completion(messages, temperature=temperature, tools=None, stream=False, reasoning_steps=False, task_name=task_name, task_description=task_description, task_id=task_id)
|
||||
reflection_response = self._chat_completion(messages, temperature=temperature, tools=None, stream=False, reasoning_steps=False, task_name=task_name, task_description=task_description, task_id=task_id, cancel_token=cancel_token)
|
||||
|
||||
if not reflection_response or not reflection_response.choices:
|
||||
raise Exception("No response from reflection request")
|
||||
@@ -3498,11 +3601,14 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
reflection_output = _get_display_functions()['ReflectionOutput'](**reflection_data)
|
||||
else:
|
||||
# Use OpenAI's structured output for OpenAI models
|
||||
_reflection_kwargs = {}
|
||||
if temperature is not None:
|
||||
_reflection_kwargs['temperature'] = temperature
|
||||
reflection_response = self._openai_client.sync_client.beta.chat.completions.parse(
|
||||
model=self.reflect_llm if self.reflect_llm else self.llm,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
response_format=_get_display_functions()['ReflectionOutput']
|
||||
response_format=_get_display_functions()['ReflectionOutput'],
|
||||
**_reflection_kwargs
|
||||
)
|
||||
|
||||
reflection_output = reflection_response.choices[0].message.parsed
|
||||
@@ -3520,7 +3626,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Apply guardrail validation after satisfactory reflection
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
self._end_run(validated_response, "completed", {"duration_ms": (time.time() - start_time) * 1000})
|
||||
@@ -3540,7 +3646,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Apply guardrail validation after max reflections
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
@@ -3556,7 +3662,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# For reflection, always use non-streaming to ensure compatibility with sync adapters
|
||||
# and to avoid streaming complexity during regeneration process
|
||||
use_stream = False
|
||||
response = self._chat_completion(messages, temperature=temperature, tools=None, stream=use_stream, task_name=task_name, task_description=task_description, task_id=task_id)
|
||||
response = self._chat_completion(messages, temperature=temperature, tools=None, stream=use_stream, task_name=task_name, task_description=task_description, task_id=task_id, cancel_token=cancel_token)
|
||||
content = response.choices[0].message.content
|
||||
response_text = content.strip() if content else ""
|
||||
reflection_count += 1
|
||||
@@ -3567,6 +3673,22 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.error("Reflection parsing failed.", exc_info=True)
|
||||
messages.append({"role": "assistant", "content": "Self Reflection failed."})
|
||||
reflection_count += 1
|
||||
if reflection_count >= self.max_reflect:
|
||||
# Bound the retry, as the async path does. Without this
|
||||
# a persistently unparseable reflection (e.g. a
|
||||
# structured-output refusal, where message.parsed is
|
||||
# None) loops forever, two LLM calls at a time.
|
||||
if self.verbose:
|
||||
_get_display_functions()['display_self_reflection']("Maximum reflection count reached after repeated parse errors, returning current response", console=self.console)
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as guard_e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection error: {guard_e}")
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
continue # Continue even after error to try again
|
||||
except Exception:
|
||||
# Catch any exception from the inner try block and re-raise to outer handler
|
||||
@@ -3586,7 +3708,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
return clean_triple_backticks(output)
|
||||
|
||||
async def achat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None):
|
||||
async def achat(self, prompt: str, temperature: Optional[float] = None, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None):
|
||||
"""Async version of chat method with self-reflection support.
|
||||
|
||||
Args:
|
||||
@@ -3633,9 +3755,12 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# handing off to the managed backend, so backend requests obey the
|
||||
# same request limits and cancel token as direct-LLM calls instead
|
||||
# of bypassing both guards.
|
||||
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
|
||||
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
|
||||
reason = getattr(_cancel, "reason", None) or "cancelled before backend call"
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
if self._rate_limiter is not None:
|
||||
await self._rate_limiter.acquire_async()
|
||||
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
|
||||
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
|
||||
reason = getattr(_cancel, "reason", None) or "cancelled before backend call"
|
||||
raise InterruptedError(f"Agent chat cancelled: {reason}")
|
||||
@@ -4147,11 +4272,14 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._rollback_chat_history_to(chat_history_length)
|
||||
return None
|
||||
|
||||
_areflection_kwargs = {}
|
||||
if temperature is not None:
|
||||
_areflection_kwargs['temperature'] = temperature
|
||||
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
|
||||
model=self.reflect_llm if self.reflect_llm else self.llm,
|
||||
messages=reflection_messages,
|
||||
temperature=temperature,
|
||||
response_format=_get_display_functions()['ReflectionOutput']
|
||||
response_format=_get_display_functions()['ReflectionOutput'],
|
||||
**_areflection_kwargs
|
||||
)
|
||||
|
||||
reflection_output = reflection_response.choices[0].message.parsed
|
||||
@@ -4265,9 +4393,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
tool_call_kwargs = dict(
|
||||
model=self.llm,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
)
|
||||
if temperature is not None:
|
||||
tool_call_kwargs['temperature'] = temperature
|
||||
if effective_tool_choice:
|
||||
tool_call_kwargs['tool_choice'] = effective_tool_choice
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
@@ -4281,11 +4410,14 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._execute_callback_and_display(original_prompt, result, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, result, start_time)
|
||||
elif output_json or output_pydantic:
|
||||
_json_kwargs = {}
|
||||
if temperature is not None:
|
||||
_json_kwargs['temperature'] = temperature
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
response_format={"type": "json_object"}
|
||||
response_format={"type": "json_object"},
|
||||
**_json_kwargs
|
||||
)
|
||||
response_text = response.choices[0].message.content
|
||||
if get_logger().getEffectiveLevel() == logging.DEBUG:
|
||||
@@ -4295,10 +4427,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._execute_callback_and_display(original_prompt, response_text, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
|
||||
else:
|
||||
_plain_kwargs = {}
|
||||
if temperature is not None:
|
||||
_plain_kwargs['temperature'] = temperature
|
||||
response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
messages=messages,
|
||||
temperature=temperature
|
||||
**_plain_kwargs
|
||||
)
|
||||
|
||||
response_text = response.choices[0].message.content
|
||||
@@ -4335,11 +4470,14 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
|
||||
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
|
||||
|
||||
_areflection_kwargs = {}
|
||||
if temperature is not None:
|
||||
_areflection_kwargs['temperature'] = temperature
|
||||
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
|
||||
model=self.reflect_llm if self.reflect_llm else self.llm,
|
||||
messages=reflection_messages,
|
||||
temperature=temperature,
|
||||
response_format=_get_display_functions()['ReflectionOutput']
|
||||
response_format=_get_display_functions()['ReflectionOutput'],
|
||||
**_areflection_kwargs
|
||||
)
|
||||
|
||||
reflection_output = reflection_response.choices[0].message.parsed
|
||||
@@ -4365,10 +4503,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
{"role": "user", "content": "Now regenerate your response using the reflection you made"}
|
||||
]
|
||||
|
||||
_regen_kwargs = {}
|
||||
if temperature is not None:
|
||||
_regen_kwargs['temperature'] = temperature
|
||||
new_response = await self._openai_client.async_client.chat.completions.create(
|
||||
model=self.llm,
|
||||
messages=regenerate_messages,
|
||||
temperature=temperature
|
||||
**_regen_kwargs
|
||||
)
|
||||
response_text = new_response.choices[0].message.content
|
||||
reflection_count += 1
|
||||
@@ -4863,6 +5004,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Handle any tool calls that were accumulated
|
||||
if tool_calls_data:
|
||||
# Mark where the tool turns begin so the follow-up can
|
||||
# append exactly the assistant tool-call and tool-result
|
||||
# messages onto the original request context below.
|
||||
tool_turn_start = len(self.chat_history)
|
||||
# Add assistant message with tool calls to chat history
|
||||
assistant_message = {"role": "assistant", "content": response_text}
|
||||
if tool_calls_data:
|
||||
@@ -4956,6 +5101,75 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Flush deferred media follow-ups after all tool replies.
|
||||
for _m in _deferred_media_followups:
|
||||
self._append_to_chat_history(_m)
|
||||
|
||||
# Ask the model for the answer now that the tools have
|
||||
# run. Without this the generator ends here: the tool
|
||||
# results sit in chat history, nothing is yielded, and
|
||||
# the caller receives an empty stream with no error --
|
||||
# indistinguishable from a model that had nothing to
|
||||
# say. The non-streaming path already does this round
|
||||
# trip, so the two disagreed about whether an answer
|
||||
# existed at all.
|
||||
#
|
||||
# Reuse the original `messages` (system prompt, output
|
||||
# constraints, memory context and the user turn) rather
|
||||
# than `self.chat_history`, which omits the system turn
|
||||
# and would let the follow-up ignore the agent role and
|
||||
# output format. Append only the tool turns produced by
|
||||
# this round -- everything added to chat history since
|
||||
# the completion began.
|
||||
followup_args = dict(completion_args)
|
||||
followup_args['messages'] = (
|
||||
list(messages) + self.chat_history[tool_turn_start:]
|
||||
)
|
||||
followup_args['stream'] = True
|
||||
# No tools on the follow-up: the model has its results
|
||||
# and should now answer, not call another tool. That
|
||||
# also bounds the turn to a single round of tools.
|
||||
followup_args.pop('tools', None)
|
||||
followup_args.pop('tool_choice', None)
|
||||
followup_args.pop('parallel_tool_calls', None)
|
||||
|
||||
# Bracket the follow-up with stream events so consumers
|
||||
# see request/terminal telemetry for the answer too --
|
||||
# the STREAM_END above referred only to the tool-call
|
||||
# round, before any answer existed.
|
||||
self.stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.REQUEST_START,
|
||||
timestamp=time_module.perf_counter(),
|
||||
metadata={
|
||||
"model": self.llm,
|
||||
"message_count": len(followup_args['messages']),
|
||||
"phase": "post_tool_answer",
|
||||
}
|
||||
))
|
||||
|
||||
followup_text = ""
|
||||
followup_last_content_time = None
|
||||
for followup_chunk in self._openai_client.sync_client.chat.completions.create(
|
||||
**followup_args
|
||||
):
|
||||
if not followup_chunk.choices:
|
||||
continue
|
||||
piece = followup_chunk.choices[0].delta.content
|
||||
if piece:
|
||||
followup_text += piece
|
||||
followup_last_content_time = time_module.perf_counter()
|
||||
yield piece
|
||||
if followup_last_content_time:
|
||||
self.stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.LAST_TOKEN,
|
||||
timestamp=followup_last_content_time
|
||||
))
|
||||
self.stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.STREAM_END,
|
||||
timestamp=time_module.perf_counter(),
|
||||
metadata={"response_length": len(followup_text)}
|
||||
))
|
||||
if followup_text:
|
||||
self._append_to_chat_history(
|
||||
{"role": "assistant", "content": followup_text}
|
||||
)
|
||||
else:
|
||||
# Add complete response to chat history (text-only response)
|
||||
if response_text:
|
||||
@@ -5256,18 +5470,111 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
agent_id=getattr(self, 'name', None),
|
||||
)
|
||||
|
||||
def _chat_completion_with_retry(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
|
||||
def _get_model_middleware_manager(self):
|
||||
"""Return a MiddlewareManager if user model-call hooks are registered.
|
||||
|
||||
Companion to ``_get_tool_middleware_manager`` for the model-call side of
|
||||
the ``hooks/middleware.py`` surface (``before_model``/``after_model``/
|
||||
``wrap_model_call``). Reuses the same lazily-built manager and returns
|
||||
``None`` when no model hooks are present, preserving the zero-overhead
|
||||
fast path.
|
||||
"""
|
||||
hooks = getattr(self, '_hooks', None)
|
||||
if not hooks:
|
||||
return None
|
||||
manager = getattr(self, '_middleware_manager', None)
|
||||
if manager is None:
|
||||
from ..hooks import MiddlewareManager
|
||||
manager = MiddlewareManager(hooks)
|
||||
self._middleware_manager = manager
|
||||
return manager if manager.has_model_hooks else None
|
||||
|
||||
@staticmethod
|
||||
def _unwrap_model_response(response):
|
||||
"""Unwrap a middleware ModelResponse back to the agent's return shape.
|
||||
|
||||
Preserves ``after_model``/``wrap_model_call`` mutations: if a hook
|
||||
rewrote ``response.content``, that string wins. The original SDK object
|
||||
stashed in ``extra["raw"]`` is only returned when the hook left content
|
||||
untouched (so non-string provider payloads pass through unchanged on the
|
||||
no-transform fast path).
|
||||
"""
|
||||
from ..hooks import ModelResponse
|
||||
if not isinstance(response, ModelResponse):
|
||||
return response
|
||||
raw = response.extra.get("raw")
|
||||
if isinstance(raw, str):
|
||||
# Content was derived from a string raw; hook edits live on content.
|
||||
return response.content
|
||||
# Non-string raw: return raw only if content still matches the value we
|
||||
# synthesized from it (i.e. hook didn't rewrite content).
|
||||
synthesized = raw if isinstance(raw, str) else (raw or "")
|
||||
if response.content == synthesized:
|
||||
return raw
|
||||
return response.content
|
||||
|
||||
def _chat_completion_with_retry(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True, cancel_token=None):
|
||||
"""
|
||||
Wrapper for _execute_unified_chat_completion that adds jittered exponential backoff retry logic.
|
||||
|
||||
This method wraps the unified chat completion call and adds retry capability for
|
||||
transient failures like rate limits, network errors, and service outages.
|
||||
"""
|
||||
# Route through user-supplied model middleware (Agent(hooks=[...])) when
|
||||
# before_model/after_model/wrap_model_call hooks are registered. The
|
||||
# retry/backoff logic below becomes the middleware chain's final handler.
|
||||
manager = self._get_model_middleware_manager()
|
||||
if manager is not None:
|
||||
from ..hooks import ModelRequest, ModelResponse, InvocationContext
|
||||
|
||||
def _model_fn(req):
|
||||
# Honor before_model/wrap_model_call mutations by threading the
|
||||
# (possibly rewritten) request fields into the retry core rather
|
||||
# than the outer captured values.
|
||||
effective_temperature = req.temperature if req.temperature is not None else temperature
|
||||
effective_tools = req.tools if req.tools is not None else tools
|
||||
result = self._chat_completion_with_retry_core(
|
||||
req.messages, effective_temperature, effective_tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id,
|
||||
response_format, stream_callback=stream_callback,
|
||||
emit_events=emit_events, cancel_token=cancel_token,
|
||||
)
|
||||
return ModelResponse(
|
||||
content=result if isinstance(result, str) else (result or ""),
|
||||
model=str(getattr(self, 'llm', '') or ''),
|
||||
extra={"raw": result},
|
||||
)
|
||||
|
||||
request = ModelRequest(
|
||||
messages=messages,
|
||||
model=str(getattr(self, 'llm', '') or ''),
|
||||
temperature=temperature if temperature is not None else 1.0,
|
||||
tools=tools,
|
||||
context=InvocationContext(
|
||||
agent_id=self.name,
|
||||
run_id=getattr(self, '_current_run_id', 'unknown'),
|
||||
session_id=getattr(self, '_session_id', None) or 'default',
|
||||
model_name=str(getattr(self, 'llm', '') or ''),
|
||||
),
|
||||
)
|
||||
response = manager.execute_model_call(request, _model_fn)
|
||||
return self._unwrap_model_response(response)
|
||||
|
||||
return self._chat_completion_with_retry_core(
|
||||
messages, temperature, tools, stream, reasoning_steps,
|
||||
task_name, task_description, task_id, response_format,
|
||||
stream_callback=stream_callback, emit_events=emit_events,
|
||||
cancel_token=cancel_token,
|
||||
)
|
||||
|
||||
def _chat_completion_with_retry_core(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True, cancel_token=None):
|
||||
"""Retry/backoff core for chat completion (middleware-agnostic)."""
|
||||
retry_config = getattr(self, '_retry_config', None)
|
||||
if not retry_config:
|
||||
return self._execute_unified_chat_completion(messages, temperature, tools, stream, reasoning_steps,
|
||||
task_name, task_description, task_id, response_format,
|
||||
stream_callback=stream_callback, emit_events=emit_events)
|
||||
stream_callback=stream_callback, emit_events=emit_events,
|
||||
cancel_token=cancel_token)
|
||||
|
||||
from .retry_utils import jittered_backoff
|
||||
from ..hooks import HookEvent, OnRetryInput
|
||||
@@ -5280,7 +5587,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Call the underlying unified chat completion directly to avoid infinite recursion
|
||||
return self._execute_unified_chat_completion(messages, temperature, tools, stream, reasoning_steps,
|
||||
task_name, task_description, task_id, response_format,
|
||||
stream_callback=stream_callback, emit_events=emit_events)
|
||||
stream_callback=stream_callback, emit_events=emit_events,
|
||||
cancel_token=cancel_token)
|
||||
|
||||
except Exception as e:
|
||||
from ..errors import LLMError
|
||||
@@ -5343,13 +5651,69 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# This should never be reached, but just in case
|
||||
raise RuntimeError("Retry loop completed without returning or raising an exception")
|
||||
|
||||
async def _achat_completion_with_retry(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
|
||||
async def _achat_completion_with_retry(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
|
||||
"""
|
||||
Async wrapper for _execute_unified_achat_completion that adds jittered exponential backoff retry logic.
|
||||
|
||||
This method wraps the async chat completion call and adds retry capability for
|
||||
transient failures like rate limits, network errors, and service outages.
|
||||
"""
|
||||
# Route through user-supplied model middleware (Agent(hooks=[...])) when
|
||||
# before_model/after_model/wrap_model_call hooks are registered, mirroring
|
||||
# the sync path so achat() honors the same hooks. The middleware chain is
|
||||
# synchronous; run it in an executor and bridge back to the async retry
|
||||
# core via the running loop so we never block or nest event loops.
|
||||
manager = self._get_model_middleware_manager()
|
||||
if manager is not None:
|
||||
import asyncio
|
||||
from ..hooks import ModelRequest, ModelResponse, InvocationContext
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _model_fn(req):
|
||||
effective_temperature = req.temperature if req.temperature is not None else temperature
|
||||
effective_tools = req.tools if req.tools is not None else tools
|
||||
fut = asyncio.run_coroutine_threadsafe(
|
||||
self._achat_completion_with_retry_core(
|
||||
req.messages, effective_temperature, effective_tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id,
|
||||
response_format, stream_callback=stream_callback,
|
||||
emit_events=emit_events,
|
||||
),
|
||||
loop,
|
||||
)
|
||||
result = fut.result()
|
||||
return ModelResponse(
|
||||
content=result if isinstance(result, str) else (result or ""),
|
||||
model=str(getattr(self, 'llm', '') or ''),
|
||||
extra={"raw": result},
|
||||
)
|
||||
|
||||
request = ModelRequest(
|
||||
messages=messages,
|
||||
model=str(getattr(self, 'llm', '') or ''),
|
||||
temperature=temperature if temperature is not None else 1.0,
|
||||
tools=tools,
|
||||
context=InvocationContext(
|
||||
agent_id=self.name,
|
||||
run_id=getattr(self, '_current_run_id', 'unknown'),
|
||||
session_id=getattr(self, '_session_id', None) or 'default',
|
||||
model_name=str(getattr(self, 'llm', '') or ''),
|
||||
),
|
||||
)
|
||||
response = await loop.run_in_executor(
|
||||
None, lambda: manager.execute_model_call(request, _model_fn)
|
||||
)
|
||||
return self._unwrap_model_response(response)
|
||||
|
||||
return await self._achat_completion_with_retry_core(
|
||||
messages, temperature, tools, stream, reasoning_steps,
|
||||
task_name, task_description, task_id, response_format,
|
||||
stream_callback=stream_callback, emit_events=emit_events,
|
||||
)
|
||||
|
||||
async def _achat_completion_with_retry_core(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
|
||||
"""Async retry/backoff core for chat completion (middleware-agnostic)."""
|
||||
retry_config = getattr(self, '_retry_config', None)
|
||||
if not retry_config:
|
||||
return await self._execute_unified_achat_completion(
|
||||
|
||||
@@ -59,6 +59,85 @@ def _accepts_idempotency_key(execute_tool_fn: Callable) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _normalize_restart_safe(value: Any) -> Optional[bool]:
|
||||
"""Accept only genuine booleans as a restart-safety declaration.
|
||||
|
||||
``None`` means undeclared. Any other type (e.g. the string ``"False"``) is a
|
||||
malformed declaration and is treated as unsafe so we fail closed rather than
|
||||
coercing a truthy value into an unintended replay.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return False
|
||||
|
||||
|
||||
def _canonical_tool_name(name: Any) -> str:
|
||||
"""Normalise a tool name for tolerant comparison across repair variants.
|
||||
|
||||
``execute_tool`` may repair case/separator variants (e.g. ``Send-Email`` →
|
||||
``send_email``); the declaration lookup mirrors that so an explicit
|
||||
``restart_safe`` declaration is not missed for a repaired name.
|
||||
"""
|
||||
return "".join(ch for ch in str(name).lower() if ch.isalnum())
|
||||
|
||||
|
||||
def _declared_restart_safe(
|
||||
function_name: str, executor: Optional[Callable]
|
||||
) -> Optional[bool]:
|
||||
"""Return an explicit ``restart_safe`` declaration for ``function_name``.
|
||||
|
||||
Resolves the tool object first from the executor's owning agent (its live
|
||||
``tools``), then from the global tool registry, and reads its
|
||||
``restart_safe`` attribute. Returns ``None`` when the tool is undeclared or
|
||||
cannot be resolved.
|
||||
"""
|
||||
if not function_name:
|
||||
return False
|
||||
target = _canonical_tool_name(function_name)
|
||||
agent = getattr(executor, "__self__", None)
|
||||
tools = getattr(agent, "tools", None)
|
||||
if isinstance(tools, (list, tuple)):
|
||||
for tool in tools:
|
||||
name = getattr(tool, "name", None) or getattr(tool, "__name__", None)
|
||||
if (
|
||||
name is not None
|
||||
and _canonical_tool_name(name) == target
|
||||
and getattr(tool, "restart_safe", None) is not None
|
||||
):
|
||||
return _normalize_restart_safe(tool.restart_safe)
|
||||
try:
|
||||
from ..tools import get_registry as _get_tool_registry
|
||||
|
||||
tool = _get_tool_registry().get(function_name)
|
||||
except Exception: # pragma: no cover - registry lookup is best-effort
|
||||
tool = None
|
||||
declared = getattr(tool, "restart_safe", None) if tool is not None else None
|
||||
return _normalize_restart_safe(declared)
|
||||
|
||||
|
||||
def _is_restart_safe(function_name: str, executor: Optional[Callable] = None) -> bool:
|
||||
"""Resolve whether a tool is safe to re-run for an in-flight durable resume.
|
||||
|
||||
An explicit ``restart_safe`` declaration on the tool contract wins. When the
|
||||
tool is undeclared (or cannot be resolved), fall back to the read-only name
|
||||
heuristic and, ultimately, fail closed — an effectful/unknown tool is *not*
|
||||
replayed so a missing declaration never causes a duplicated side effect.
|
||||
"""
|
||||
if not function_name:
|
||||
return False
|
||||
declared = _declared_restart_safe(function_name, executor)
|
||||
if declared is not None:
|
||||
return declared
|
||||
try:
|
||||
from .tool_execution import ToolExecutionMixin
|
||||
|
||||
return bool(ToolExecutionMixin._is_read_only_tool(function_name))
|
||||
except Exception: # pragma: no cover - heuristic import is best-effort
|
||||
return False
|
||||
|
||||
|
||||
class DurableRunContext:
|
||||
"""Per-turn journal state; safe for parallel tool-call wrappers."""
|
||||
|
||||
@@ -81,6 +160,10 @@ class DurableRunContext:
|
||||
self._results_by_tool_call_id: Dict[str, Any] = {}
|
||||
self._pending_by_call_id: Dict[str, Tuple[int, str, Dict[str, Any], str]] = {}
|
||||
self._pending_by_fingerprint: Dict[str, Tuple[int, str, Dict[str, Any], str]] = {}
|
||||
# Names of in-flight tool calls (journalled tool_call with no tool_result
|
||||
# at the crash). Used only when replaying to gate replay by restart-safety.
|
||||
self._inflight_names_by_call_id: Dict[str, str] = {}
|
||||
self._inflight_names_by_fingerprint: Dict[str, str] = {}
|
||||
self._recorded_iterations = set()
|
||||
for event in events:
|
||||
if event.kind == KIND_TOOL_RESULT:
|
||||
@@ -107,8 +190,11 @@ class DurableRunContext:
|
||||
or f"{self.run_id}:{event.seq}:{name}"
|
||||
)
|
||||
pending = (event.seq, call_id, args, key)
|
||||
fingerprint = self._fingerprint(name, args)
|
||||
self._pending_by_call_id[call_id] = pending
|
||||
self._pending_by_fingerprint[self._fingerprint(name, args)] = pending
|
||||
self._pending_by_fingerprint[fingerprint] = pending
|
||||
self._inflight_names_by_call_id[call_id] = name
|
||||
self._inflight_names_by_fingerprint[fingerprint] = name
|
||||
|
||||
self._resume_completed_steps = len(self._recorded_iterations)
|
||||
|
||||
@@ -120,6 +206,44 @@ class DurableRunContext:
|
||||
def _fingerprint(function_name: str, arguments: Dict[str, Any]) -> str:
|
||||
return f"{function_name}:{json.dumps(_json_safe(arguments), sort_keys=True)}"
|
||||
|
||||
def _is_inflight_call(
|
||||
self,
|
||||
function_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
tool_call_id: Optional[str],
|
||||
) -> bool:
|
||||
"""Whether this call was in-flight (journalled, no result) at the crash.
|
||||
|
||||
Only meaningful while replaying: such calls have a recorded ``tool_call``
|
||||
but no ``tool_result``, so re-driving the loop would re-execute their
|
||||
side effect. Completed calls (already have a result) are replayed
|
||||
upstream and never reach this check.
|
||||
"""
|
||||
if not self.replaying:
|
||||
return False
|
||||
call_id = str(tool_call_id) if tool_call_id is not None else None
|
||||
if call_id and call_id in self._inflight_names_by_call_id:
|
||||
return True
|
||||
return self._fingerprint(function_name, arguments) in (
|
||||
self._inflight_names_by_fingerprint
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _not_safely_resumable_result(function_name: str) -> Dict[str, Any]:
|
||||
"""Typed, operator-visible outcome for a gated in-flight effectful tool."""
|
||||
return {
|
||||
"error": (
|
||||
f"Tool '{function_name}' was in-flight when the durable run "
|
||||
"crashed and is not declared restart_safe; its outcome could not "
|
||||
"be confirmed. It was not re-executed on resume to avoid a "
|
||||
"duplicate side effect. Reconcile the external action manually "
|
||||
"or mark the tool @tool(restart_safe=True) if it is idempotent."
|
||||
),
|
||||
"error_type": "NotSafelyResumable",
|
||||
"not_safely_resumable": True,
|
||||
"restart_safe": False,
|
||||
}
|
||||
|
||||
def restore_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Append completed decision/result pairs once, before the next model call."""
|
||||
if not self.replaying or self._restored:
|
||||
@@ -307,9 +431,19 @@ class DurableRunContext:
|
||||
call_id = str(tool_call_id) if tool_call_id is not None else None
|
||||
if call_id and call_id in self._results_by_tool_call_id:
|
||||
return self._results_by_tool_call_id[call_id]
|
||||
gate_inflight = self._is_inflight_call(
|
||||
function_name, arguments, tool_call_id
|
||||
) and not _is_restart_safe(function_name, execute_tool_fn)
|
||||
seq, call_id, idempotency_key = self._allocate_step(
|
||||
function_name, arguments, tool_call_id
|
||||
)
|
||||
if gate_inflight:
|
||||
return self._record_result(
|
||||
seq,
|
||||
call_id,
|
||||
self._not_safely_resumable_result(function_name),
|
||||
iteration_index,
|
||||
)
|
||||
token = _active_idempotency_key.set(idempotency_key)
|
||||
try:
|
||||
call_kwargs = dict(kwargs)
|
||||
@@ -340,9 +474,20 @@ class DurableRunContext:
|
||||
call_id = str(tool_call_id) if tool_call_id is not None else None
|
||||
if call_id and call_id in self._results_by_tool_call_id:
|
||||
return self._results_by_tool_call_id[call_id]
|
||||
gate_inflight = self._is_inflight_call(
|
||||
function_name, arguments, tool_call_id
|
||||
) and not _is_restart_safe(function_name, execute_tool_fn)
|
||||
seq, call_id, idempotency_key = await asyncio.to_thread(
|
||||
self._allocate_step, function_name, arguments, tool_call_id
|
||||
)
|
||||
if gate_inflight:
|
||||
return await asyncio.to_thread(
|
||||
self._record_result,
|
||||
seq,
|
||||
call_id,
|
||||
self._not_safely_resumable_result(function_name),
|
||||
iteration_index,
|
||||
)
|
||||
token = _active_idempotency_key.set(idempotency_key)
|
||||
try:
|
||||
call_kwargs = dict(kwargs)
|
||||
|
||||
@@ -1568,7 +1568,18 @@ Write the complete compiled report:"""
|
||||
result.get("_praison_retryable") is False):
|
||||
result.pop("_praison_retryable", None)
|
||||
return result
|
||||
|
||||
|
||||
# The body already ran and may have completed its side effect
|
||||
# before failing. Honour an explicit restart_safe=False /
|
||||
# idempotent=False declaration, exactly as the sync loops do
|
||||
# (tool_execution.py:884 and :2115). Without this the async
|
||||
# path accepts the declaration and silently re-runs the tool
|
||||
# up to max_attempts times -- charging a card three times for
|
||||
# a tool the author marked "must never be silently re-executed".
|
||||
if self._tool_declares_not_idempotent(function_name, tools_override):
|
||||
result.pop("_praison_retryable", None)
|
||||
return result
|
||||
|
||||
# Determine error type for retry policy
|
||||
error_type = self._classify_error_type(result, last_exception)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from typing import Optional, Any, Callable, Dict, List, Union, TYPE_CHECKING, Li
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import inspect
|
||||
from contextlib import contextmanager
|
||||
from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
import contextvars
|
||||
@@ -469,12 +470,46 @@ class Handoff:
|
||||
return value
|
||||
return 'gpt-4o-mini'
|
||||
|
||||
@contextmanager
|
||||
def _seed_target_history(self, prior_messages: Optional[List[Any]]):
|
||||
"""Temporarily seed the filtered handoff context onto the target agent.
|
||||
|
||||
The configured ``context_policy``/``input_filter`` produce the messages the
|
||||
target should see; without seeding them the target only ever received the
|
||||
literal handoff prompt string and the configured policy had no effect.
|
||||
|
||||
The seeding is *invocation-scoped*: the prior messages are prepended for the
|
||||
duration of this handoff and the target's original ``chat_history`` is
|
||||
restored on exit. This keeps handoff context from leaking into later
|
||||
handoffs or ordinary chats that reuse the same target agent, and stops the
|
||||
target prompt from growing across sequential handoffs.
|
||||
No shared mutable state is retained between agents.
|
||||
"""
|
||||
existing = getattr(self.agent, 'chat_history', None)
|
||||
if not prior_messages or not isinstance(existing, list):
|
||||
yield
|
||||
return
|
||||
try:
|
||||
prior = list(prior_messages)
|
||||
except TypeError:
|
||||
yield
|
||||
return
|
||||
original = existing
|
||||
try:
|
||||
# Avoid re-seeding if the context is already at the head.
|
||||
if existing[:len(prior)] != prior:
|
||||
self.agent.chat_history = prior + existing
|
||||
yield
|
||||
finally:
|
||||
self.agent.chat_history = original
|
||||
|
||||
def _execute_with_runtime_resolution(
|
||||
self,
|
||||
source_agent: 'Agent',
|
||||
prompt: str,
|
||||
effective_tools: Optional[List[Any]],
|
||||
context: Dict[str, Any]
|
||||
context: Dict[str, Any],
|
||||
prior_messages: Optional[List[Any]] = None,
|
||||
) -> str:
|
||||
"""Execute handoff through the target agent's full chat pipeline."""
|
||||
target_model_ref = self._extract_model_ref(self.agent)
|
||||
@@ -484,14 +519,16 @@ class Handoff:
|
||||
target_model_ref,
|
||||
_get_handoff_depth(),
|
||||
)
|
||||
return self.agent.chat(prompt, tools=effective_tools)
|
||||
with self._seed_target_history(prior_messages):
|
||||
return self.agent.chat(prompt, tools=effective_tools)
|
||||
|
||||
async def _execute_with_runtime_resolution_async(
|
||||
self,
|
||||
source_agent: 'Agent',
|
||||
prompt: str,
|
||||
effective_tools: Optional[List[Any]],
|
||||
context: Dict[str, Any]
|
||||
context: Dict[str, Any],
|
||||
prior_messages: Optional[List[Any]] = None,
|
||||
) -> str:
|
||||
"""Execute handoff through the target agent's full async chat pipeline."""
|
||||
target_model_ref = self._extract_model_ref(self.agent)
|
||||
@@ -501,13 +538,14 @@ class Handoff:
|
||||
target_model_ref,
|
||||
_get_handoff_depth(),
|
||||
)
|
||||
async_chat = getattr(self.agent, 'achat', None)
|
||||
if callable(async_chat) and inspect.iscoroutinefunction(async_chat):
|
||||
return await async_chat(prompt, tools=effective_tools)
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, lambda: self.agent.chat(prompt, tools=effective_tools)
|
||||
)
|
||||
with self._seed_target_history(prior_messages):
|
||||
async_chat = getattr(self.agent, 'achat', None)
|
||||
if callable(async_chat) and inspect.iscoroutinefunction(async_chat):
|
||||
return await async_chat(prompt, tools=effective_tools)
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, lambda: self.agent.chat(prompt, tools=effective_tools)
|
||||
)
|
||||
|
||||
def _check_safety(self, source_agent: 'Agent') -> None:
|
||||
"""
|
||||
@@ -661,8 +699,9 @@ class Handoff:
|
||||
# Execute on_handoff callback
|
||||
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
|
||||
|
||||
# Prepare context (for future extensibility - context data available for hooks)
|
||||
_ = self._prepare_context(source_agent, kwargs)
|
||||
# Prepare context per the configured context_policy/input_filter and
|
||||
# thread the filtered history through to the target agent.
|
||||
handoff_data = self._prepare_context(source_agent, kwargs)
|
||||
|
||||
# Build prompt with context
|
||||
context_prefix = f"[Handoff from {source_agent.name}] "
|
||||
@@ -677,7 +716,8 @@ class Handoff:
|
||||
|
||||
# Resolve runtime at turn-time instead of using construction-time pin
|
||||
response = self._execute_with_runtime_resolution(
|
||||
source_agent, full_prompt, effective_tools, kwargs
|
||||
source_agent, full_prompt, effective_tools, kwargs,
|
||||
prior_messages=handoff_data.messages,
|
||||
)
|
||||
|
||||
result = HandoffResult(
|
||||
@@ -769,8 +809,9 @@ class Handoff:
|
||||
# Execute callback
|
||||
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
|
||||
|
||||
# Prepare context (for future extensibility)
|
||||
_ = self._prepare_context(source_agent, kwargs)
|
||||
# Prepare context per the configured context_policy/input_filter
|
||||
# and thread the filtered history through to the target agent.
|
||||
handoff_data = self._prepare_context(source_agent, kwargs)
|
||||
|
||||
# Build prompt
|
||||
context_prefix = f"[Handoff from {source_agent.name}] "
|
||||
@@ -785,7 +826,8 @@ class Handoff:
|
||||
|
||||
# Resolve runtime at turn-time for async execution
|
||||
response = await self._execute_with_runtime_resolution_async(
|
||||
source_agent, full_prompt, effective_tools, kwargs
|
||||
source_agent, full_prompt, effective_tools, kwargs,
|
||||
prior_messages=handoff_data.messages,
|
||||
)
|
||||
|
||||
result = HandoffResult(
|
||||
@@ -898,20 +940,39 @@ class Handoff:
|
||||
# Prepare handoff data with context policy
|
||||
handoff_data = self._prepare_context(source_agent, kwargs)
|
||||
|
||||
# Get the last user message or context to pass to target agent
|
||||
# Get the last user message or context to pass to target agent.
|
||||
# Track the index of the message chosen for ``prompt`` so exactly
|
||||
# that item is excluded from the seeded history (it may not be the
|
||||
# final list item if an assistant/tool message follows it).
|
||||
last_message = None
|
||||
for msg in reversed(handoff_data.messages):
|
||||
selected_index = None
|
||||
for i in range(len(handoff_data.messages) - 1, -1, -1):
|
||||
msg = handoff_data.messages[i]
|
||||
if isinstance(msg, dict) and msg.get('role') == 'user':
|
||||
last_message = msg.get('content', '')
|
||||
selected_index = i
|
||||
break
|
||||
|
||||
if not last_message and handoff_data.messages:
|
||||
last_msg = handoff_data.messages[-1]
|
||||
selected_index = len(handoff_data.messages) - 1
|
||||
last_msg = handoff_data.messages[selected_index]
|
||||
if isinstance(last_msg, dict):
|
||||
last_message = last_msg.get('content', '')
|
||||
else:
|
||||
last_message = str(last_msg)
|
||||
|
||||
|
||||
# Tool-style handoff (as_tool): ContextPolicy.NONE discards the
|
||||
# history, so the prompt must come from the generated ``task``/
|
||||
# ``prompt`` argument instead. Without this the sub-agent would
|
||||
# never run and the tool would report a hollow "completed". The
|
||||
# task is the sub-agent's whole instruction, so it is passed
|
||||
# verbatim rather than wrapped in a ``[Handoff from …]`` prefix.
|
||||
task_prompt = None
|
||||
if not last_message and not self.input_type:
|
||||
task_prompt = kwargs.get('task') or kwargs.get('prompt')
|
||||
if task_prompt:
|
||||
last_message = str(task_prompt)
|
||||
|
||||
# Prepare context information
|
||||
context_info = f"[Handoff from {source_agent.name}] "
|
||||
if kwargs and self.input_type:
|
||||
@@ -919,15 +980,26 @@ class Handoff:
|
||||
|
||||
# Execute the target agent with tool boundary enforcement
|
||||
if last_message:
|
||||
prompt = context_info + last_message
|
||||
prompt = last_message if task_prompt else context_info + last_message
|
||||
logger.info(f"Handing off to {self.agent.name} with prompt: {prompt}")
|
||||
|
||||
# Compute effective tools based on tool policy
|
||||
effective_tools = self._compute_effective_tools(source_agent)
|
||||
|
||||
# Resolve runtime at turn-time for tool function execution
|
||||
# Resolve runtime at turn-time for tool function execution.
|
||||
# The selected message is already folded into ``prompt`` above,
|
||||
# so exclude exactly that item from the seeded history to avoid
|
||||
# duplicating it while preserving any messages that follow it.
|
||||
if selected_index is None:
|
||||
prior_messages = list(handoff_data.messages)
|
||||
else:
|
||||
prior_messages = (
|
||||
handoff_data.messages[:selected_index]
|
||||
+ handoff_data.messages[selected_index + 1:]
|
||||
)
|
||||
response = self._execute_with_runtime_resolution(
|
||||
source_agent, prompt, effective_tools, kwargs
|
||||
source_agent, prompt, effective_tools, kwargs,
|
||||
prior_messages=prior_messages,
|
||||
)
|
||||
|
||||
result = HandoffResult(
|
||||
@@ -995,6 +1067,17 @@ class Handoff:
|
||||
)
|
||||
)
|
||||
handoff_tool.__signature__ = inspect.Signature(sig_params)
|
||||
elif self.config.context_policy == ContextPolicy.NONE:
|
||||
# Tool-style handoff (as_tool): no conversation context is passed, so
|
||||
# advertise an explicit ``task`` parameter the caller must fill. This
|
||||
# is what ``handoff_tool`` reads as the sub-agent's prompt above.
|
||||
handoff_tool.__signature__ = inspect.Signature([
|
||||
inspect.Parameter(
|
||||
'task',
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
annotation=str,
|
||||
)
|
||||
])
|
||||
|
||||
return handoff_tool
|
||||
|
||||
@@ -1468,8 +1551,9 @@ class TypedHandoff(Handoff, Generic[T]):
|
||||
# Execute on_handoff callback
|
||||
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
|
||||
|
||||
# Apply input filter if provided (fixes Greptile P1 issue)
|
||||
_ = self._prepare_context(source_agent, kwargs)
|
||||
# Apply context policy / input filter; the resulting messages are
|
||||
# seeded onto the target only for the duration of its chat below.
|
||||
handoff_data = self._prepare_context(source_agent, kwargs)
|
||||
|
||||
# Validate payload against schema
|
||||
try:
|
||||
@@ -1496,7 +1580,8 @@ class TypedHandoff(Handoff, Generic[T]):
|
||||
logger.info(f"Typed handoff to {self.agent.name} with validated payload")
|
||||
|
||||
# Execute with timeout if sync
|
||||
response = self.agent.chat(full_prompt)
|
||||
with self._seed_target_history(handoff_data.messages):
|
||||
response = self.agent.chat(full_prompt)
|
||||
|
||||
result = HandoffResult(
|
||||
success=True,
|
||||
@@ -1581,8 +1666,9 @@ class TypedHandoff(Handoff, Generic[T]):
|
||||
# Execute callback
|
||||
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
|
||||
|
||||
# Apply input filter if provided (fixes Greptile P1 issue)
|
||||
_ = self._prepare_context(source_agent, kwargs)
|
||||
# Apply context policy / input filter; the resulting messages are
|
||||
# seeded onto the target only for the duration of its chat below.
|
||||
handoff_data = self._prepare_context(source_agent, kwargs)
|
||||
|
||||
# Validate payload against schema
|
||||
try:
|
||||
@@ -1608,12 +1694,13 @@ class TypedHandoff(Handoff, Generic[T]):
|
||||
logger.info(f"Async typed handoff to {self.agent.name}")
|
||||
|
||||
# Execute - check for async chat method
|
||||
if hasattr(self.agent, 'achat'):
|
||||
response = await self.agent.achat(full_prompt)
|
||||
else:
|
||||
# Run sync chat in executor
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(None, self.agent.chat, full_prompt)
|
||||
with self._seed_target_history(handoff_data.messages):
|
||||
if hasattr(self.agent, 'achat'):
|
||||
response = await self.agent.achat(full_prompt)
|
||||
else:
|
||||
# Run sync chat in executor
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(None, self.agent.chat, full_prompt)
|
||||
|
||||
return HandoffResult(
|
||||
success=True,
|
||||
|
||||
@@ -850,7 +850,11 @@ class ToolExecutionMixin:
|
||||
# a transient the inner loop did NOT already re-drive:
|
||||
# • _outer_timeout — this loop's own wall-clock guard
|
||||
# tripped; the body ran once and the inner budget
|
||||
# never saw it, so it earns one outer pass.
|
||||
# never saw it. BUT the timed-out body is still
|
||||
# running in an orphaned thread (future.cancel() is
|
||||
# a no-op once started), so retrying a mutating tool
|
||||
# would duplicate its side effects. Only re-drive it
|
||||
# when the tool is known idempotent/read-only.
|
||||
# • circuit_open — returned by the inner loop WITHOUT a
|
||||
# body run, so retrying costs no duplicate side effect.
|
||||
# • _praison_retryable — a raised exception was flattened
|
||||
@@ -868,10 +872,16 @@ class ToolExecutionMixin:
|
||||
# RetryPolicy.retry_on the inner loop honours — not a word
|
||||
# in the tool's own payload. Surface it once.
|
||||
is_retryable = bool(
|
||||
result.get("_outer_timeout")
|
||||
(result.get("_outer_timeout")
|
||||
and self._is_tool_idempotent(function_name))
|
||||
or result.get("circuit_open")
|
||||
or (result.get("_praison_retryable") is True
|
||||
and error_type not in inner_policy.retry_on)
|
||||
and error_type not in inner_policy.retry_on
|
||||
# The body already ran and may have completed
|
||||
# its side effect before raising. Honour an
|
||||
# explicit non-idempotent declaration here, as
|
||||
# the _outer_timeout clause above already does.
|
||||
and not self._tool_declares_not_idempotent(function_name))
|
||||
)
|
||||
# Strip the private control-plane tag before it can reach
|
||||
# the model or be re-surfaced as the tool's payload.
|
||||
@@ -1948,7 +1958,7 @@ class ToolExecutionMixin:
|
||||
)
|
||||
return any(marker in name for marker in edit_markers)
|
||||
|
||||
def _check_permission_manager_deny(self, function_name):
|
||||
def _check_permission_manager_deny(self, function_name, arguments=None):
|
||||
"""Return an error dict if the PermissionManager hard-denies the tool.
|
||||
|
||||
Ensures the pattern-based ``PermissionManager`` (rules from
|
||||
@@ -1956,18 +1966,51 @@ class ToolExecutionMixin:
|
||||
uniformly — native functions **and** MCP tools — since both flow through
|
||||
``_execute_tool_impl``. Returns ``None`` when no manager is attached or
|
||||
the tool is not denied, preserving backward compatibility.
|
||||
|
||||
Both the tool *name* and the argument-scoped target (e.g.
|
||||
``bash:rm -rf /tmp/x`` built from ``execute_command(command=...)``) are
|
||||
checked so an argument-level ``deny`` rule — which the rule engine can
|
||||
already match — actually fires. Without this the deny tier was a
|
||||
name-only decision: once a shell tool was allowed by name, no rule could
|
||||
narrow it. The argument target reuses ``build_permission_target`` so it
|
||||
speaks the same vocabulary as the allow side's scoped grants.
|
||||
"""
|
||||
manager = getattr(self, "_permission_manager", None)
|
||||
if manager is None:
|
||||
return None
|
||||
try:
|
||||
if manager.is_denied(function_name, getattr(self, "name", None)):
|
||||
return {
|
||||
"error": f"Tool '{function_name}' blocked by permission policy",
|
||||
"permission_denied": True,
|
||||
}
|
||||
except Exception as e:
|
||||
logging.debug("permission manager is_denied failed for %s: %s", function_name, e)
|
||||
agent_name = getattr(self, "name", None)
|
||||
targets = [function_name]
|
||||
if arguments:
|
||||
try:
|
||||
from ..approval.utils import build_permission_target
|
||||
# Normalise argument keys the same way ``_cast_arguments`` does
|
||||
# before dispatch (strip trailing '='/whitespace LLMs hallucinate)
|
||||
# so ``command=`` still resolves to the ``command`` command-key
|
||||
# and an argument-scoped deny (``bash:rm *``) cannot be evaded by
|
||||
# a malformed kwarg name that only normalises *after* this gate.
|
||||
normalized_args = arguments
|
||||
if isinstance(arguments, dict):
|
||||
normalized_args = {
|
||||
(k.strip().rstrip('=').strip() if isinstance(k, str) else k): v
|
||||
for k, v in arguments.items()
|
||||
}
|
||||
scoped_target = build_permission_target(function_name, normalized_args)
|
||||
if scoped_target and scoped_target != function_name:
|
||||
targets.append(scoped_target)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logging.debug(
|
||||
"could not build argument-scoped permission target for %s: %s",
|
||||
function_name, e,
|
||||
)
|
||||
for target in targets:
|
||||
try:
|
||||
if manager.is_denied(target, agent_name):
|
||||
return {
|
||||
"error": f"Tool '{function_name}' blocked by permission policy",
|
||||
"permission_denied": True,
|
||||
}
|
||||
except Exception as e:
|
||||
logging.debug("permission manager is_denied failed for %s: %s", target, e)
|
||||
return None
|
||||
|
||||
def _is_bypass_mode(self) -> bool:
|
||||
@@ -2002,7 +2045,7 @@ class ToolExecutionMixin:
|
||||
# Pattern-based PermissionManager deny gate (native + MCP, uniform).
|
||||
# BYPASS mode skips this gate as its contract requires.
|
||||
if not self._is_bypass_mode():
|
||||
manager_denial = self._check_permission_manager_deny(function_name)
|
||||
manager_denial = self._check_permission_manager_deny(function_name, arguments)
|
||||
if manager_denial is not None:
|
||||
return manager_denial
|
||||
|
||||
@@ -2022,6 +2065,14 @@ class ToolExecutionMixin:
|
||||
if decision.modified_args:
|
||||
arguments = decision.modified_args
|
||||
logging.info(f"Using modified arguments: {arguments}")
|
||||
# Re-authorize: an approval backend may have rewritten the arguments
|
||||
# into a command that an argument-scoped deny rule prohibits. The
|
||||
# earlier gate only saw the original args, so re-run it against the
|
||||
# final ones before dispatch. BYPASS still opts out entirely.
|
||||
if not self._is_bypass_mode():
|
||||
manager_denial = self._check_permission_manager_deny(function_name, arguments)
|
||||
if manager_denial is not None:
|
||||
return manager_denial
|
||||
|
||||
from ..approval import get_approval_registry
|
||||
get_approval_registry().mark_approved(
|
||||
@@ -2040,7 +2091,7 @@ class ToolExecutionMixin:
|
||||
# Pattern-based PermissionManager deny gate (native + MCP, uniform).
|
||||
# BYPASS mode skips this gate as its contract requires.
|
||||
if not self._is_bypass_mode():
|
||||
manager_denial = self._check_permission_manager_deny(function_name)
|
||||
manager_denial = self._check_permission_manager_deny(function_name, arguments)
|
||||
if manager_denial is not None:
|
||||
return manager_denial
|
||||
|
||||
@@ -2061,6 +2112,12 @@ class ToolExecutionMixin:
|
||||
if decision.modified_args:
|
||||
arguments = decision.modified_args
|
||||
logging.info(f"Using modified arguments: {arguments}")
|
||||
# Re-authorize modified args against the deny gate — see the sync
|
||||
# path for rationale. BYPASS still opts out entirely.
|
||||
if not self._is_bypass_mode():
|
||||
manager_denial = self._check_permission_manager_deny(function_name, arguments)
|
||||
if manager_denial is not None:
|
||||
return manager_denial
|
||||
|
||||
from ..approval import get_approval_registry
|
||||
get_approval_registry().mark_approved(
|
||||
@@ -2098,7 +2155,13 @@ class ToolExecutionMixin:
|
||||
result.get("guardrail_denied") or
|
||||
result.get("circuit_open")):
|
||||
return result
|
||||
|
||||
|
||||
# The body already ran and may have completed its side effect
|
||||
# before failing. Honour an explicit non-idempotent declaration
|
||||
# rather than re-driving it (send a second email, charge twice).
|
||||
if self._tool_declares_not_idempotent(function_name):
|
||||
return result
|
||||
|
||||
# Determine error type for retry policy
|
||||
error_type = self._classify_error_type(result, last_exception)
|
||||
|
||||
@@ -2980,6 +3043,104 @@ class ToolExecutionMixin:
|
||||
else:
|
||||
return f"Unknown bridge tool: {function_name}"
|
||||
|
||||
def _tool_declares_not_idempotent(self, tool_name, tools_override=None):
|
||||
"""True only when the tool *explicitly* declares itself unsafe to re-run.
|
||||
|
||||
Deliberately narrower than ``_is_tool_idempotent``, which answers False
|
||||
for unknown tools as a safe default: gating a retry on that would stop
|
||||
retrying every unregistered user tool.
|
||||
|
||||
This keys on an explicit declaration and nothing else. In particular it
|
||||
must NOT consult the shared ``MUTATING_TOOLS`` name registry -- that is a
|
||||
73-name list maintained for the escalation loop guard, and using it here
|
||||
silently removed the retry from any *undeclared* tool that happened to
|
||||
share a name with an entry (``write_file``, ``store_memory``, ``mkdir``...).
|
||||
|
||||
``tools_override`` mirrors the resolution order in ``_execute_tool_impl`` /
|
||||
``_execute_tool_async_impl``: a task-scoped tool passed via ``tools_override``
|
||||
shadows an agent-level tool of the same name, so its declaration must win.
|
||||
Without this an override-only ``restart_safe=False`` tool would be missed
|
||||
here and silently retried.
|
||||
"""
|
||||
tools = getattr(self, 'tools', [])
|
||||
if not isinstance(tools, (list, tuple)):
|
||||
tools = []
|
||||
if isinstance(tools_override, (list, tuple)):
|
||||
# Override tools take precedence and are searched first.
|
||||
tools = list(tools_override) + list(tools)
|
||||
for tool in tools:
|
||||
name_attr = getattr(tool, '__name__', None) or getattr(tool, 'name', None)
|
||||
if name_attr == tool_name:
|
||||
explicit = getattr(tool, 'idempotent', None)
|
||||
if not isinstance(explicit, bool):
|
||||
# ``restart_safe`` is the public replay-safety contract on
|
||||
# ``@tool`` (FunctionTool) and ``BaseTool``: ``False`` marks an
|
||||
# effectful tool that must never be silently re-executed.
|
||||
explicit = getattr(tool, 'restart_safe', None)
|
||||
if isinstance(explicit, bool):
|
||||
return not explicit
|
||||
return False
|
||||
# A tool from the process-global registry (only reachable under
|
||||
# ToolConfig(allow_global_tools=True)) can carry its own declaration;
|
||||
# ``_is_tool_idempotent`` consults it, so this must too.
|
||||
if getattr(self, '_allow_global_tools', False):
|
||||
try:
|
||||
from ..tools.registry import get_registry
|
||||
global_tool = get_registry().get(tool_name)
|
||||
for attr in ('idempotent', 'restart_safe'):
|
||||
explicit = getattr(global_tool, attr, None)
|
||||
if isinstance(explicit, bool):
|
||||
return not explicit
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _is_tool_idempotent(self, tool_name):
|
||||
"""Whether re-running ``tool_name`` is safe (no duplicated side effects).
|
||||
|
||||
Used to decide if an OUTER wall-clock timeout may be retried: the timed-out
|
||||
tool body is still running in an orphaned thread (``future.cancel()`` is a
|
||||
no-op once started), so auto-retrying a mutating tool would duplicate its
|
||||
side effects (send a second email, charge twice). Only tools classified as
|
||||
read-only/idempotent are safe to re-drive.
|
||||
|
||||
Precedence: explicit ``idempotent`` attribute on the tool > the shared
|
||||
read-only/mutating name registry (reused from the escalation loop guard).
|
||||
Unknown tools default to NOT idempotent (safe: surface the timeout once).
|
||||
"""
|
||||
tools = getattr(self, 'tools', [])
|
||||
if not isinstance(tools, (list, tuple)):
|
||||
tools = []
|
||||
for tool in tools:
|
||||
tool_name_attr = getattr(tool, '__name__', None) or getattr(tool, 'name', None)
|
||||
if tool_name_attr == tool_name:
|
||||
explicit = getattr(tool, 'idempotent', None)
|
||||
if isinstance(explicit, bool):
|
||||
return explicit
|
||||
break
|
||||
# A tool from the process-global registry (only reachable when
|
||||
# ToolConfig(allow_global_tools=True)) can carry its own explicit
|
||||
# ``idempotent`` flag even when its name is absent from the shared
|
||||
# read-only/mutating registries below.
|
||||
if getattr(self, '_allow_global_tools', False):
|
||||
try:
|
||||
from ..tools.registry import get_registry
|
||||
global_tool = get_registry().get(tool_name)
|
||||
explicit = getattr(global_tool, 'idempotent', None)
|
||||
if isinstance(explicit, bool):
|
||||
return explicit
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from ..escalation.loop_guard import IDEMPOTENT_TOOLS, MUTATING_TOOLS
|
||||
except Exception:
|
||||
return False
|
||||
if tool_name in IDEMPOTENT_TOOLS:
|
||||
return True
|
||||
if tool_name in MUTATING_TOOLS:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _get_tool_retry_policy(self, tool_name):
|
||||
"""Get retry policy for a tool (tool-level > agent-level > default).
|
||||
|
||||
|
||||
@@ -716,8 +716,11 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
# The resolved value is the default model for the members: applied to
|
||||
# every agent that never named a model of its own; an agent that did
|
||||
# keeps it. Same rule AgentFlow uses for the agents it builds.
|
||||
from ..utils.model_alias import resolve_model_alias
|
||||
self.llm = resolve_model_alias(llm, model, type(self).__name__)
|
||||
# Must UNWRAP, not just alias-resolve: this value is pushed into every
|
||||
# member via _apply_default_llm, and a member holding an LLMConfig fails
|
||||
# inside chat() with "'LLMConfig' object has no attribute 'lower'".
|
||||
from ..utils.model_alias import resolve_model_name
|
||||
self.llm = resolve_model_name(llm, model, type(self).__name__)
|
||||
if self.llm:
|
||||
for _member in (agents.values() if isinstance(agents, dict) else (agents or [])):
|
||||
_apply = getattr(_member, "_apply_default_llm", None)
|
||||
@@ -1543,6 +1546,20 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
return False
|
||||
|
||||
def _should_continue_on_dep_failure(self, task_id):
|
||||
"""True if the task opted into graceful degradation via its own flags.
|
||||
|
||||
Honors the documented per-task flow-control knobs ``skip_on_failure``
|
||||
and ``on_error`` (``"continue"``). When set, a dependent task still runs
|
||||
(in degraded mode) instead of being force-failed when an upstream
|
||||
dependency fails.
|
||||
"""
|
||||
task = self.tasks.get(task_id)
|
||||
if task is None:
|
||||
return False
|
||||
return bool(getattr(task, 'skip_on_failure', False)) or \
|
||||
getattr(task, 'on_error', 'stop') == 'continue'
|
||||
|
||||
async def arun_all_tasks(self):
|
||||
"""Async version of run_all_tasks method"""
|
||||
process = Process(
|
||||
@@ -1565,8 +1582,9 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
await self._gather_with_isolation([c for _, c in tasks_to_run])
|
||||
tasks_to_run = []
|
||||
# A just-flushed prerequisite may now be failed; don't
|
||||
# queue a dependent that would run with missing context.
|
||||
if self._deps_failed(task_id):
|
||||
# queue a dependent that would run with missing context
|
||||
# unless it opted into graceful degradation.
|
||||
if self._deps_failed(task_id) and not self._should_continue_on_dep_failure(task_id):
|
||||
self.tasks[task_id].status = "failed"
|
||||
continue
|
||||
tasks_to_run.append((task_id, self.arun_task(task_id)))
|
||||
@@ -1605,8 +1623,9 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
if self._depends_on_pending(task, async_tasks_to_run):
|
||||
await flush_async_tasks()
|
||||
# A just-flushed prerequisite may now be failed; don't
|
||||
# queue a dependent that would run with missing context.
|
||||
if self._deps_failed(task_id):
|
||||
# queue a dependent that would run with missing context
|
||||
# unless it opted into graceful degradation.
|
||||
if self._deps_failed(task_id) and not self._should_continue_on_dep_failure(task_id):
|
||||
self.tasks[task_id].status = "failed"
|
||||
continue
|
||||
# Collect async tasks to run in parallel
|
||||
@@ -1615,8 +1634,9 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
# Before running a sync task, execute all pending async tasks
|
||||
await flush_async_tasks()
|
||||
# A just-flushed async prerequisite may now be failed; skip
|
||||
# the dependent so we don't run it with missing upstream context.
|
||||
if self._deps_failed(task_id):
|
||||
# the dependent so we don't run it with missing upstream
|
||||
# context, unless it opted into graceful degradation.
|
||||
if self._deps_failed(task_id) and not self._should_continue_on_dep_failure(task_id):
|
||||
self.tasks[task_id].status = "failed"
|
||||
continue
|
||||
# Run sync task in an executor to avoid blocking the event loop
|
||||
|
||||
@@ -32,6 +32,12 @@ class ApprovalRequest:
|
||||
approval_id: Stable correlation id that survives a process restart
|
||||
and can be matched to an inbound channel callback.
|
||||
Auto-generated when not supplied.
|
||||
authorized_reviewers: Optional set of reviewer identities allowed to
|
||||
resolve this request. ``None`` (default) keeps today's
|
||||
behaviour (any scope-holder may resolve). When populated a
|
||||
backend may bind resolution to these reviewers so that in a
|
||||
multi-operator deployment a request addressed to one
|
||||
operator cannot be resolved by another.
|
||||
"""
|
||||
|
||||
tool_name: str
|
||||
@@ -41,6 +47,7 @@ class ApprovalRequest:
|
||||
session_id: Optional[str] = None
|
||||
context: Dict[str, Any] = field(default_factory=dict)
|
||||
approval_id: str = field(default_factory=_new_approval_id)
|
||||
authorized_reviewers: Optional[List[str]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -89,6 +89,11 @@ class BotConfig:
|
||||
# replaces it with done_emoji on completion (e.g. "✅").
|
||||
ack_emoji: str = ""
|
||||
done_emoji: str = "✅"
|
||||
# Which inbound messages get an ack reaction. One of:
|
||||
# "off" | "direct" | "group-mentions" | "group-all" | "all".
|
||||
# Default "group-mentions": ack DMs and group mentions/replies, but stay
|
||||
# quiet on ambient group chatter the bot won't answer.
|
||||
ack_scope: str = "group-mentions"
|
||||
|
||||
# Session TTL in seconds. 0 = disabled (sessions never expire).
|
||||
# When set, stale sessions older than this are auto-reaped.
|
||||
@@ -158,6 +163,7 @@ class BotConfig:
|
||||
"debounce_ms": self.debounce_ms,
|
||||
"ack_emoji": self.ack_emoji,
|
||||
"done_emoji": self.done_emoji,
|
||||
"ack_scope": self.ack_scope,
|
||||
"session_ttl": self.session_ttl,
|
||||
"busy_mode": self.busy_mode,
|
||||
"busy_ack": self.busy_ack,
|
||||
|
||||
@@ -263,7 +263,7 @@ def create_memory_flush_agent(
|
||||
) -> Any:
|
||||
"""Create a restricted child agent sharing only the parent's memory store."""
|
||||
from ..agent.agent import Agent
|
||||
from ..config.feature_configs import ExecutionConfig
|
||||
from ..config.feature_configs import ExecutionConfig, OutputConfig
|
||||
from ..tools.memory import search_memory, store_memory
|
||||
|
||||
llm = config.llm or getattr(parent_agent, "llm", None)
|
||||
@@ -286,8 +286,7 @@ def create_memory_flush_agent(
|
||||
else getattr(parent_agent, "_memory_instance", None)
|
||||
),
|
||||
execution=ExecutionConfig(context_compaction=False, max_steps=3),
|
||||
verbose=False,
|
||||
stream=False,
|
||||
output=OutputConfig(verbose=False, stream=False),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1902,6 +1902,48 @@ class RuntimeConfig:
|
||||
RuntimeParam = Union[bool, str, Dict[str, Any], RuntimeConfig, None]
|
||||
|
||||
|
||||
# Known runtime names, keyed by their canonical spelling. Every alias maps to
|
||||
# the runtime whose capability matrix the agent will actually use, so a name
|
||||
# that resolves here can never silently drop capabilities downstream.
|
||||
RUNTIME_ALIASES: Dict[str, str] = {
|
||||
"native": "native",
|
||||
"plugin_harness": "plugin-harness",
|
||||
"plugin-harness": "plugin-harness",
|
||||
"harness": "plugin-harness",
|
||||
"plugin": "plugin-harness",
|
||||
"reduced": "plugin-harness",
|
||||
}
|
||||
|
||||
|
||||
def canonical_runtime_name(value: str) -> str:
|
||||
"""Normalise a runtime name; reject typos but allow genuine plugin names.
|
||||
|
||||
Matching is case-insensitive, ignores surrounding whitespace, and treats
|
||||
``-``/``_`` as interchangeable, mirroring the other preset resolvers.
|
||||
|
||||
A value that names a known runtime resolves to its canonical spelling, so
|
||||
``NATIVE``/`` native `` all map to ``native`` and pick up the full matrix
|
||||
instead of silently dropping capabilities. A value that is a close typo of
|
||||
a known runtime (e.g. ``nativ``) raises with a suggestion rather than
|
||||
degrading silently. A genuinely unknown name (e.g. a third-party plugin
|
||||
runtime registered later) is returned as-is so it can fall back to the
|
||||
reduced harness — matching the runtime registry, not a closed literal.
|
||||
"""
|
||||
from .parse_utils import canonical_preset_key, make_preset_error, suggest_similar
|
||||
|
||||
key = canonical_preset_key(value)
|
||||
if key in RUNTIME_ALIASES:
|
||||
return RUNTIME_ALIASES[key]
|
||||
known = sorted(set(RUNTIME_ALIASES))
|
||||
if suggest_similar(key, known):
|
||||
# Close enough to a known runtime that it is almost certainly a typo.
|
||||
# Pass the full alias set so the error can suggest the matched name
|
||||
# (e.g. "reducd" -> "reduced") rather than only canonical spellings.
|
||||
raise make_preset_error("runtime", value, known)
|
||||
# Unrecognised but not a near-miss: treat as an opaque plugin runtime name.
|
||||
return value
|
||||
|
||||
|
||||
def resolve_runtime(value: RuntimeParam) -> Optional[RuntimeConfig]:
|
||||
"""Resolve runtime= parameter following precedence ladder."""
|
||||
if value is None or value is False:
|
||||
@@ -1909,13 +1951,24 @@ def resolve_runtime(value: RuntimeParam) -> Optional[RuntimeConfig]:
|
||||
if value is True:
|
||||
return RuntimeConfig()
|
||||
if isinstance(value, str):
|
||||
return RuntimeConfig(preferred_runtime=value)
|
||||
return RuntimeConfig(preferred_runtime=canonical_runtime_name(value))
|
||||
if isinstance(value, (list, set, tuple, frozenset)):
|
||||
return RuntimeConfig(required_capabilities=list(value))
|
||||
if isinstance(value, dict):
|
||||
return RuntimeConfig(**value)
|
||||
config = RuntimeConfig(**value)
|
||||
if config.preferred_runtime is not None:
|
||||
config.preferred_runtime = canonical_runtime_name(config.preferred_runtime)
|
||||
return config
|
||||
if isinstance(value, RuntimeConfig):
|
||||
return value
|
||||
if value.preferred_runtime is None:
|
||||
return value
|
||||
# Return a normalised copy rather than mutating the caller's object,
|
||||
# so a config reused/compared after agent construction is unchanged.
|
||||
from dataclasses import replace
|
||||
return replace(
|
||||
value,
|
||||
preferred_runtime=canonical_runtime_name(value.preferred_runtime),
|
||||
)
|
||||
raise TypeError(
|
||||
f"Invalid runtime parameter type: {type(value).__name__}. "
|
||||
"Expected None/False, True, str, list, set, dict, or RuntimeConfig."
|
||||
@@ -1986,6 +2039,7 @@ __all__ = [
|
||||
"resolve_tool_search",
|
||||
"resolve_tools",
|
||||
"resolve_runtime",
|
||||
"canonical_runtime_name",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1108,6 +1108,18 @@ def get_optimizer(
|
||||
Optimizer instance
|
||||
"""
|
||||
optimizer_class = OPTIMIZER_REGISTRY.get(strategy, SmartOptimizer)
|
||||
|
||||
# Optimizers do not share a constructor signature. Callers (e.g. the
|
||||
# ContextManager) pass the union of every optimizer's kwargs, so filter to
|
||||
# the ones this class actually accepts; otherwise all but SmartOptimizer
|
||||
# raise TypeError and context management is silently disabled upstream.
|
||||
import inspect
|
||||
params = inspect.signature(optimizer_class.__init__).parameters
|
||||
accepts_var_kw = any(
|
||||
p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
|
||||
)
|
||||
if not accepts_var_kw:
|
||||
kwargs = {k: v for k, v in kwargs.items() if k in params}
|
||||
return optimizer_class(**kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -88,6 +88,26 @@ class _LazyDbModule:
|
||||
db = _LazyDbModule()
|
||||
|
||||
|
||||
# ``from praisonaiagents import db`` returns *this submodule*, not the singleton
|
||||
# above (Python binds a real submodule as a package attribute, shadowing the
|
||||
# package ``__getattr__``). Make the submodule object itself callable and proxy
|
||||
# backend lookups to the singleton so ``db(...)`` and ``db.PraisonDB`` both work
|
||||
# regardless of how ``db`` is imported.
|
||||
import sys as _sys
|
||||
from types import ModuleType as _ModuleType
|
||||
|
||||
|
||||
class _CallableDbModule(_ModuleType):
|
||||
def __call__(self, **kwargs):
|
||||
return db(**kwargs)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(db, name)
|
||||
|
||||
|
||||
_sys.modules[__name__].__class__ = _CallableDbModule
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DbAdapter",
|
||||
"AsyncDbAdapter",
|
||||
|
||||
@@ -9,6 +9,7 @@ that was duplicated across multiple evaluators.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from praisonaiagents._logging import get_logger
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime, timezone
|
||||
@@ -16,6 +17,16 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Tolerant matcher for judge field labels. Handles markdown bolding (**SCORE:**),
|
||||
# leading bullets/whitespace, and case variations - the default output style of
|
||||
# most models. Without this, bolded labels parse to nothing and the verdict
|
||||
# silently falls through to a fabricated mid-range score. Mirrors the matcher in
|
||||
# safety.py so grader/judge fail the same way safety already does.
|
||||
_FIELD_RE = re.compile(
|
||||
r"^[\s>*_#`-]*(score|reasoning|suggestions)\s*\**\s*:\s*(.*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class GradeResult:
|
||||
"""
|
||||
@@ -186,39 +197,18 @@ EXPECTED OUTPUT (ideal response):
|
||||
Returns:
|
||||
Parsed GradeResult
|
||||
"""
|
||||
score = 5.0 # Default
|
||||
reasoning = "Unable to parse response"
|
||||
suggestions: List[str] = []
|
||||
|
||||
lines = response_text.strip().split('\n')
|
||||
in_suggestions = False
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
if line.startswith('SCORE:'):
|
||||
try:
|
||||
score_str = line.replace('SCORE:', '').strip()
|
||||
score = float(score_str)
|
||||
# Clamp to valid range
|
||||
score = max(1.0, min(10.0, score))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
elif line.startswith('REASONING:'):
|
||||
reasoning = line.replace('REASONING:', '').strip()
|
||||
|
||||
elif line.startswith('SUGGESTIONS:'):
|
||||
in_suggestions = True
|
||||
rest = line.replace('SUGGESTIONS:', '').strip()
|
||||
if rest.lower() != 'none' and rest:
|
||||
suggestions.append(rest)
|
||||
|
||||
elif in_suggestions and line.startswith('-'):
|
||||
suggestion = line.lstrip('- ').strip()
|
||||
if suggestion and suggestion.lower() != 'none':
|
||||
suggestions.append(suggestion)
|
||||
|
||||
score, reasoning, suggestions, score_parsed = _extract_score_reasoning(response_text)
|
||||
|
||||
if not score_parsed:
|
||||
# No numeric score was parsed. Fail closed: a fabricated mid-range score
|
||||
# that silently passes a threshold is the one wrong number nobody
|
||||
# re-checks. Report the minimum instead.
|
||||
logger.warning(
|
||||
"Could not parse grader response; failing closed. Raw: %r",
|
||||
response_text[:200],
|
||||
)
|
||||
score = 1.0
|
||||
|
||||
return GradeResult(
|
||||
score=score,
|
||||
reasoning=reasoning,
|
||||
@@ -365,38 +355,102 @@ EXPECTED OUTPUT (ideal response):
|
||||
expected_output=expected_output,
|
||||
)
|
||||
|
||||
def _extract_score_reasoning(response_text: str) -> tuple:
|
||||
"""
|
||||
Line-based tolerant extraction of SCORE / REASONING / SUGGESTIONS.
|
||||
|
||||
Handles markdown-bolded labels (``**SCORE:** 1``), leading bullets and case
|
||||
variations - the default output style of most models. Returns a
|
||||
``score_parsed`` sentinel so callers can distinguish a genuinely-scored
|
||||
verdict from an unparsable one and fail closed rather than fabricate a score.
|
||||
|
||||
Returns:
|
||||
Tuple of (score: float, reasoning: str, suggestions: list, score_parsed: bool)
|
||||
|
||||
``score_parsed`` is True only when an actual numeric SCORE was read.
|
||||
Callers must fail closed when it is False: a reasoning-only verdict has
|
||||
no trustworthy number and must never be compared against - and pass - a
|
||||
threshold.
|
||||
"""
|
||||
score = 5.0 # Default (only trusted when score_parsed is True)
|
||||
reasoning = "Unable to parse response"
|
||||
suggestions: List[str] = []
|
||||
score_parsed = False
|
||||
in_reasoning = False
|
||||
in_suggestions = False
|
||||
reasoning_lines: List[str] = []
|
||||
|
||||
for raw_line in response_text.strip().split('\n'):
|
||||
line = raw_line.strip()
|
||||
|
||||
match = _FIELD_RE.match(line) if line else None
|
||||
if match:
|
||||
field_name = match.group(1).lower()
|
||||
value = match.group(2).strip().strip('*_`').strip()
|
||||
|
||||
if field_name == 'score':
|
||||
number = re.search(r'-?\d+(?:\.\d+)?', value)
|
||||
if number:
|
||||
try:
|
||||
score = max(1.0, min(10.0, float(number.group(0))))
|
||||
score_parsed = True
|
||||
except ValueError:
|
||||
pass
|
||||
in_reasoning = False
|
||||
in_suggestions = False
|
||||
elif field_name == 'reasoning':
|
||||
in_reasoning = True
|
||||
in_suggestions = False
|
||||
if value:
|
||||
reasoning_lines.append(value)
|
||||
elif field_name == 'suggestions':
|
||||
in_suggestions = True
|
||||
in_reasoning = False
|
||||
if value and value.lower() != 'none':
|
||||
suggestions.append(value)
|
||||
continue
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if in_suggestions and line.lstrip('*_`').startswith('-'):
|
||||
suggestion = line.lstrip('*_`- ').strip()
|
||||
if suggestion and suggestion.lower() != 'none':
|
||||
suggestions.append(suggestion)
|
||||
elif in_reasoning:
|
||||
reasoning_lines.append(line)
|
||||
|
||||
if reasoning_lines:
|
||||
reasoning = ' '.join(reasoning_lines).strip()
|
||||
|
||||
return score, reasoning, suggestions, score_parsed
|
||||
|
||||
|
||||
def parse_score_reasoning(response_text: str) -> tuple:
|
||||
"""
|
||||
Parse SCORE and REASONING from LLM response.
|
||||
|
||||
|
||||
DRY: Static utility function for parsing LLM-as-judge responses.
|
||||
Used by AccuracyEvaluator, CriteriaEvaluator, and BaseLLMGrader.
|
||||
|
||||
|
||||
Tolerant of markdown-bolded labels. When nothing can be parsed the verdict
|
||||
fails closed (score 1.0) rather than silently returning a mid-range 5.0 that
|
||||
could pass a threshold - the one wrong number nobody re-checks.
|
||||
|
||||
Args:
|
||||
response_text: Raw LLM response text
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (score: float, reasoning: str)
|
||||
"""
|
||||
import re
|
||||
score, reasoning, _suggestions, score_parsed = _extract_score_reasoning(response_text)
|
||||
|
||||
score = 5.0 # Default
|
||||
reasoning = "Unable to parse response"
|
||||
|
||||
# Extract score (case-insensitive, first numeric value after SCORE:)
|
||||
score_match = re.search(r'SCORE:\s*(\d+(?:\.\d+)?)', response_text, re.IGNORECASE)
|
||||
if score_match:
|
||||
try:
|
||||
score = float(score_match.group(1))
|
||||
# Clamp to valid range
|
||||
score = max(1.0, min(10.0, score))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Extract reasoning (case-insensitive, preserves multi-line explanations)
|
||||
reasoning_match = re.search(r'REASONING:\s*(.+)', response_text, re.IGNORECASE | re.DOTALL)
|
||||
if reasoning_match:
|
||||
reasoning = reasoning_match.group(1).strip()
|
||||
if not score_parsed:
|
||||
logger.warning(
|
||||
"Could not parse judge response; failing closed. Raw: %r",
|
||||
response_text[:200],
|
||||
)
|
||||
score = 1.0
|
||||
|
||||
return score, reasoning
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from praisonaiagents._logging import get_logger
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
|
||||
|
||||
from .grader import BaseLLMGrader
|
||||
from .grader import BaseLLMGrader, _extract_score_reasoning
|
||||
from .results import JudgeResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -294,41 +294,22 @@ SUGGESTIONS:
|
||||
Returns:
|
||||
JudgeResult with score, passed, reasoning, suggestions
|
||||
"""
|
||||
score = 5.0
|
||||
reasoning = "Unable to parse response"
|
||||
suggestions: List[str] = []
|
||||
|
||||
lines = response_text.strip().split('\n')
|
||||
in_suggestions = False
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
if line.startswith('SCORE:'):
|
||||
try:
|
||||
score_str = line.replace('SCORE:', '').strip()
|
||||
score = float(score_str)
|
||||
score = max(1.0, min(10.0, score))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
elif line.startswith('REASONING:'):
|
||||
reasoning = line.replace('REASONING:', '').strip()
|
||||
|
||||
elif line.startswith('SUGGESTIONS:'):
|
||||
in_suggestions = True
|
||||
rest = line.replace('SUGGESTIONS:', '').strip()
|
||||
if rest.lower() not in ('none', '') and rest:
|
||||
suggestions.append(rest)
|
||||
|
||||
elif in_suggestions and line.startswith('-'):
|
||||
suggestion = line.lstrip('- ').strip()
|
||||
if suggestion and suggestion.lower() != 'none':
|
||||
suggestions.append(suggestion)
|
||||
|
||||
score, reasoning, suggestions, score_parsed = _extract_score_reasoning(response_text)
|
||||
|
||||
if not score_parsed:
|
||||
# An unscored verdict is an error, not a middling result. Fail closed
|
||||
# so a fabricated 5.0 can never be compared against - and pass - a
|
||||
# threshold. A reasoning-only response has no trustworthy number.
|
||||
# Mirrors safety.py / parse_score_reasoning.
|
||||
logger.warning(
|
||||
"Could not parse judge response; failing closed. Raw: %r",
|
||||
response_text[:200],
|
||||
)
|
||||
score = 1.0
|
||||
|
||||
return JudgeResult(
|
||||
score=score,
|
||||
passed=score >= self.threshold,
|
||||
passed=score_parsed and score >= self.threshold,
|
||||
reasoning=reasoning,
|
||||
output=output,
|
||||
expected=expected,
|
||||
|
||||
@@ -29,11 +29,16 @@ from .protocols import (
|
||||
# Config hot-reload observability (Issue #3049)
|
||||
ReloadStatus,
|
||||
compute_config_revision,
|
||||
# Saturation / back-pressure observability (Issue #4265)
|
||||
HealthPressure,
|
||||
evaluate_pressure,
|
||||
# Push protocols and dataclasses
|
||||
PushChannelProtocol,
|
||||
PresenceProtocol,
|
||||
DeliveryGuaranteeProtocol,
|
||||
OutboundDeliveryProtocol,
|
||||
IdempotencyStoreProtocol,
|
||||
InMemoryIdempotencyStore,
|
||||
ChannelInfo,
|
||||
PresenceInfo,
|
||||
# Home channel and delivery protocols
|
||||
@@ -159,6 +164,11 @@ from .protocols import (
|
||||
TurnPlacement,
|
||||
TurnExecutorProtocol,
|
||||
InProcessTurnExecutor,
|
||||
# Global operator emergency-stop / pause brake (Issue #4220)
|
||||
EmergencyStopState,
|
||||
EmergencyStopProtocol,
|
||||
NullEmergencyStop,
|
||||
FileEmergencyStop,
|
||||
# Schema-validated inbound frame codec (Issue #2831)
|
||||
HelloParams,
|
||||
HelloResult,
|
||||
@@ -209,6 +219,7 @@ from .config import (
|
||||
GatewayConfig,
|
||||
SessionConfig,
|
||||
ApiConfig,
|
||||
EmergencyStopConfig,
|
||||
ChannelRouteConfig,
|
||||
MultiChannelGatewayConfig,
|
||||
# Config version stamp + doctor-driven migration (Issue #3841)
|
||||
@@ -303,11 +314,15 @@ __all__ = [
|
||||
# Config hot-reload observability (Issue #3049)
|
||||
"ReloadStatus",
|
||||
"compute_config_revision",
|
||||
"HealthPressure",
|
||||
"evaluate_pressure",
|
||||
# Push protocols (always available)
|
||||
"PushChannelProtocol",
|
||||
"PresenceProtocol",
|
||||
"DeliveryGuaranteeProtocol",
|
||||
"OutboundDeliveryProtocol",
|
||||
"IdempotencyStoreProtocol",
|
||||
"InMemoryIdempotencyStore",
|
||||
"ChannelInfo",
|
||||
"PresenceInfo",
|
||||
# Home channel and delivery protocols
|
||||
@@ -429,6 +444,11 @@ __all__ = [
|
||||
"TurnPlacement",
|
||||
"TurnExecutorProtocol",
|
||||
"InProcessTurnExecutor",
|
||||
# Global operator emergency-stop / pause brake (Issue #4220)
|
||||
"EmergencyStopState",
|
||||
"EmergencyStopProtocol",
|
||||
"NullEmergencyStop",
|
||||
"FileEmergencyStop",
|
||||
# Schema-validated inbound frame codec (Issue #2831)
|
||||
"HelloParams",
|
||||
"HelloResult",
|
||||
@@ -473,6 +493,7 @@ __all__ = [
|
||||
"GatewayConfig",
|
||||
"SessionConfig",
|
||||
"ApiConfig",
|
||||
"EmergencyStopConfig",
|
||||
"ChannelRouteConfig",
|
||||
"MultiChannelGatewayConfig",
|
||||
# Config version stamp + doctor-driven migration (Issue #3841)
|
||||
|
||||
@@ -706,6 +706,71 @@ class ApiConfig:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmergencyStopConfig:
|
||||
"""Configuration for the global operator emergency-stop brake (Issue #4220).
|
||||
|
||||
Selects the backend for the durable, fail-safe operator brake consulted at
|
||||
every new-work admission seam (WebSocket inbound, HTTP/MCP inbound, kanban
|
||||
dispatch, scheduler due-loop). The default ``"off"`` backend engages a
|
||||
no-op brake so ``is_engaged()`` is always ``False`` — behaviour is
|
||||
byte-for-byte today's, no new dependency, no hot-path change. Selecting
|
||||
``"file"`` persists the engaged state to a sentinel at ``path`` so it
|
||||
survives a crash/restart and is shared across lanes; an unreadable/corrupt
|
||||
sentinel counts as *engaged* (fail-safe).
|
||||
|
||||
This maps onto the pure core
|
||||
:class:`~praisonaiagents.gateway.protocols.EmergencyStopProtocol`;
|
||||
:meth:`to_estop` builds the concrete brake.
|
||||
|
||||
Attributes:
|
||||
backend: ``"off"`` (default, no-op) or ``"file"`` (durable sentinel).
|
||||
path: Sentinel location for the ``"file"`` backend (required for it).
|
||||
"""
|
||||
|
||||
backend: str = "off"
|
||||
path: Optional[str] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.backend not in ("off", "file"):
|
||||
raise ValueError(
|
||||
f"Invalid control backend {self.backend!r}; expected 'off' or 'file'"
|
||||
)
|
||||
if self.backend == "file" and not self.path:
|
||||
raise ValueError("control backend 'file' requires a 'path'")
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Whether a real (non-``off``) brake backend is selected."""
|
||||
return self.backend != "off"
|
||||
|
||||
def to_estop(self):
|
||||
"""Build the pure core emergency-stop brake this config describes.
|
||||
|
||||
``"off"`` returns a :class:`NullEmergencyStop` (never engaged);
|
||||
``"file"`` returns a durable, fail-safe :class:`FileEmergencyStop`.
|
||||
"""
|
||||
from .protocols import FileEmergencyStop, NullEmergencyStop
|
||||
|
||||
if self.backend == "file" and self.path:
|
||||
return FileEmergencyStop(self.path)
|
||||
return NullEmergencyStop()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {"backend": self.backend, "path": self.path}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]]) -> "EmergencyStopConfig":
|
||||
"""Create from a parsed ``gateway.control`` mapping (tolerant of None)."""
|
||||
if not isinstance(data, dict):
|
||||
return cls()
|
||||
return cls(
|
||||
backend=str(data.get("backend") or "off"),
|
||||
path=data.get("path"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GatewayConfig:
|
||||
"""Configuration for the gateway server.
|
||||
@@ -783,6 +848,12 @@ class GatewayConfig:
|
||||
# serialises turns across replicas so a horizontally-scaled gateway does not
|
||||
# run concurrent turns on one session.
|
||||
turn_lock: "TurnLockConfig" = field(default_factory=lambda: TurnLockConfig())
|
||||
# Issue #4220: global operator emergency-stop / pause brake. Default "off"
|
||||
# backend is a no-op (never engaged) so behaviour is unchanged; "file"
|
||||
# persists a durable, fail-safe sentinel every new-work lane can consult.
|
||||
control: "EmergencyStopConfig" = field(
|
||||
default_factory=lambda: EmergencyStopConfig()
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Post-initialization to set bind_host from host if not specified and validate values."""
|
||||
@@ -905,6 +976,7 @@ class GatewayConfig:
|
||||
"api": self.api.to_dict(),
|
||||
"liveness": self.liveness.to_dict(),
|
||||
"turn_lock": self.turn_lock.to_dict(),
|
||||
"control": self.control.to_dict(),
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -1096,6 +1168,7 @@ class MultiChannelGatewayConfig:
|
||||
api=ApiConfig.from_dict(gw_data.get("api")),
|
||||
liveness=LivenessConfig.from_dict(gw_data.get("liveness")),
|
||||
turn_lock=TurnLockConfig.from_dict(gw_data.get("turn_lock")),
|
||||
control=EmergencyStopConfig.from_dict(gw_data.get("control")),
|
||||
)
|
||||
|
||||
# Parse agents section (pass through as dicts)
|
||||
|
||||
@@ -126,11 +126,27 @@ class LoopWatchdog:
|
||||
self._last_ack = 0.0
|
||||
self._last_probe = 0.0
|
||||
self._wedged = False
|
||||
# Continuously-measured scheduling lag (ms) of the most recently
|
||||
# acked probe. Read back by the health surface as a saturation signal
|
||||
# (Issue #4265) — the same measurement the wedge check already relies
|
||||
# on, exposed rather than only compared against the wedge threshold.
|
||||
self._probe_sent_at = 0.0
|
||||
self._last_lag_ms = 0.0
|
||||
|
||||
@property
|
||||
def armed(self) -> bool:
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
@property
|
||||
def last_lag_ms(self) -> float:
|
||||
"""Most recent event-loop scheduling lag in milliseconds.
|
||||
|
||||
The delay between scheduling a probe and the loop running its ack.
|
||||
``0.0`` until the first probe round-trips. Read by the gateway health
|
||||
surface as a forward saturation signal (Issue #4265).
|
||||
"""
|
||||
return self._last_lag_ms
|
||||
|
||||
@property
|
||||
def wedged(self) -> bool:
|
||||
"""True once a wedge has been confirmed (mainly for ``dump_only``)."""
|
||||
@@ -176,7 +192,22 @@ class LoopWatchdog:
|
||||
|
||||
def _ack(self) -> None:
|
||||
"""Callback run *on the loop*; records that the loop is alive."""
|
||||
self._last_ack = time.monotonic()
|
||||
now = time.monotonic()
|
||||
self._last_ack = now
|
||||
# Record the scheduling lag: how long the loop took to run this ack
|
||||
# after we scheduled it. This is the same signal the wedge check uses,
|
||||
# exposed as a gauge for the health surface (Issue #4265).
|
||||
#
|
||||
# ``_probe_sent_at`` is anchored to the *oldest still-unacked* probe,
|
||||
# not the most recent one: when the loop stalls across several probe
|
||||
# intervals the watchdog keeps scheduling fresh acks that all queue
|
||||
# behind the wedge, and measuring from the newest probe would report a
|
||||
# fraction of the true stall. Consuming the anchor here (setting it to
|
||||
# 0.0) lets the next scheduled probe re-anchor from a caught-up loop.
|
||||
sent = self._probe_sent_at
|
||||
if sent:
|
||||
self._last_lag_ms = max(0.0, (now - sent) * 1000.0)
|
||||
self._probe_sent_at = 0.0
|
||||
|
||||
def _schedule_probe(self) -> bool:
|
||||
"""Schedule an ack onto the loop. Returns False if scheduling fails."""
|
||||
@@ -184,8 +215,16 @@ class LoopWatchdog:
|
||||
if loop is None:
|
||||
return False
|
||||
try:
|
||||
# Only (re-)anchor the lag clock when the previous probe has been
|
||||
# acked (``_ack`` zeroes ``_probe_sent_at``). If the loop is stalled
|
||||
# and earlier probes are still queued, keep the anchor pinned to the
|
||||
# oldest unacked probe so the reported lag reflects the full stall
|
||||
# rather than the gap since the newest probe (Issue #4265).
|
||||
now = time.monotonic()
|
||||
if not self._probe_sent_at:
|
||||
self._probe_sent_at = now
|
||||
loop.call_soon_threadsafe(self._ack)
|
||||
self._last_probe = time.monotonic()
|
||||
self._last_probe = now
|
||||
return True
|
||||
except RuntimeError:
|
||||
# Loop is closed / not running — treat as not-scheduled but do not
|
||||
|
||||
@@ -200,6 +200,10 @@ class EventType(str, Enum):
|
||||
TOOL_PROGRESS_STREAM = "tool_progress_stream"
|
||||
STREAM_ERROR = "stream_error"
|
||||
STREAM_END = "stream_end"
|
||||
MODEL_FALLBACK_STREAM = "model_fallback_stream"
|
||||
RETRY_STREAM = "retry_stream"
|
||||
TODO_STREAM = "todo_stream"
|
||||
TOOL_RESULT_STREAM = "tool_result_stream"
|
||||
|
||||
# System events
|
||||
HEALTH = "health"
|
||||
@@ -1095,6 +1099,10 @@ class GatewayProtocol(Protocol):
|
||||
:func:`compute_config_revision`) of the config the gateway is
|
||||
*actually running*, comparable against the on-disk revision to
|
||||
detect drift.
|
||||
- pressure: Optional :class:`HealthPressure` dict (admission /
|
||||
inbound / outbound backlog + event-loop lag and a derived
|
||||
``pressure`` classification), when the gateway runs back-pressure
|
||||
machinery. See :class:`HealthPressure` and :func:`evaluate_pressure`.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -1190,6 +1198,165 @@ def compute_config_revision(config: Optional[Dict[str, Any]]) -> str:
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Saturation / back-pressure observability (Issue #4265)
|
||||
#
|
||||
# The always-on gateway already *enforces* back-pressure — a concurrency
|
||||
# ceiling with a bounded fair wait-queue (``AdmissionGate``), a durable
|
||||
# outbound outbox with a dead-letter tier (``OutboundQueue``), and an
|
||||
# event-loop wedge watchdog (``LoopWatchdog``) — but it exposes no saturation
|
||||
# *telemetry*. The health surface reports liveness and config drift, yet never
|
||||
# reports how close the gateway is to its limits, so the first signal an
|
||||
# operator gets is dropped work (shed turns / stalled outbound) rather than a
|
||||
# forward warning. This is the small, canonical contract — mirroring
|
||||
# ``ReloadStatus`` exactly — that both the SDK's ``gateway.health()`` and the
|
||||
# bot's ``/health`` populate so saturation is a first-class, observable signal.
|
||||
# Core owns only the *shape* (a frozen dataclass) and a pure, deterministic
|
||||
# classification helper; the wrapper reads back the facts the enforcement layer
|
||||
# already computes and fills the block.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HealthPressure:
|
||||
"""Observable saturation snapshot of the gateway's back-pressure machinery.
|
||||
|
||||
Populated by the running gateway and surfaced in
|
||||
:meth:`GatewayProtocol.health` so an operator (or an autoscaler / a
|
||||
``/ready``-driven load balancer) can ask "is the gateway backing up?"
|
||||
*before* admission starts shedding turns — rather than inferring overload
|
||||
post-mortem from shed-load log lines.
|
||||
|
||||
Attributes:
|
||||
admission_max: Configured concurrency ceiling (``0`` when unbounded).
|
||||
admission_in_flight: Turns currently running.
|
||||
admission_queued: Turns currently waiting for a slot.
|
||||
admission_shed_total: Cumulative turns shed since start.
|
||||
inbox_pending: Aggregate pending inbound queue depth across sessions.
|
||||
outbox_pending: Durable outbound backlog awaiting delivery.
|
||||
outbox_dead_letter: Durable outbound entries in the dead-letter tier.
|
||||
event_loop_lag_p99_ms: Recent event-loop scheduling lag (ms).
|
||||
pressure: Single derived classification — ``"nominal"``,
|
||||
``"elevated"`` or ``"saturated"`` (closed vocabulary) — so an
|
||||
orchestrator can branch on one field.
|
||||
"""
|
||||
|
||||
admission_max: int = 0
|
||||
admission_in_flight: int = 0
|
||||
admission_queued: int = 0
|
||||
admission_shed_total: int = 0
|
||||
inbox_pending: int = 0
|
||||
outbox_pending: int = 0
|
||||
outbox_dead_letter: int = 0
|
||||
event_loop_lag_p99_ms: float = 0.0
|
||||
pressure: Literal["nominal", "elevated", "saturated"] = "nominal"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a JSON-serializable dict for the health surface."""
|
||||
return {
|
||||
"admission_max": self.admission_max,
|
||||
"admission_in_flight": self.admission_in_flight,
|
||||
"admission_queued": self.admission_queued,
|
||||
"admission_shed_total": self.admission_shed_total,
|
||||
"inbox_pending": self.inbox_pending,
|
||||
"outbox_pending": self.outbox_pending,
|
||||
"outbox_dead_letter": self.outbox_dead_letter,
|
||||
"event_loop_lag_p99_ms": self.event_loop_lag_p99_ms,
|
||||
"pressure": self.pressure,
|
||||
}
|
||||
|
||||
|
||||
def evaluate_pressure(
|
||||
*,
|
||||
admission: Optional[Dict[str, Any]] = None,
|
||||
inbox_pending: int = 0,
|
||||
outbox_pending: int = 0,
|
||||
outbox_dead_letter: int = 0,
|
||||
loop_lag_p99_ms: float = 0.0,
|
||||
) -> HealthPressure:
|
||||
"""Pure, deterministic classification of gateway load. No I/O.
|
||||
|
||||
Reads back facts the enforcement layer already computes
|
||||
(``AdmissionGate.stats()``, the outbox ``status`` counts, the watchdog's
|
||||
measured lag) and folds them into a single :class:`HealthPressure`. Lives
|
||||
in core — like :func:`compute_config_revision` — so the SDK and the bot
|
||||
classify identically without divergence.
|
||||
|
||||
Classification (highest wins):
|
||||
|
||||
* ``"saturated"`` — admission is full *and* work is queuing, the outbound
|
||||
dead-letter tier is non-empty, or the event loop is lagging badly
|
||||
(``>= 500 ms``): shed / stall is imminent or occurring.
|
||||
* ``"elevated"`` — admission is highly utilised (``>= 80 %``), the inbound
|
||||
or outbound backlog is building, or the loop is lagging (``>= 100 ms``).
|
||||
* ``"nominal"`` — none of the above.
|
||||
|
||||
Args:
|
||||
admission: An :meth:`AdmissionGate.stats` snapshot (or ``None``).
|
||||
inbox_pending: Aggregate pending inbound queue depth.
|
||||
outbox_pending: Durable outbound backlog.
|
||||
outbox_dead_letter: Durable outbound dead-letter depth.
|
||||
loop_lag_p99_ms: Recent event-loop scheduling lag in ms.
|
||||
|
||||
Returns:
|
||||
A frozen :class:`HealthPressure` ready for the health surface.
|
||||
"""
|
||||
stats = admission or {}
|
||||
|
||||
def _int(value: Any) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
admission_max = _int(stats.get("max_concurrent_runs"))
|
||||
in_flight = _int(stats.get("in_flight"))
|
||||
queued = _int(stats.get("queued"))
|
||||
shed_total = _int(stats.get("shed"))
|
||||
inbox = max(0, _int(inbox_pending))
|
||||
outbox = max(0, _int(outbox_pending))
|
||||
dead_letter = max(0, _int(outbox_dead_letter))
|
||||
try:
|
||||
lag = float(loop_lag_p99_ms or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
lag = 0.0
|
||||
if lag < 0.0:
|
||||
lag = 0.0
|
||||
|
||||
utilisation = (in_flight / admission_max) if admission_max > 0 else 0.0
|
||||
|
||||
saturated = (
|
||||
(admission_max > 0 and in_flight >= admission_max and queued > 0)
|
||||
or dead_letter > 0
|
||||
or lag >= 500.0
|
||||
)
|
||||
elevated = (
|
||||
utilisation >= 0.8
|
||||
or queued > 0
|
||||
or inbox > 0
|
||||
or outbox > 0
|
||||
or lag >= 100.0
|
||||
)
|
||||
if saturated:
|
||||
pressure: Literal["nominal", "elevated", "saturated"] = "saturated"
|
||||
elif elevated:
|
||||
pressure = "elevated"
|
||||
else:
|
||||
pressure = "nominal"
|
||||
|
||||
return HealthPressure(
|
||||
admission_max=admission_max,
|
||||
admission_in_flight=in_flight,
|
||||
admission_queued=queued,
|
||||
admission_shed_total=shed_total,
|
||||
inbox_pending=inbox,
|
||||
outbox_pending=outbox,
|
||||
outbox_dead_letter=dead_letter,
|
||||
event_loop_lag_p99_ms=lag,
|
||||
pressure=pressure,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Push notification dataclasses
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1567,6 +1734,90 @@ class OutboundDeliveryProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdempotencyStoreProtocol(Protocol):
|
||||
"""Protocol for inbound webhook/trigger delivery deduplication.
|
||||
|
||||
The gateway's inbound HTTP hook surface must not start a second agent run
|
||||
for an event a provider re-delivers (webhook providers routinely retry for
|
||||
minutes to days). Deduplication is a three-step, restart-stable claim on the
|
||||
deterministic idempotency key (``compute_idempotency_key``):
|
||||
|
||||
- ``reserve`` — atomically claim the key. Returns ``False`` when the key
|
||||
was already recorded *or* is currently in flight, so both a redelivery
|
||||
and a concurrent duplicate are rejected.
|
||||
- ``record`` — commit the key after a successful run so future
|
||||
redeliveries dedup.
|
||||
- ``release`` — drop an in-flight reservation after a failed run so the
|
||||
provider's retry can re-run.
|
||||
|
||||
This mirrors :class:`CallbackPayloadStoreProtocol`: the contract lives in
|
||||
core so third-party gateways and a future ``redis`` backend interoperate;
|
||||
core ships the bounded in-memory default (:class:`InMemoryIdempotencyStore`)
|
||||
and the durable, restart-surviving SQLite backend lives in the
|
||||
``praisonai-bot`` runtime (as the ingress journal and outbound queue do).
|
||||
"""
|
||||
|
||||
def reserve(self, key: str) -> bool:
|
||||
"""Atomically claim ``key``; ``False`` if seen-or-in-flight."""
|
||||
...
|
||||
|
||||
def record(self, key: str) -> None:
|
||||
"""Commit ``key`` as processed after a successful run."""
|
||||
...
|
||||
|
||||
def release(self, key: str) -> None:
|
||||
"""Drop an in-flight reservation so a failed delivery can be retried."""
|
||||
...
|
||||
|
||||
|
||||
class InMemoryIdempotencyStore:
|
||||
"""Bounded, zero-dependency in-memory :class:`IdempotencyStoreProtocol`.
|
||||
|
||||
The default store for single-replica deployments — identical behaviour to
|
||||
the gateway's original per-process ``OrderedDict`` + in-flight ``set``. It
|
||||
is *not* durable: after a process restart the store is empty, so a durable
|
||||
backend (SQLite/redis) must be injected for the restart/multi-replica case.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_entries: int = 10_000,
|
||||
ttl_seconds: float = 86_400.0,
|
||||
) -> None:
|
||||
self._max_entries = max(1, int(max_entries))
|
||||
self._ttl_seconds = float(ttl_seconds)
|
||||
# key -> insertion time (seconds); insertion-ordered for FIFO eviction.
|
||||
self._seen: "Dict[str, float]" = {}
|
||||
self._inflight: Set[str] = set()
|
||||
|
||||
def _purge_expired(self, now: float) -> None:
|
||||
if not self._seen:
|
||||
return
|
||||
expired = [k for k, ts in self._seen.items() if now - ts > self._ttl_seconds]
|
||||
for k in expired:
|
||||
self._seen.pop(k, None)
|
||||
|
||||
def reserve(self, key: str) -> bool:
|
||||
now = time.time()
|
||||
self._purge_expired(now)
|
||||
if key in self._seen or key in self._inflight:
|
||||
return False
|
||||
self._inflight.add(key)
|
||||
return True
|
||||
|
||||
def record(self, key: str) -> None:
|
||||
self._inflight.discard(key)
|
||||
self._seen[key] = time.time()
|
||||
while len(self._seen) > self._max_entries:
|
||||
oldest = next(iter(self._seen))
|
||||
self._seen.pop(oldest, None)
|
||||
|
||||
def release(self, key: str) -> None:
|
||||
self._inflight.discard(key)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-route, trust-tiered toolset scoping (Issue #2298)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -6024,8 +6275,16 @@ def resolve_required_scope(
|
||||
# status/read needs READ. Anything unregistered defaults to ADMIN (deny).
|
||||
def _register_core_gateway_methods() -> None:
|
||||
core: Dict[str, OperatorScope] = {
|
||||
# Connection lifecycle. These run before a client can hold any scope, so
|
||||
# they must not require one -- classifying them ADMIN by omission is what
|
||||
# kept resolve_required_scope() from ever being wired into dispatch.
|
||||
"hello": OperatorScope.READ,
|
||||
"join": OperatorScope.READ,
|
||||
"leave": OperatorScope.READ,
|
||||
"agent.message": OperatorScope.WRITE,
|
||||
"message": OperatorScope.WRITE,
|
||||
# Aborting a turn mutates it, so it carries the same scope as sending one.
|
||||
"abort": OperatorScope.WRITE,
|
||||
"session.status": OperatorScope.READ,
|
||||
"session.transcript": OperatorScope.READ,
|
||||
"approvals.resolve": OperatorScope.APPROVALS,
|
||||
@@ -6041,3 +6300,195 @@ def _register_core_gateway_methods() -> None:
|
||||
|
||||
|
||||
_register_core_gateway_methods()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global operator emergency-stop / pause brake (Issue #4220)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# A single, durable, fail-safe operator brake: "stop admitting NEW agent work
|
||||
# everywhere, right now, but let in-flight runs finish — and resume later with
|
||||
# no restart." Every new-work admission seam (WebSocket inbound, HTTP/MCP
|
||||
# inbound, kanban dispatch cycle, scheduler due-loop) consults one shared
|
||||
# contract instead of each lane deciding independently.
|
||||
#
|
||||
# Design mirrors the drain / idle / admission / liveness policy family already
|
||||
# in this module: a pure ``Protocol`` in core plus a lightweight default and a
|
||||
# durable fail-safe backend. When no brake is engaged, ``is_engaged()`` is
|
||||
# ``False`` and behaviour is byte-for-byte today's — zero cost, no new
|
||||
# dependency, fully backward-compatible.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmergencyStopState:
|
||||
"""Immutable snapshot of the operator brake, for status/audit surfaces.
|
||||
|
||||
``engaged`` is the only field the hot path needs; ``reason``/``actor``/
|
||||
``at`` provide the optional audit trail surfaced in ``/health``/status.
|
||||
"""
|
||||
|
||||
engaged: bool
|
||||
reason: str = ""
|
||||
actor: str = ""
|
||||
at: float = 0.0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"engaged": self.engaged,
|
||||
"reason": self.reason,
|
||||
"actor": self.actor,
|
||||
"at": self.at,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EmergencyStopProtocol(Protocol):
|
||||
"""Pure contract for the global operator brake consulted at every seam.
|
||||
|
||||
Fail-safe rule: if the engaged state cannot be read with confidence (a
|
||||
corrupt / unreadable durable sentinel), ``is_engaged()`` MUST return
|
||||
``True`` — an ambiguous brake holds new work rather than letting it run
|
||||
freely. ``engage``/``disengage`` are idempotent.
|
||||
"""
|
||||
|
||||
def is_engaged(self) -> bool: ...
|
||||
|
||||
def engage(self, *, reason: str = "", actor: str = "") -> None: ...
|
||||
|
||||
def disengage(self) -> None: ...
|
||||
|
||||
def state(self) -> EmergencyStopState: ...
|
||||
|
||||
|
||||
class NullEmergencyStop:
|
||||
"""Default no-op brake: never engaged, no persistence.
|
||||
|
||||
Selected by ``backend: "off"`` (the default) so a gateway with no brake
|
||||
configured behaves exactly as before — ``is_engaged()`` is always
|
||||
``False`` and ``engage``/``disengage`` are inert.
|
||||
"""
|
||||
|
||||
def is_engaged(self) -> bool:
|
||||
return False
|
||||
|
||||
def engage(self, *, reason: str = "", actor: str = "") -> None:
|
||||
return None
|
||||
|
||||
def disengage(self) -> None:
|
||||
return None
|
||||
|
||||
def state(self) -> EmergencyStopState:
|
||||
return EmergencyStopState(engaged=False)
|
||||
|
||||
|
||||
class FileEmergencyStop:
|
||||
"""Durable, fail-safe file-sentinel operator brake.
|
||||
|
||||
Engaging writes a small JSON sentinel at ``path``; disengaging removes it.
|
||||
The engaged state therefore survives a crash/restart (durable) and is
|
||||
shared by every lane that consults the same path.
|
||||
|
||||
Fail-safe: any error reading the sentinel — a partially written file,
|
||||
corrupt JSON, a permission error — is treated as *engaged*. An ambiguous
|
||||
brake holds new work; it never silently falls open to "run freely". The
|
||||
common, unambiguous case (sentinel simply absent) reports not-engaged.
|
||||
|
||||
Pure-stdlib (``os``/``json``): no new dependency, safe to live in core.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
if not path:
|
||||
raise ValueError("FileEmergencyStop requires a non-empty sentinel path")
|
||||
import os
|
||||
|
||||
self._path = os.path.expanduser(str(path))
|
||||
|
||||
def is_engaged(self) -> bool:
|
||||
import os
|
||||
|
||||
if not os.path.exists(self._path):
|
||||
return False
|
||||
# Present but unreadable/corrupt => fail-safe engaged.
|
||||
try:
|
||||
with open(self._path, "r", encoding="utf-8") as fh:
|
||||
fh.read()
|
||||
return True
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
def engage(self, *, reason: str = "", actor: str = "") -> None:
|
||||
import json
|
||||
import os
|
||||
|
||||
directory = os.path.dirname(self._path)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
payload = {
|
||||
"engaged": True,
|
||||
"reason": str(reason or ""),
|
||||
"actor": str(actor or ""),
|
||||
"at": time.time(),
|
||||
}
|
||||
# Atomic replace so a reader never observes a half-written sentinel
|
||||
# (which would fail-safe engaged anyway, but this keeps it clean).
|
||||
# ``mkstemp`` in the sentinel's own directory yields an unpredictable
|
||||
# name owned by us, so a local attacker cannot pre-create a symlink to
|
||||
# redirect the write. fsync + parent-dir fsync make an engaged brake
|
||||
# durable across an abrupt crash.
|
||||
import tempfile
|
||||
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
dir=directory or ".", prefix=".gateway.pause.", suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(payload, fh)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, self._path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
if directory:
|
||||
try:
|
||||
dir_fd = os.open(directory, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(dir_fd)
|
||||
finally:
|
||||
os.close(dir_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def disengage(self) -> None:
|
||||
import os
|
||||
|
||||
try:
|
||||
os.remove(self._path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
def state(self) -> EmergencyStopState:
|
||||
import json
|
||||
import os
|
||||
|
||||
if not os.path.exists(self._path):
|
||||
return EmergencyStopState(engaged=False)
|
||||
try:
|
||||
with open(self._path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("sentinel is not a JSON object")
|
||||
return EmergencyStopState(
|
||||
engaged=True,
|
||||
reason=str(data.get("reason", "")),
|
||||
actor=str(data.get("actor", "")),
|
||||
at=float(data.get("at", 0.0) or 0.0),
|
||||
)
|
||||
except (OSError, TypeError, ValueError):
|
||||
# Fail-safe: unreadable/corrupt sentinel counts as engaged. A valid
|
||||
# JSON object with a non-numeric ``at`` (e.g. a list) raises
|
||||
# TypeError from ``float()`` and must also fail-safe engaged.
|
||||
return EmergencyStopState(engaged=True, reason="unreadable-sentinel")
|
||||
@@ -512,7 +512,7 @@ class Knowledge:
|
||||
# Check if input is URL
|
||||
if isinstance(input_path, str) and (input_path.startswith('http://') or input_path.startswith('https://')):
|
||||
self._log(f"Processing URL: {input_path}")
|
||||
raise NotImplementedError("URL processing not yet implemented")
|
||||
return self._process_url(input_path, user_id, agent_id, run_id, metadata)
|
||||
|
||||
# CRITICAL FIX: Check if input is a directory - recursively process all files
|
||||
if os.path.isdir(input_path):
|
||||
@@ -626,7 +626,65 @@ class Knowledge:
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing input {input_path}: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
def _process_url(self, url, user_id=None, agent_id=None, run_id=None, metadata=None):
|
||||
"""Fetch a URL, extract its content, and store it in memory.
|
||||
|
||||
Uses MarkItDown (already a dependency for document extraction) to fetch
|
||||
and convert the remote resource, then chunks and stores it through the
|
||||
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.
|
||||
"""
|
||||
result = self.markdown.convert(url)
|
||||
content = getattr(result, 'text_content', None)
|
||||
if not content:
|
||||
raise ValueError(f"No content could be extracted from URL: {url}")
|
||||
|
||||
chunks = self.chunker.chunk(content)
|
||||
memories = [
|
||||
chunk.text.strip() if hasattr(chunk, 'text') else str(chunk).strip()
|
||||
for chunk in chunks if chunk
|
||||
]
|
||||
|
||||
if not metadata:
|
||||
metadata = {}
|
||||
metadata['source_type'] = 'url'
|
||||
metadata['source'] = url
|
||||
|
||||
all_results = []
|
||||
for memory in memories:
|
||||
if not memory:
|
||||
continue
|
||||
memory_result = self.store(
|
||||
memory, user_id=user_id, agent_id=agent_id,
|
||||
run_id=run_id, metadata=metadata,
|
||||
)
|
||||
if not memory_result:
|
||||
continue
|
||||
if isinstance(memory_result, dict):
|
||||
all_results.extend(memory_result.get('results', []))
|
||||
elif isinstance(memory_result, list):
|
||||
all_results.extend(memory_result)
|
||||
else:
|
||||
from .models import AddResult
|
||||
if isinstance(memory_result, AddResult):
|
||||
if not memory_result.success:
|
||||
raise RuntimeError(
|
||||
memory_result.message or "Failed to store chunk"
|
||||
)
|
||||
all_results.append(memory_result.id)
|
||||
elif hasattr(memory_result, 'results'):
|
||||
all_results.extend(memory_result.results)
|
||||
elif isinstance(memory_result, str):
|
||||
# Some backends (e.g. MongoDB) return a bare document id.
|
||||
all_results.append(memory_result)
|
||||
|
||||
self._emit_knowledge_event(
|
||||
"add", source=url, chunk_count=len(memories),
|
||||
metadata=metadata, agent_id=agent_id,
|
||||
)
|
||||
return {'results': all_results, 'relations': []}
|
||||
|
||||
def index(
|
||||
self,
|
||||
path: str,
|
||||
|
||||
@@ -107,6 +107,22 @@ def __getattr__(name):
|
||||
from .rate_limiter import RateLimiter
|
||||
_lazy_cache[name] = RateLimiter
|
||||
return RateLimiter
|
||||
elif name == "QuotaCoordinatorProtocol":
|
||||
from .quota import QuotaCoordinatorProtocol
|
||||
_lazy_cache[name] = QuotaCoordinatorProtocol
|
||||
return QuotaCoordinatorProtocol
|
||||
elif name == "QuotaCoordinatorConfig":
|
||||
from .quota import QuotaCoordinatorConfig
|
||||
_lazy_cache[name] = QuotaCoordinatorConfig
|
||||
return QuotaCoordinatorConfig
|
||||
elif name == "LocalQuotaCoordinator":
|
||||
from .quota import LocalQuotaCoordinator
|
||||
_lazy_cache[name] = LocalQuotaCoordinator
|
||||
return LocalQuotaCoordinator
|
||||
elif name == "build_quota_coordinator":
|
||||
from .quota import build_quota_coordinator
|
||||
_lazy_cache[name] = build_quota_coordinator
|
||||
return build_quota_coordinator
|
||||
elif name == "TokenUsage":
|
||||
from .llm import TokenUsage
|
||||
_lazy_cache[name] = TokenUsage
|
||||
@@ -229,6 +245,11 @@ __all__ = [
|
||||
"create_routing_agent",
|
||||
"RateLimiter",
|
||||
"TokenUsage",
|
||||
# Provider quota coordination (cross-replica credential cooldowns)
|
||||
"QuotaCoordinatorProtocol",
|
||||
"QuotaCoordinatorConfig",
|
||||
"LocalQuotaCoordinator",
|
||||
"build_quota_coordinator",
|
||||
# Protocols
|
||||
"LLMProviderProtocol",
|
||||
"ModelCapabilitiesProtocol",
|
||||
|
||||
@@ -16,6 +16,7 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
@@ -25,6 +26,9 @@ from typing import (
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .quota import QuotaCoordinatorProtocol
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class ProviderStatus(str, Enum):
|
||||
@@ -112,6 +116,29 @@ class AuthProfile:
|
||||
metadata=data.get("metadata", {}),
|
||||
)
|
||||
|
||||
@property
|
||||
def credential_id(self) -> str:
|
||||
"""Stable, non-secret identity for cross-replica quota coordination.
|
||||
|
||||
The coordinator keys benches by the *credential*, not the profile
|
||||
label. ``name`` is a local display label: two replicas may name the
|
||||
same key differently (breaking cooldown propagation) or reuse a name
|
||||
like ``"default"`` for different keys (benching an unrelated key).
|
||||
Derive a deterministic id from the fields that identify the credential
|
||||
(provider, api_key, base_url) using a short salted hash so the real key
|
||||
is never exposed. Falls back to ``name`` when no api_key is present.
|
||||
"""
|
||||
if not self.api_key:
|
||||
return self.name
|
||||
import hashlib
|
||||
|
||||
digest = hashlib.sha256(
|
||||
f"{self.provider}\x00{self.base_url or ''}\x00{self.api_key}".encode(
|
||||
"utf-8", "ignore"
|
||||
)
|
||||
).hexdigest()[:16]
|
||||
return f"{self.provider}:{digest}"
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""Check if this profile is currently available."""
|
||||
@@ -207,13 +234,27 @@ class FailoverManager:
|
||||
profile = manager.get_next_profile()
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[FailoverConfig] = None):
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[FailoverConfig] = None,
|
||||
coordinator: Optional["QuotaCoordinatorProtocol"] = None,
|
||||
):
|
||||
"""Initialize the failover manager.
|
||||
|
||||
Args:
|
||||
config: Failover configuration
|
||||
coordinator: Optional shared quota coordinator. When supplied
|
||||
(e.g. a Redis-backed one wired by the gateway), credential
|
||||
benches/cooldowns are coordinated fleet-wide so a key benched
|
||||
by any replica is skipped by all replicas. Defaults to an
|
||||
in-memory local coordinator, keeping single-replica behaviour
|
||||
exactly as before with no new dependency and no hot-path cost.
|
||||
"""
|
||||
self.config = config or FailoverConfig()
|
||||
if coordinator is None:
|
||||
from .quota import LocalQuotaCoordinator
|
||||
coordinator = LocalQuotaCoordinator()
|
||||
self._coordinator: "QuotaCoordinatorProtocol" = coordinator
|
||||
self._profiles: List[AuthProfile] = []
|
||||
self._current_index: int = 0
|
||||
self._on_failover_callbacks: List[Callable[[AuthProfile, AuthProfile], None]] = []
|
||||
@@ -274,6 +315,30 @@ class FailoverManager:
|
||||
with self._lock:
|
||||
return list(self._profiles)
|
||||
|
||||
def _sync_bench_from_coordinator(
|
||||
self, profile: AuthProfile, now: Optional[float] = None
|
||||
) -> None:
|
||||
"""Apply a fleet-wide bench from the coordinator onto a local profile.
|
||||
|
||||
If another replica benched this credential, reflect its cooldown here
|
||||
so this replica skips it too. Fail-open: never raise into failover.
|
||||
Callers already hold ``self._lock``.
|
||||
"""
|
||||
try:
|
||||
until = self._coordinator.benched_until(profile.credential_id, now=now)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.warning(
|
||||
f"Quota coordinator lookup failed for '{profile.name}': {e}; "
|
||||
f"using local cooldown state"
|
||||
)
|
||||
return
|
||||
if until is not None and (
|
||||
profile.cooldown_until is None or until > profile.cooldown_until
|
||||
):
|
||||
if profile.status == ProviderStatus.AVAILABLE:
|
||||
profile.status = ProviderStatus.RATE_LIMITED
|
||||
profile.cooldown_until = until
|
||||
|
||||
def get_next_profile(self) -> Optional[AuthProfile]:
|
||||
"""Get the next available profile.
|
||||
|
||||
@@ -287,9 +352,10 @@ class FailoverManager:
|
||||
if not self._profiles:
|
||||
return None
|
||||
|
||||
# First, check if any cooldowns have expired
|
||||
# First, sync fleet-wide benches, then check if any cooldowns have expired.
|
||||
current_time = time.time()
|
||||
for profile in self._profiles:
|
||||
self._sync_bench_from_coordinator(profile, now=current_time)
|
||||
if profile.cooldown_until and current_time >= profile.cooldown_until:
|
||||
profile.reset()
|
||||
|
||||
@@ -328,12 +394,29 @@ class FailoverManager:
|
||||
f"Profile '{profile.name}' rate limited, "
|
||||
f"cooldown for {self.config.cooldown_on_rate_limit}s"
|
||||
)
|
||||
reason = "rate_limit"
|
||||
else:
|
||||
profile.mark_error(error, self.config.cooldown_on_error)
|
||||
logger.warning(
|
||||
f"Profile '{profile.name}' error: {error}, "
|
||||
f"cooldown for {self.config.cooldown_on_error}s"
|
||||
)
|
||||
reason = "error"
|
||||
|
||||
# Share the bench fleet-wide so other replicas skip this key too.
|
||||
# Fail-open: a coordinator hiccup must never wedge failover.
|
||||
if profile.cooldown_until is not None:
|
||||
try:
|
||||
self._coordinator.bench(
|
||||
profile.credential_id,
|
||||
until=profile.cooldown_until,
|
||||
reason=reason,
|
||||
)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.warning(
|
||||
f"Quota coordinator bench failed for '{profile.name}': {e}; "
|
||||
f"falling back to local cooldown only"
|
||||
)
|
||||
|
||||
# Resolve the target profile and snapshot callbacks while holding
|
||||
# the lock, but invoke them AFTER releasing it. User callbacks may
|
||||
@@ -361,8 +444,30 @@ class FailoverManager:
|
||||
"""
|
||||
with self._lock:
|
||||
if profile.status != ProviderStatus.AVAILABLE:
|
||||
# Snapshot the cooldown this success is recovering from BEFORE
|
||||
# resetting, so we can avoid clobbering a newer fleet-wide bench.
|
||||
recovered_from = profile.cooldown_until
|
||||
profile.reset()
|
||||
logger.info(f"Profile '{profile.name}' recovered")
|
||||
cred_id = profile.credential_id
|
||||
try:
|
||||
# A concurrent/other-replica failure may have benched this
|
||||
# credential with a LATER expiry than the cooldown this
|
||||
# (possibly stale, already in-flight) success is recovering
|
||||
# from. Clearing unconditionally would resume a credential
|
||||
# that is still rate-limited. Only clear when the shared
|
||||
# bench is not newer than what we recovered from.
|
||||
shared_until = self._coordinator.benched_until(cred_id)
|
||||
if (
|
||||
shared_until is None
|
||||
or recovered_from is None
|
||||
or shared_until <= recovered_from
|
||||
):
|
||||
self._coordinator.clear(cred_id)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.warning(
|
||||
f"Quota coordinator clear failed for '{profile.name}': {e}"
|
||||
)
|
||||
|
||||
def on_failover(
|
||||
self,
|
||||
|
||||
@@ -28,6 +28,14 @@ from ..tools.call_executor import ToolCall, create_tool_call_executor
|
||||
from ..tools.schema import build_tool_definition
|
||||
|
||||
|
||||
# Sentinel returned as the "arguments" value when a tool call's argument string
|
||||
# cannot be parsed (e.g. truncated by max_tokens or a dropped connection).
|
||||
# It is distinct from {} ("no arguments"): {} would silently execute the tool
|
||||
# with the WRONG arguments and report success. Callers must detect this sentinel
|
||||
# and surface a tool-error so the model can re-emit the call instead.
|
||||
_TOOL_ARGUMENTS_PARSE_FAILED = object()
|
||||
|
||||
|
||||
def _durable_iteration_kwargs(execute_tool_fn: Callable, index: int) -> Dict[str, int]:
|
||||
if getattr(execute_tool_fn, "_accepts_durable_iteration", False):
|
||||
return {"_durable_iteration_index": index}
|
||||
@@ -1844,7 +1852,10 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
if isinstance(tool_call.get("function"), dict)
|
||||
else None
|
||||
) or tool_call.get("name", "unknown_function")
|
||||
arguments = {}
|
||||
# A parse failure is NOT "no arguments". Returning {} here would run
|
||||
# the tool with the wrong arguments and report success. Signal the
|
||||
# failure so callers surface a tool-error instead of dispatching.
|
||||
arguments = _TOOL_ARGUMENTS_PARSE_FAILED
|
||||
tool_call_id = tool_call.get("id", f"tool_{id(tool_call)}")
|
||||
|
||||
return function_name, arguments, tool_call_id
|
||||
@@ -2399,8 +2410,8 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
# Handle Gemini internal tools (e.g., {"googleSearch": {}}, {"urlContext": {}}, {"codeExecution": {}})
|
||||
elif isinstance(tool, dict) and len(tool) == 1:
|
||||
tool_name = next(iter(tool.keys()))
|
||||
gemini_internal_tools = {'googleSearch', 'urlContext', 'codeExecution'}
|
||||
if tool_name in gemini_internal_tools:
|
||||
from .model_capabilities import GEMINI_INTERNAL_TOOLS
|
||||
if tool_name in GEMINI_INTERNAL_TOOLS:
|
||||
logging.debug(f"Using Gemini internal tool: {tool_name}")
|
||||
formatted_tools.append(tool)
|
||||
else:
|
||||
@@ -2509,7 +2520,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
prompt: Union[str, List[Dict]],
|
||||
system_prompt: Optional[str] = None,
|
||||
chat_history: Optional[List[Dict]] = None,
|
||||
temperature: float = 1.0,
|
||||
temperature: Optional[float] = None,
|
||||
tools: Optional[List[Any]] = None,
|
||||
output_json: Optional[BaseModel] = None,
|
||||
output_pydantic: Optional[BaseModel] = None,
|
||||
@@ -2826,10 +2837,17 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
# Execute tool calls using ToolCallExecutor (Gap 2: parallel or sequential)
|
||||
is_ollama = self._is_ollama_provider()
|
||||
tool_calls_batch = []
|
||||
parse_error_messages = []
|
||||
|
||||
# Prepare batch of ToolCall objects
|
||||
for tool_call in tool_calls:
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama=is_ollama)
|
||||
if self._tool_arguments_parse_failed(arguments):
|
||||
# Do NOT dispatch with {}: surface a retryable error.
|
||||
parse_error_messages.append(
|
||||
self._tool_parse_error_message(function_name, tool_call_id)
|
||||
)
|
||||
continue
|
||||
tool_calls_batch.append(ToolCall(
|
||||
function_name=function_name,
|
||||
arguments=arguments,
|
||||
@@ -2903,6 +2921,11 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
"content": content,
|
||||
})
|
||||
|
||||
# Report any unparseable tool-call arguments so the
|
||||
# model can re-emit them (never dispatched with {}).
|
||||
for _err_msg in parse_error_messages:
|
||||
messages.append(_err_msg)
|
||||
|
||||
# Safety: break after max_iter iterations
|
||||
if iteration_count + 1 >= max_iterations:
|
||||
self._last_stop_reason = "max_steps"
|
||||
@@ -3606,6 +3629,11 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
is_ollama = self._is_ollama_provider()
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama)
|
||||
|
||||
if self._tool_arguments_parse_failed(arguments):
|
||||
# Do NOT dispatch with {}: surface a retryable error.
|
||||
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
|
||||
continue
|
||||
|
||||
# Validate and filter arguments for Ollama provider
|
||||
if is_ollama and tools:
|
||||
# First check if any argument references a previous tool result
|
||||
@@ -4165,7 +4193,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
prompt: Union[str, List[Dict]],
|
||||
system_prompt: Optional[str] = None,
|
||||
chat_history: Optional[List[Dict]] = None,
|
||||
temperature: float = 1.0,
|
||||
temperature: Optional[float] = None,
|
||||
tools: Optional[List[Any]] = None,
|
||||
output_json: Optional[BaseModel] = None,
|
||||
output_pydantic: Optional[BaseModel] = None,
|
||||
@@ -4350,10 +4378,17 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Execute tool calls using ToolCallExecutor (Gap 2: parallel or sequential)
|
||||
is_ollama = self._is_ollama_provider()
|
||||
tool_calls_batch = []
|
||||
parse_error_messages = []
|
||||
|
||||
# Prepare batch of ToolCall objects
|
||||
for tool_call in tool_calls:
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama)
|
||||
if self._tool_arguments_parse_failed(arguments):
|
||||
# Do NOT dispatch with {}: surface a retryable error.
|
||||
parse_error_messages.append(
|
||||
self._tool_parse_error_message(function_name, tool_call_id)
|
||||
)
|
||||
continue
|
||||
tool_calls_batch.append(ToolCall(
|
||||
function_name=function_name,
|
||||
arguments=arguments,
|
||||
@@ -4393,6 +4428,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
tool_result.is_ollama
|
||||
)
|
||||
messages.append(tool_message)
|
||||
|
||||
# Report any unparseable tool-call arguments so the
|
||||
# model can re-emit them (never dispatched with {}).
|
||||
for _err_msg in parse_error_messages:
|
||||
messages.append(_err_msg)
|
||||
|
||||
# Continue conversation after tool execution - get follow-up response
|
||||
try:
|
||||
@@ -4500,7 +4540,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
prompt: Union[str, List[Dict]],
|
||||
system_prompt: Optional[str] = None,
|
||||
chat_history: Optional[List[Dict]] = None,
|
||||
temperature: float = 1.0,
|
||||
temperature: Optional[float] = None,
|
||||
tools: Optional[List[Any]] = None,
|
||||
output_json: Optional[BaseModel] = None,
|
||||
output_pydantic: Optional[BaseModel] = None,
|
||||
@@ -4730,6 +4770,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
for tool_call in tool_calls:
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
|
||||
if self._tool_arguments_parse_failed(arguments):
|
||||
# Do NOT dispatch with {}: surface a retryable error.
|
||||
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
|
||||
continue
|
||||
logging.debug(f"[RESPONSES_API_ASYNC] Executing tool {function_name}")
|
||||
tool_result = await _dispatch_async_tool(
|
||||
execute_tool_fn,
|
||||
@@ -4979,6 +5023,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
is_ollama = self._is_ollama_provider()
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama)
|
||||
|
||||
if self._tool_arguments_parse_failed(arguments):
|
||||
# Do NOT dispatch with {}: surface a retryable error.
|
||||
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
|
||||
continue
|
||||
|
||||
# Validate and filter arguments for Ollama provider
|
||||
if is_ollama and tools:
|
||||
arguments = self._validate_and_filter_ollama_arguments(function_name, arguments, tools)
|
||||
@@ -5689,6 +5738,16 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Override with any provided parameters
|
||||
params.update(override_params)
|
||||
|
||||
# Resolve temperature from the instance when the caller did not specify
|
||||
# one. Public entry points now default temperature to None so a value
|
||||
# configured via Agent(llm={'temperature': ...}) actually reaches the
|
||||
# request instead of being silently overridden by a hardcoded 1.0.
|
||||
if params.get("temperature") is None:
|
||||
if self.temperature is not None:
|
||||
params["temperature"] = self.temperature
|
||||
else:
|
||||
params.pop("temperature", None)
|
||||
|
||||
# Handle structured output parameters
|
||||
output_json = override_params.get('output_json')
|
||||
@@ -6323,6 +6382,17 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
is_reasoning=False,
|
||||
))
|
||||
|
||||
elif evt_type == "response.reasoning_summary_text.delta":
|
||||
delta_text = (event.get("delta", "") if isinstance(event, dict)
|
||||
else getattr(event, "delta", ""))
|
||||
if _emit and delta_text:
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.DELTA_TEXT,
|
||||
timestamp=time.perf_counter(),
|
||||
content=delta_text,
|
||||
is_reasoning=True,
|
||||
))
|
||||
|
||||
elif evt_type == "response.function_call_arguments.delta":
|
||||
idx = (event.get("output_index", 0) if isinstance(event, dict)
|
||||
else getattr(event, "output_index", 0))
|
||||
@@ -6435,17 +6505,41 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
tool_call_id = tool_call.id
|
||||
except (json.JSONDecodeError, AttributeError) as e:
|
||||
logging.error(f"Error parsing object-style tool call: {e}")
|
||||
function_name = "unknown_function"
|
||||
arguments = {}
|
||||
tool_call_id = f"tool_{id(tool_call)}"
|
||||
function_name = getattr(getattr(tool_call, 'function', None), 'name', None) or "unknown_function"
|
||||
# See _parse_tool_call_arguments: a parse failure is not {}.
|
||||
arguments = _TOOL_ARGUMENTS_PARSE_FAILED
|
||||
tool_call_id = getattr(tool_call, 'id', None) or f"tool_{id(tool_call)}"
|
||||
return function_name, arguments, tool_call_id
|
||||
|
||||
@staticmethod
|
||||
def _tool_arguments_parse_failed(arguments) -> bool:
|
||||
"""True when tool-call arguments could not be parsed (vs. legitimately empty)."""
|
||||
return arguments is _TOOL_ARGUMENTS_PARSE_FAILED
|
||||
|
||||
@staticmethod
|
||||
def _tool_parse_error_message(function_name: str, tool_call_id: str) -> Dict[str, str]:
|
||||
"""Build a tool-role message telling the model its arguments were lost.
|
||||
|
||||
Surfacing this instead of dispatching with {} converts a silent
|
||||
wrong-action-reported-as-success into a visible, retryable error.
|
||||
"""
|
||||
return {
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": (
|
||||
f"Error: arguments for tool '{function_name}' could not be parsed "
|
||||
f"(the argument string was invalid or truncated). "
|
||||
f"The tool was NOT executed. Please re-emit the tool call with "
|
||||
f"complete, valid JSON arguments."
|
||||
),
|
||||
}
|
||||
|
||||
# Response without tool calls
|
||||
def response(
|
||||
self,
|
||||
prompt: Union[str, List[Dict]],
|
||||
system_prompt: Optional[str] = None,
|
||||
temperature: float = 1.0,
|
||||
temperature: Optional[float] = None,
|
||||
stream: bool = True,
|
||||
verbose: bool = True,
|
||||
markdown: bool = True,
|
||||
@@ -6539,7 +6633,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self,
|
||||
prompt: Union[str, List[Dict]],
|
||||
system_prompt: Optional[str] = None,
|
||||
temperature: float = 1.0,
|
||||
temperature: Optional[float] = None,
|
||||
stream: bool = True,
|
||||
verbose: bool = True,
|
||||
markdown: bool = True,
|
||||
|
||||
@@ -1579,10 +1579,13 @@ class OpenAIClient:
|
||||
params = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"stream": stream,
|
||||
**kwargs
|
||||
}
|
||||
# Omit temperature when unset so the provider default applies rather
|
||||
# than sending an explicit None.
|
||||
if temperature is not None:
|
||||
params["temperature"] = temperature
|
||||
|
||||
# Add tools if provided
|
||||
if tools:
|
||||
@@ -1640,10 +1643,13 @@ class OpenAIClient:
|
||||
params = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"stream": stream,
|
||||
**kwargs
|
||||
}
|
||||
# Omit temperature when unset so the provider default applies rather
|
||||
# than sending an explicit None.
|
||||
if temperature is not None:
|
||||
params["temperature"] = temperature
|
||||
|
||||
# Add tools if provided
|
||||
if tools:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Provider Quota Coordination for PraisonAI Agents.
|
||||
|
||||
Provides a lightweight, protocol-driven seam so provider-quota state
|
||||
(credential cooldowns / benches) can be coordinated *fleet-wide* when a
|
||||
shared backend is configured, while defaulting to today's in-process,
|
||||
in-memory behaviour with zero new dependencies and no hot-path change.
|
||||
|
||||
Design:
|
||||
- ``QuotaCoordinatorProtocol`` is a pure contract (stdlib + typing only).
|
||||
- ``LocalQuotaCoordinator`` is the default in-memory implementation used
|
||||
when no shared backend is selected — single-replica deployments behave
|
||||
exactly as before.
|
||||
- A concrete shared backend (e.g. Redis, reusing the gateway's existing
|
||||
connection) implements the same protocol in the wrapper/bot package and
|
||||
is selected via ``QuotaCoordinatorConfig``. Core keeps only the protocol
|
||||
and the in-memory default.
|
||||
|
||||
Fail-open by contract: implementations MUST degrade to local behaviour on
|
||||
backend errors rather than wedge a healthy request path, and bench entries
|
||||
carry TTLs so a dead replica's benches self-expire.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class QuotaCoordinatorProtocol(Protocol):
|
||||
"""Contract for coordinating provider-quota state across replicas.
|
||||
|
||||
A coordinator tracks per-credential cooldowns ("benches"). When a shared
|
||||
backend is configured, a credential benched by any replica is treated as
|
||||
benched by all replicas until its cooldown expires.
|
||||
"""
|
||||
|
||||
def bench(self, cred_id: str, *, until: float, reason: str = "") -> None:
|
||||
"""Bench a credential until ``until`` (an epoch timestamp, TTL-based)."""
|
||||
...
|
||||
|
||||
def is_benched(self, cred_id: str, *, now: Optional[float] = None) -> bool:
|
||||
"""Return True if the credential is currently benched."""
|
||||
...
|
||||
|
||||
def benched_until(self, cred_id: str, *, now: Optional[float] = None) -> Optional[float]:
|
||||
"""Return the epoch timestamp the credential is benched until, or None."""
|
||||
...
|
||||
|
||||
def clear(self, cred_id: str) -> None:
|
||||
"""Clear any bench for the credential (e.g. on recovery)."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuotaCoordinatorConfig:
|
||||
"""Configuration for provider-quota coordination.
|
||||
|
||||
Attributes:
|
||||
backend: ``"local"`` (in-memory, default) or a shared backend name
|
||||
such as ``"redis"`` provided by the wrapper/bot package.
|
||||
url: Optional backend URL; when omitted a shared backend reuses the
|
||||
already-established connection (e.g. gateway ``RedisConfig``).
|
||||
"""
|
||||
|
||||
backend: str = "local"
|
||||
url: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Optional[str]]:
|
||||
return {"backend": self.backend, "url": self.url}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Optional[str]]) -> "QuotaCoordinatorConfig":
|
||||
return cls(
|
||||
backend=(data.get("backend") or "local"),
|
||||
url=data.get("url"),
|
||||
)
|
||||
|
||||
|
||||
class LocalQuotaCoordinator:
|
||||
"""In-memory ``QuotaCoordinatorProtocol`` (default, zero-dependency).
|
||||
|
||||
Thread-safe via a lightweight lock. Bench entries carry an expiry so
|
||||
they self-clear once the cooldown passes — mirroring the TTL semantics a
|
||||
shared backend uses so behaviour is identical across implementations.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
import threading
|
||||
|
||||
self._benches: Dict[str, float] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def bench(self, cred_id: str, *, until: float, reason: str = "") -> None:
|
||||
with self._lock:
|
||||
existing = self._benches.get(cred_id)
|
||||
# Keep the longest cooldown if a longer one is already set.
|
||||
if existing is None or until > existing:
|
||||
self._benches[cred_id] = until
|
||||
|
||||
def is_benched(self, cred_id: str, *, now: Optional[float] = None) -> bool:
|
||||
return self.benched_until(cred_id, now=now) is not None
|
||||
|
||||
def benched_until(self, cred_id: str, *, now: Optional[float] = None) -> Optional[float]:
|
||||
current = time.time() if now is None else now
|
||||
with self._lock:
|
||||
until = self._benches.get(cred_id)
|
||||
if until is None:
|
||||
return None
|
||||
if current >= until:
|
||||
# Expired — self-clear.
|
||||
self._benches.pop(cred_id, None)
|
||||
return None
|
||||
return until
|
||||
|
||||
def clear(self, cred_id: str) -> None:
|
||||
with self._lock:
|
||||
self._benches.pop(cred_id, None)
|
||||
|
||||
|
||||
def build_quota_coordinator(
|
||||
config: Optional[QuotaCoordinatorConfig] = None,
|
||||
) -> QuotaCoordinatorProtocol:
|
||||
"""Build a coordinator from config, defaulting to the local in-memory one.
|
||||
|
||||
Only the ``"local"`` backend is resolvable from core. Shared backends
|
||||
(e.g. ``"redis"``) are provided by the wrapper/bot package, which passes
|
||||
an already-constructed coordinator instance to ``FailoverManager``; this
|
||||
helper falls back to local for unknown backends so core never grows a
|
||||
heavy optional dependency.
|
||||
"""
|
||||
if config is None or config.backend == "local":
|
||||
return LocalQuotaCoordinator()
|
||||
# Unknown/heavy backend not resolvable in core: fail open to local.
|
||||
return LocalQuotaCoordinator()
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
import sqlite3
|
||||
import json
|
||||
import time
|
||||
import asyncio
|
||||
import shutil
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional, Union, Literal
|
||||
@@ -279,6 +280,27 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
# Initialize use_vector_search to False for non-MongoDB providers
|
||||
self.use_vector_search = False
|
||||
|
||||
@staticmethod
|
||||
def _path_safe_suffix(user_id: Any) -> str:
|
||||
"""Return a filesystem-safe ``_<user_id>`` suffix for default store paths.
|
||||
|
||||
The scoping ``user_id`` doubles as a metadata filter, so it may be any
|
||||
string. When it is embedded into default sqlite/chroma paths a raw value
|
||||
containing separators or traversal (e.g. ``a/b`` or ``../escape``) would
|
||||
create stores outside the project-data dir or break sqlite init. Reject
|
||||
path separators/parent refs and keep only path-safe characters; a bare
|
||||
store with no user_id keeps the original shared defaults.
|
||||
"""
|
||||
if not user_id:
|
||||
return ""
|
||||
text = str(user_id)
|
||||
if ".." in text or "/" in text or "\\" in text or "\x00" in text:
|
||||
raise ValueError(
|
||||
"memory user_id must not contain path separators or parent references"
|
||||
)
|
||||
safe = "".join(c if (c.isalnum() or c in "-_.") else "_" for c in text.strip())
|
||||
return f"_{safe}" if safe else ""
|
||||
|
||||
def _get_adapter_config(self) -> Dict[str, Any]:
|
||||
"""Get configuration for adapter initialization."""
|
||||
# Only include adapter-relevant configuration
|
||||
@@ -296,11 +318,18 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
from ..paths import get_project_data_dir
|
||||
project_data = str(get_project_data_dir())
|
||||
os.makedirs(project_data, exist_ok=True)
|
||||
|
||||
config["short_db"] = self.cfg.get("short_db", os.path.join(project_data, "short_term.db"))
|
||||
config["long_db"] = self.cfg.get("long_db", os.path.join(project_data, "long_term.db"))
|
||||
config["rag_db_path"] = self.cfg.get("rag_db_path", os.path.join(project_data, "chroma_db"))
|
||||
config["collection_name"] = self.cfg.get("collection_name", "memory_store")
|
||||
|
||||
# When a user_id scopes this store, derive per-user default paths/names so
|
||||
# two agents in one process/dir don't silently share one on-disk store.
|
||||
# Explicit short_db/long_db/rag_db_path/collection_name always win; a bare
|
||||
# store with no user_id keeps the original shared defaults (backwards
|
||||
# compatible).
|
||||
suffix = self._path_safe_suffix(self.cfg.get("user_id"))
|
||||
|
||||
config["short_db"] = self.cfg.get("short_db", os.path.join(project_data, f"short_term{suffix}.db"))
|
||||
config["long_db"] = self.cfg.get("long_db", os.path.join(project_data, f"long_term{suffix}.db"))
|
||||
config["rag_db_path"] = self.cfg.get("rag_db_path", os.path.join(project_data, f"chroma_db{suffix}"))
|
||||
config["collection_name"] = self.cfg.get("collection_name", f"memory_store{suffix}")
|
||||
config["verbose"] = self.verbose
|
||||
|
||||
# Add specific configurations for different adapters
|
||||
@@ -337,9 +366,13 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
# This ensures existing code that accesses _get_stm_conn() still works
|
||||
from ..paths import get_project_data_dir
|
||||
project_data = str(get_project_data_dir())
|
||||
|
||||
self.short_db = self.cfg.get("short_db", os.path.join(project_data, "short_term.db"))
|
||||
self.long_db = self.cfg.get("long_db", os.path.join(project_data, "long_term.db"))
|
||||
|
||||
# Scope legacy direct-SQLite defaults by user_id so this path matches the
|
||||
# per-user store paths derived in _get_adapter_config (explicit paths win).
|
||||
suffix = self._path_safe_suffix(self.cfg.get("user_id"))
|
||||
|
||||
self.short_db = self.cfg.get("short_db", os.path.join(project_data, f"short_term{suffix}.db"))
|
||||
self.long_db = self.cfg.get("long_db", os.path.join(project_data, f"long_term{suffix}.db"))
|
||||
|
||||
# Only create separate SQLite adapter if primary adapter is not SQLite
|
||||
if self.provider != "sqlite":
|
||||
@@ -358,6 +391,110 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
self._all_connections = self._sqlite_adapter._all_connections
|
||||
self._connection_lock = self._sqlite_adapter._connection_lock
|
||||
|
||||
@staticmethod
|
||||
def _resolve_batch_target(method_name: str) -> str:
|
||||
"""Map a staged write name to its ``store_*`` persistence method."""
|
||||
target = method_name.replace("add_", "store_", 1)
|
||||
if target not in ("store_short_term", "store_long_term"):
|
||||
raise ValueError(f"Unsupported staged memory write {method_name!r}")
|
||||
return target
|
||||
|
||||
def commit_memory_batch(
|
||||
self,
|
||||
writes: List[tuple],
|
||||
*,
|
||||
deadline: float,
|
||||
commit_guard: Any = None,
|
||||
) -> None:
|
||||
"""Persist a batch of staged memory writes.
|
||||
|
||||
Mirrors ``FileMemory.commit_memory_batch`` so the pre-compaction fact
|
||||
flush (compaction/memory_flush.py) durably commits extracted facts for
|
||||
sqlite/chroma/mongodb/mem0 backends instead of raising and being
|
||||
swallowed. Each staged tuple is ``(method_name, args, kwargs)`` produced
|
||||
by _StagedMemory; ``add_*`` names map to the ``store_*`` equivalents.
|
||||
|
||||
The ``store_*`` methods serialize their own SQLite writes with
|
||||
``_write_lock``; this method therefore must NOT hold that lock while
|
||||
calling them (it is a non-reentrant ``threading.Lock`` and doing so
|
||||
would deadlock). Each write goes through ``commit_guard.commit`` so a
|
||||
write is skipped once the flush has been cancelled or the shared
|
||||
``deadline`` has passed, honoring the same atomic-commit contract as the
|
||||
file backend.
|
||||
"""
|
||||
for method_name, args, kwargs in writes:
|
||||
target = self._resolve_batch_target(method_name)
|
||||
store = getattr(self, target)
|
||||
authorized = self._authorize_commit(
|
||||
lambda store=store, args=args, kwargs=kwargs: store(*args, **kwargs),
|
||||
deadline=deadline,
|
||||
commit_guard=commit_guard,
|
||||
)
|
||||
if not authorized:
|
||||
return
|
||||
|
||||
async def acommit_memory_batch(
|
||||
self,
|
||||
writes: List[tuple],
|
||||
*,
|
||||
deadline: float,
|
||||
commit_guard: Any = None,
|
||||
) -> None:
|
||||
"""Async variant preferring ``store_*_async`` when available.
|
||||
|
||||
Blocking ``store_*`` work is offloaded to a worker thread so the event
|
||||
loop is never blocked, and each write is gated by the same deadline /
|
||||
cancellation guard as the sync path so late or cancelled facts are not
|
||||
persisted after compaction resumes.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
for method_name, args, kwargs in writes:
|
||||
if self._commit_expired(deadline, commit_guard):
|
||||
return
|
||||
target = self._resolve_batch_target(method_name)
|
||||
async_method = getattr(self, f"{target}_async", None)
|
||||
if async_method is not None:
|
||||
if self._commit_expired(deadline, commit_guard):
|
||||
return
|
||||
result = async_method(*args, **kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
continue
|
||||
|
||||
store = getattr(self, target)
|
||||
authorized = await asyncio.to_thread(
|
||||
self._authorize_commit,
|
||||
lambda store=store, args=args, kwargs=kwargs: store(*args, **kwargs),
|
||||
deadline=deadline,
|
||||
commit_guard=commit_guard,
|
||||
)
|
||||
if not authorized:
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _commit_expired(deadline: float, commit_guard: Any) -> bool:
|
||||
"""True once the flush was cancelled or the shared deadline has passed."""
|
||||
if commit_guard is not None and getattr(commit_guard, "cancelled", False):
|
||||
return True
|
||||
return time.monotonic() >= deadline
|
||||
|
||||
def _authorize_commit(
|
||||
self, operation: Any, *, deadline: float, commit_guard: Any
|
||||
) -> bool:
|
||||
"""Run ``operation`` under the guard's atomic-commit gate.
|
||||
|
||||
Returns ``False`` (and skips the write) once cancelled or past the
|
||||
deadline. When no guard is supplied, fall back to a plain deadline
|
||||
check so callers outside the flush path still behave correctly.
|
||||
"""
|
||||
if commit_guard is not None and hasattr(commit_guard, "commit"):
|
||||
return commit_guard.commit(operation, deadline=deadline)
|
||||
if self._commit_expired(deadline, commit_guard):
|
||||
return False
|
||||
operation()
|
||||
return True
|
||||
|
||||
def _get_stm_conn(self):
|
||||
"""Get thread-local short-term memory SQLite connection."""
|
||||
if not hasattr(self._local, 'stm_conn') or self._local.stm_conn is None:
|
||||
@@ -1412,13 +1549,17 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
elif memory_type == "long_term":
|
||||
return self.delete_long_term(memory_id)
|
||||
|
||||
# Search both types
|
||||
if self.delete_short_term(memory_id):
|
||||
return True
|
||||
if self.delete_long_term(memory_id):
|
||||
return True
|
||||
|
||||
return False
|
||||
# No tier given: delete from BOTH tiers, never short-circuit on the first
|
||||
# hit. The SQLite adapter gives each tier its own AUTOINCREMENT sequence
|
||||
# (sqlite_adapter.py:83 and :107), so the Nth short-term row and the Nth
|
||||
# long-term row always share an id -- collision is the norm, not an edge
|
||||
# case. Returning on the first hit therefore deleted an unrelated
|
||||
# short-term row and left the caller's actual target in place, while
|
||||
# reporting success. `forget()` exposes no tier argument at all, so a
|
||||
# caller has no way to disambiguate.
|
||||
deleted_short = self.delete_short_term(memory_id)
|
||||
deleted_long = self.delete_long_term(memory_id)
|
||||
return deleted_short or deleted_long
|
||||
|
||||
def delete_memories(self, memory_ids: List[str]) -> int:
|
||||
"""
|
||||
@@ -1757,13 +1898,17 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
into a single text block with deduplication and clean formatting.
|
||||
|
||||
Args:
|
||||
include_in_output: If None, memory content is only included when debug logging is enabled.
|
||||
If True, memory content is always included.
|
||||
If False, memory content is never included (only logged for debugging).
|
||||
include_in_output: If None, memory content is included (the primary
|
||||
purpose of this method is to feed retrieved memory
|
||||
into the prompt). If True, memory content is always
|
||||
included. If False, memory content is never included
|
||||
(only logged for debugging).
|
||||
"""
|
||||
# Determine whether to include memory content in output based on logging level
|
||||
# Include retrieved memory by default. Gating this on DEBUG-level logging
|
||||
# meant Agent(memory=True) silently retrieved hits but never appended them
|
||||
# to the prompt under normal usage.
|
||||
if include_in_output is None:
|
||||
include_in_output = get_logger().getEffectiveLevel() == logging.DEBUG
|
||||
include_in_output = True
|
||||
|
||||
q = (task_descr + " " + additional).strip()
|
||||
lines = []
|
||||
@@ -1933,6 +2078,30 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
|
||||
return result
|
||||
|
||||
def get_context(self, query: Optional[str] = None, **kwargs) -> str:
|
||||
"""
|
||||
Return retrieved memory as a prompt-ready context string.
|
||||
|
||||
Satisfies ``AgentMemoryProtocol`` (used by ``Agent.get_memory_context()``)
|
||||
so the non-prompt-caching code path can inject memory into the system
|
||||
prompt. Without this, ``hasattr(memory, 'get_context')`` was False and the
|
||||
fallback branch always returned "".
|
||||
"""
|
||||
# A missing/empty query would make the local fallback search match every
|
||||
# record (LIKE "%%") with no relevance ordering, leaking unrelated (and
|
||||
# possibly other users') memories into the prompt. Retrieve nothing until
|
||||
# there is a meaningful query.
|
||||
if not query or not str(query).strip():
|
||||
return ""
|
||||
# Propagate the effective user id so user-scoped memories are included and
|
||||
# cross-user records are not returned by a shared Memory instance.
|
||||
user_id = kwargs.get("user_id") or self.cfg.get("user_id")
|
||||
return self.build_context_for_task(
|
||||
task_descr=query,
|
||||
user_id=user_id,
|
||||
include_in_output=True,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Master Reset (Everything)
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -2129,6 +2298,26 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
|
||||
def get_all_memories(self) -> List[Dict[str, Any]]:
|
||||
"""Get all memories from both short-term and long-term storage"""
|
||||
# Delegate to the active adapter whenever one is configured, mirroring
|
||||
# delete_short_term/reset_short_term. Data written through the adapter
|
||||
# lives in short_term_memory/long_term_memory; the legacy SELECTs below
|
||||
# target short_mem/long_mem, which nothing in the codebase creates, so
|
||||
# they raise OperationalError, get swallowed, and return [] every time.
|
||||
if (getattr(self, "memory_adapter", None)
|
||||
and hasattr(self.memory_adapter, "get_all_memories")):
|
||||
# Adapters expose the tier under different keys (the SQLite adapter
|
||||
# uses `memory_type`, others may use `type`). The legacy path below
|
||||
# returns `type`, so normalize to keep that public field populated
|
||||
# for backward compatibility while retaining any adapter-specific
|
||||
# keys the record already carries.
|
||||
return [
|
||||
{
|
||||
**record,
|
||||
"type": record.get("type") or record.get("memory_type"),
|
||||
}
|
||||
for record in self.memory_adapter.get_all_memories()
|
||||
]
|
||||
|
||||
all_memories = []
|
||||
|
||||
try:
|
||||
|
||||
@@ -257,6 +257,26 @@ class _LazyObsModule:
|
||||
obs = _LazyObsModule()
|
||||
|
||||
|
||||
# ``from praisonaiagents import obs`` returns *this submodule*, not the singleton
|
||||
# above (Python binds a real submodule as a package attribute, shadowing the
|
||||
# package ``__getattr__``). Make the submodule object itself callable and proxy
|
||||
# provider lookups to the singleton so ``obs(...)``, ``obs.auto()`` and
|
||||
# ``obs.langfuse()`` all work regardless of how ``obs`` is imported.
|
||||
import sys as _sys
|
||||
from types import ModuleType as _ModuleType
|
||||
|
||||
|
||||
class _CallableObsModule(_ModuleType):
|
||||
def __call__(self, provider: str = "auto", **kwargs):
|
||||
return obs(provider, **kwargs)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(obs, name)
|
||||
|
||||
|
||||
_sys.modules[__name__].__class__ = _CallableObsModule
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ObservabilityAdapter",
|
||||
"Span",
|
||||
|
||||
@@ -801,9 +801,9 @@ Subtask: {st.name}
|
||||
decision_str = None
|
||||
if current_task.result:
|
||||
if current_task.result.pydantic and hasattr(current_task.result.pydantic, "decision"):
|
||||
decision_str = current_task.result.pydantic.decision.lower()
|
||||
decision_str = current_task.result.pydantic.decision.lower().strip()
|
||||
elif current_task.result.raw:
|
||||
decision_str = current_task.result.raw.lower()
|
||||
decision_str = current_task.result.raw.lower().strip()
|
||||
|
||||
# For loop tasks, use "done" to follow condition path
|
||||
if current_task.task_type == "loop" and all(t.status == "completed" for t in subtasks):
|
||||
@@ -894,9 +894,9 @@ Subtask: {st.name}
|
||||
if current_task and current_task.result:
|
||||
if current_task.task_type in ["decision", "loop"]:
|
||||
# Get decision from pydantic or raw response
|
||||
decision_str = current_task.result.raw.lower()
|
||||
decision_str = current_task.result.raw.lower().strip()
|
||||
if current_task.result.pydantic and hasattr(current_task.result.pydantic, "decision"):
|
||||
decision_str = current_task.result.pydantic.decision.lower()
|
||||
decision_str = current_task.result.pydantic.decision.lower().strip()
|
||||
|
||||
# Check if task has conditions and next_tasks
|
||||
if current_task.condition:
|
||||
@@ -1022,7 +1022,13 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
|
||||
self.tasks[dep.id].status == "failed"
|
||||
for dep in task.context if hasattr(dep, 'id') and dep.id in self.tasks
|
||||
)
|
||||
if deps_failed:
|
||||
# Honor per-task graceful-degradation flags: a task that opted
|
||||
# into skip_on_failure/on_error="continue" still runs even when
|
||||
# an upstream dependency failed.
|
||||
if deps_failed and not (
|
||||
getattr(task, 'skip_on_failure', False)
|
||||
or getattr(task, 'on_error', 'stop') == 'continue'
|
||||
):
|
||||
task.status = "failed"
|
||||
continue
|
||||
if task.status != "completed":
|
||||
@@ -1408,9 +1414,9 @@ Subtask: {st.name}
|
||||
decision_str = None
|
||||
if current_task.result:
|
||||
if current_task.result.pydantic and hasattr(current_task.result.pydantic, "decision"):
|
||||
decision_str = current_task.result.pydantic.decision.lower()
|
||||
decision_str = current_task.result.pydantic.decision.lower().strip()
|
||||
elif current_task.result.raw:
|
||||
decision_str = current_task.result.raw.lower()
|
||||
decision_str = current_task.result.raw.lower().strip()
|
||||
|
||||
# For loop tasks, use "done" to follow condition path
|
||||
if current_task.task_type == "loop" and all(t.status == "completed" for t in subtasks):
|
||||
@@ -1499,9 +1505,9 @@ Subtask: {st.name}
|
||||
if current_task and current_task.result:
|
||||
if current_task.task_type in ["decision", "loop"]:
|
||||
# Get decision from pydantic or raw response
|
||||
decision_str = current_task.result.raw.lower()
|
||||
decision_str = current_task.result.raw.lower().strip()
|
||||
if current_task.result.pydantic and hasattr(current_task.result.pydantic, "decision"):
|
||||
decision_str = current_task.result.pydantic.decision.lower()
|
||||
decision_str = current_task.result.pydantic.decision.lower().strip()
|
||||
|
||||
# Check if task has conditions and next_tasks
|
||||
if current_task.condition:
|
||||
@@ -1627,7 +1633,13 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
|
||||
self.tasks[dep.id].status == "failed"
|
||||
for dep in task.context if hasattr(dep, 'id') and dep.id in self.tasks
|
||||
)
|
||||
if deps_failed:
|
||||
# Honor per-task graceful-degradation flags: a task that opted
|
||||
# into skip_on_failure/on_error="continue" still runs even when
|
||||
# an upstream dependency failed.
|
||||
if deps_failed and not (
|
||||
getattr(task, 'skip_on_failure', False)
|
||||
or getattr(task, 'on_error', 'stop') == 'continue'
|
||||
):
|
||||
task.status = "failed"
|
||||
continue
|
||||
if task.status != "completed":
|
||||
|
||||
@@ -104,6 +104,16 @@ def __getattr__(name: str):
|
||||
_module_cache[name] = SessionMirrorProtocol
|
||||
return SessionMirrorProtocol
|
||||
|
||||
if name == "PortableSessionStoreProtocol":
|
||||
from .protocols import PortableSessionStoreProtocol
|
||||
_module_cache[name] = PortableSessionStoreProtocol
|
||||
return PortableSessionStoreProtocol
|
||||
|
||||
if name == "ImportReport":
|
||||
from .protocols import ImportReport
|
||||
_module_cache[name] = ImportReport
|
||||
return ImportReport
|
||||
|
||||
if name == "SessionHit":
|
||||
from .protocols import SessionHit
|
||||
_module_cache[name] = SessionHit
|
||||
@@ -197,6 +207,8 @@ __all__ = [
|
||||
"SessionStoreProtocol",
|
||||
"SearchableSessionStoreProtocol",
|
||||
"SessionMirrorProtocol",
|
||||
"PortableSessionStoreProtocol",
|
||||
"ImportReport",
|
||||
"SessionHit",
|
||||
"SessionSummary",
|
||||
"HierarchicalSessionStore",
|
||||
|
||||
@@ -87,6 +87,35 @@ class SessionSummary:
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportReport:
|
||||
"""Outcome of a :meth:`PortableSessionStoreProtocol.import_sessions` call.
|
||||
|
||||
A structured, JSON-serialisable summary so an operator (or the gateway CLI)
|
||||
can see exactly what a restore did: how many sessions were imported vs
|
||||
skipped, and why. ``skipped`` entries carry ``{"session_id", "reason"}`` so
|
||||
a rejected record (cap exceeded, malformed, duplicate) is observable rather
|
||||
than silently dropped.
|
||||
"""
|
||||
|
||||
imported: int = 0
|
||||
skipped: List[Dict[str, str]] = field(default_factory=list)
|
||||
version: int = 1
|
||||
|
||||
@property
|
||||
def skipped_count(self) -> int:
|
||||
return len(self.skipped)
|
||||
|
||||
def as_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a JSON-serialisable dict."""
|
||||
return {
|
||||
"imported": self.imported,
|
||||
"skipped": self.skipped,
|
||||
"skipped_count": self.skipped_count,
|
||||
"version": self.version,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SessionStoreProtocol(Protocol):
|
||||
"""Protocol for session persistence backends.
|
||||
@@ -187,6 +216,85 @@ class SessionStoreProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PortableSessionStoreProtocol(Protocol):
|
||||
"""Protocol for portable backup / restore / migration of sessions.
|
||||
|
||||
Extends the storage contract with a store-agnostic way to export a
|
||||
versioned, JSON-serialisable payload and import it back on another host or
|
||||
into another store — so a gateway operator can back up, move, or restore
|
||||
conversation state without hand-copying opaque store files.
|
||||
|
||||
The payload shape is deliberately simple and versioned::
|
||||
|
||||
{"version": 1, "sessions": [ <session dict>, ... ]}
|
||||
|
||||
Each session dict is the same portable shape produced by
|
||||
``SessionData.to_dict`` (round-trips through ``SessionData.from_dict``), so
|
||||
export/import is symmetric across the built-in stores. ``import_sessions``
|
||||
is *hardened*: it caps how much it will ingest and, by default, resets live
|
||||
routing/activity fields so restored state is inert until re-bound (an
|
||||
imported record must not masquerade as an active connection).
|
||||
|
||||
Example::
|
||||
|
||||
data = store.export_all()
|
||||
report = other_store.import_sessions(data)
|
||||
print(report.imported, report.skipped_count)
|
||||
"""
|
||||
|
||||
def export_session(
|
||||
self, session_id: str, *, include_lineage: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""Export a single session to a portable, versioned payload.
|
||||
|
||||
Args:
|
||||
session_id: The session to export.
|
||||
include_lineage: When True, also include any compacted/rotated
|
||||
ancestors of this conversation so it round-trips as one logical
|
||||
session.
|
||||
|
||||
Returns:
|
||||
``{"version": ..., "sessions": [...]}`` (empty ``sessions`` when the
|
||||
id is unknown).
|
||||
"""
|
||||
...
|
||||
|
||||
def export_all(self) -> Dict[str, Any]:
|
||||
"""Export every stored session to a portable, versioned payload.
|
||||
|
||||
Returns:
|
||||
``{"version": ..., "sessions": [...]}`` covering all sessions.
|
||||
"""
|
||||
...
|
||||
|
||||
def import_sessions(
|
||||
self,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
max_sessions: int = 10_000,
|
||||
reset_live_fields: bool = True,
|
||||
overwrite: bool = False,
|
||||
) -> "ImportReport":
|
||||
"""Import sessions from an exported payload (hardened).
|
||||
|
||||
Args:
|
||||
payload: A payload previously produced by ``export_session`` /
|
||||
``export_all``.
|
||||
max_sessions: Cap on how many sessions to ingest; the remainder is
|
||||
skipped and reported (guards against an oversized payload).
|
||||
reset_live_fields: When True (default), clear live routing/activity
|
||||
fields on each imported record so restored state is inert until
|
||||
re-bound (does not masquerade as an active connection).
|
||||
overwrite: When False (default), an existing session id is skipped
|
||||
rather than clobbered.
|
||||
|
||||
Returns:
|
||||
An :class:`ImportReport` summarising imported vs skipped sessions.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SearchableSessionStoreProtocol(Protocol):
|
||||
"""Protocol for cross-session conversation recall.
|
||||
|
||||
@@ -2148,6 +2148,219 @@ class DefaultSessionStore:
|
||||
"""Check if a session exists."""
|
||||
filepath = self._get_session_path(session_id)
|
||||
return os.path.exists(filepath)
|
||||
|
||||
# ── Portable export / import / migration (Issue #4267) ────────────────
|
||||
#
|
||||
# Store-agnostic backup/restore/migration for the gateway's own store.
|
||||
# The payload is a simple, versioned envelope carrying the same portable
|
||||
# session dicts that ``SessionData.to_dict`` already produces, so it
|
||||
# round-trips through ``from_dict`` and is symmetric across the built-in
|
||||
# stores (``SqliteSessionStore`` inherits this behaviour unchanged).
|
||||
|
||||
# Bumped only on a breaking payload-shape change; import tolerates older/
|
||||
# unversioned payloads (best-effort) rather than rejecting them.
|
||||
PORTABLE_VERSION = 1
|
||||
|
||||
# Live routing / activity fields cleared on import (unless opted out) so a
|
||||
# restored record is inert until re-bound and cannot masquerade as an active
|
||||
# connection. Kept intentionally small: transcript + identity survive; only
|
||||
# the gateway's live wiring is reset.
|
||||
_LIVE_FIELDS = ("gateway_session_id", "agent_id")
|
||||
|
||||
def export_session(
|
||||
self, session_id: str, *, include_lineage: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""Export a single session to a portable, versioned payload.
|
||||
|
||||
Reuses the existing ``SessionData.to_dict`` shape (so the payload
|
||||
round-trips through ``import_sessions``). When ``include_lineage`` is
|
||||
set, any compacted/rotated ancestors sharing this session's lineage id
|
||||
are included so the conversation restores as one logical session.
|
||||
"""
|
||||
session = self._read_session_fresh(session_id)
|
||||
if not self.session_exists(session_id):
|
||||
return {"version": self.PORTABLE_VERSION, "sessions": []}
|
||||
|
||||
sessions = [session.to_dict()]
|
||||
if include_lineage:
|
||||
for extra in self._collect_lineage(session, exclude=session_id):
|
||||
sessions.append(extra)
|
||||
return {"version": self.PORTABLE_VERSION, "sessions": sessions}
|
||||
|
||||
def export_all(self) -> Dict[str, Any]:
|
||||
"""Export every stored session to a portable, versioned payload."""
|
||||
sessions: List[Dict[str, Any]] = []
|
||||
try:
|
||||
filenames = os.listdir(self.session_dir)
|
||||
except (IOError, OSError):
|
||||
filenames = []
|
||||
for filename in filenames:
|
||||
if not filename.endswith(".json"):
|
||||
continue
|
||||
filepath = os.path.join(self.session_dir, filename)
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
sessions.append(json.load(f))
|
||||
except (json.JSONDecodeError, IOError, OSError):
|
||||
continue
|
||||
return {"version": self.PORTABLE_VERSION, "sessions": sessions}
|
||||
|
||||
def _collect_lineage(
|
||||
self, session: SessionData, *, exclude: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return other on-disk sessions sharing this session's lineage id.
|
||||
|
||||
Uses the same *chain* identifiers as recall's lineage dedup
|
||||
(``lineage_id`` / ``root_session_id`` / ``thread_id``) so a compacted /
|
||||
rotated continuation exports alongside its logical session. Returns the
|
||||
raw session dicts (already portable). Empty when no lineage is known.
|
||||
"""
|
||||
lineage = self._lineage_key(session.to_dict())
|
||||
if not lineage:
|
||||
return []
|
||||
out: List[Dict[str, Any]] = []
|
||||
try:
|
||||
filenames = os.listdir(self.session_dir)
|
||||
except (IOError, OSError):
|
||||
return []
|
||||
for filename in filenames:
|
||||
if not filename.endswith(".json"):
|
||||
continue
|
||||
filepath = os.path.join(self.session_dir, filename)
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, IOError, OSError):
|
||||
continue
|
||||
if data.get("session_id") == exclude:
|
||||
continue
|
||||
if self._lineage_key(data) == lineage:
|
||||
out.append(data)
|
||||
return out
|
||||
|
||||
def _save_imported_session(self, session: SessionData) -> bool:
|
||||
"""Persist a restored session verbatim (no retention/window applied).
|
||||
|
||||
Mirrors ``_save_session`` (timestamp + atomic, file-locked write) but
|
||||
deliberately skips ``_enforce_window`` so importing a valid larger
|
||||
export is not truncated/compacted by the destination's own retention
|
||||
settings before it lands on disk.
|
||||
"""
|
||||
filepath = self._get_session_path(session.session_id)
|
||||
session.updated_at = datetime.now(timezone.utc).isoformat()
|
||||
with FileLock(filepath, self.lock_timeout):
|
||||
if not self._atomic_write_json(filepath, session.to_dict()):
|
||||
logger.error(f"Failed to save imported session {session.session_id}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def import_sessions(
|
||||
self,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
max_sessions: int = 10_000,
|
||||
reset_live_fields: bool = True,
|
||||
overwrite: bool = False,
|
||||
) -> "ImportReport":
|
||||
"""Import sessions from an exported payload (hardened).
|
||||
|
||||
Validates the payload shape, caps ingest at ``max_sessions``, skips
|
||||
malformed / duplicate records (reported, not silently dropped) and —
|
||||
unless opted out — resets live routing/activity fields so restored
|
||||
state is inert until re-bound.
|
||||
"""
|
||||
from .protocols import ImportReport
|
||||
|
||||
report = ImportReport(version=self.PORTABLE_VERSION)
|
||||
if not isinstance(payload, dict):
|
||||
return report
|
||||
# Reject payloads from a newer, breaking export format: a future shape
|
||||
# could otherwise slip through ``from_dict`` with silently defaulted
|
||||
# fields and corrupt restored state. Older/unversioned payloads are
|
||||
# still accepted (best-effort) since v1 is the compatible baseline.
|
||||
payload_version = payload.get("version")
|
||||
if isinstance(payload_version, int) and payload_version > self.PORTABLE_VERSION:
|
||||
report.skipped.append(
|
||||
{
|
||||
"session_id": "",
|
||||
"reason": (
|
||||
f"unsupported payload version {payload_version} "
|
||||
f"(supported <= {self.PORTABLE_VERSION})"
|
||||
),
|
||||
}
|
||||
)
|
||||
return report
|
||||
raw_sessions = payload.get("sessions")
|
||||
if not isinstance(raw_sessions, list):
|
||||
return report
|
||||
|
||||
seen_ids: set = set()
|
||||
for index, raw in enumerate(raw_sessions):
|
||||
if report.imported >= max_sessions:
|
||||
report.skipped.append(
|
||||
{"session_id": "", "reason": f"max_sessions={max_sessions} cap reached"}
|
||||
)
|
||||
# Everything after the cap is skipped for the same reason; stop.
|
||||
break
|
||||
if not isinstance(raw, dict):
|
||||
report.skipped.append(
|
||||
{"session_id": "", "reason": f"malformed record at index {index}"}
|
||||
)
|
||||
continue
|
||||
session_id = str(raw.get("session_id") or "").strip()
|
||||
if not session_id:
|
||||
report.skipped.append(
|
||||
{"session_id": "", "reason": f"missing session_id at index {index}"}
|
||||
)
|
||||
continue
|
||||
# Cycle / duplicate guard: the same id appearing twice in one payload
|
||||
# is ingested once (first wins) so a self-referential lineage export
|
||||
# cannot double-write.
|
||||
if session_id in seen_ids:
|
||||
report.skipped.append(
|
||||
{"session_id": session_id, "reason": "duplicate in payload"}
|
||||
)
|
||||
continue
|
||||
seen_ids.add(session_id)
|
||||
|
||||
if not overwrite and self.session_exists(session_id):
|
||||
report.skipped.append(
|
||||
{"session_id": session_id, "reason": "already exists (use overwrite)"}
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
data = dict(raw)
|
||||
if reset_live_fields:
|
||||
for field_name in self._LIVE_FIELDS:
|
||||
data.pop(field_name, None)
|
||||
meta = data.get("metadata")
|
||||
if isinstance(meta, dict):
|
||||
# Copy before mutating so the caller's payload (and any
|
||||
# later import with reset_live_fields=False) keeps its
|
||||
# original live fields intact (data = dict(raw) is shallow).
|
||||
meta = dict(meta)
|
||||
for field_name in self._LIVE_FIELDS:
|
||||
meta.pop(field_name, None)
|
||||
data["metadata"] = meta
|
||||
session = SessionData.from_dict(data)
|
||||
session.session_id = session_id
|
||||
# Persist the imported record verbatim: an import is a restore,
|
||||
# so the destination's retention/active_window must not truncate
|
||||
# or compact a valid larger export before it lands on disk.
|
||||
if not self._save_imported_session(session):
|
||||
report.skipped.append(
|
||||
{"session_id": session_id, "reason": "write failed"}
|
||||
)
|
||||
continue
|
||||
with self._lock:
|
||||
self._cache[session_id] = session
|
||||
report.imported += 1
|
||||
except Exception as e: # pragma: no cover - defensive; one bad record
|
||||
report.skipped.append(
|
||||
{"session_id": session_id, "reason": f"import error: {e}"}
|
||||
)
|
||||
return report
|
||||
|
||||
def invalidate_cache(self, session_id: Optional[str] = None) -> None:
|
||||
"""Invalidate cache for a session or all sessions."""
|
||||
|
||||
@@ -10,6 +10,7 @@ import threading
|
||||
import concurrent.futures
|
||||
import queue
|
||||
import asyncio
|
||||
import contextvars
|
||||
from contextlib import contextmanager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -241,13 +242,25 @@ def instrument_agent(agent: 'Agent', telemetry: Optional['MinimalTelemetry'] = N
|
||||
original_start = agent.start if hasattr(agent, 'start') else None
|
||||
original_execute_tool = agent.execute_tool if hasattr(agent, 'execute_tool') else None
|
||||
|
||||
# Re-entrancy guard scoped per-agent (this ContextVar is created fresh in
|
||||
# each instrument_agent call) AND per-execution. A plain instance attribute
|
||||
# is shared across threads/tasks, so N concurrent chat() calls on one agent
|
||||
# would all but the first see reentrant=True and undercount N:1 (#4196). A
|
||||
# ContextVar gives each thread/async task its own value, so nested
|
||||
# run()->chat() still records once while concurrent top-level calls each
|
||||
# record independently.
|
||||
in_execution = contextvars.ContextVar(
|
||||
f'_telemetry_in_execution_{id(agent)}', default=False
|
||||
)
|
||||
|
||||
def _make_instrumented_execution(original_method):
|
||||
@wraps(original_method)
|
||||
def wrapper(*args, **kwargs):
|
||||
# Only the outermost of run/start/chat records the execution.
|
||||
reentrant = getattr(agent, '_telemetry_in_execution', False)
|
||||
reentrant = in_execution.get()
|
||||
token = None
|
||||
if not reentrant:
|
||||
agent._telemetry_in_execution = True
|
||||
token = in_execution.set(True)
|
||||
try:
|
||||
result = original_method(*args, **kwargs)
|
||||
if not reentrant and not performance_mode:
|
||||
@@ -270,8 +283,8 @@ def instrument_agent(agent: 'Agent', telemetry: Optional['MinimalTelemetry'] = N
|
||||
})
|
||||
raise
|
||||
finally:
|
||||
if not reentrant:
|
||||
agent._telemetry_in_execution = False
|
||||
if token is not None:
|
||||
in_execution.reset(token)
|
||||
return wrapper
|
||||
|
||||
if original_chat:
|
||||
|
||||
@@ -31,12 +31,48 @@ from praisonaiagents._logging import get_logger
|
||||
from typing import Optional, List
|
||||
|
||||
from ..approval import require_approval
|
||||
from .path_safety import resolve_within_root
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Availability cache for performance (checked once per process)
|
||||
_availability_cache: Optional[bool] = None
|
||||
|
||||
# Bound the output returned to the model. ast-grep can emit millions of chars
|
||||
# against a large tree; without a cap that floods the context window.
|
||||
_MAX_OUTPUT_SIZE = 10000
|
||||
|
||||
|
||||
def _workspace_spill_dir() -> Optional[str]:
|
||||
"""Directory for overflow artifacts the agent can actually retrieve.
|
||||
|
||||
The pointer emitted by :func:`bounded_with_pointer` tells the model to
|
||||
``read_file``/``grep`` the artifact, but those tools reject paths outside
|
||||
the workspace. When the caller has not pinned an explicit
|
||||
``PRAISONAI_TOOL_OUTPUT_DIR`` (which they own), spill into a workspace-local
|
||||
directory so the advertised retrieval path is reachable. Returns ``None`` to
|
||||
defer to ``_output_overflow``'s own resolution when the env var is set.
|
||||
"""
|
||||
import os
|
||||
if os.environ.get("PRAISONAI_TOOL_OUTPUT_DIR"):
|
||||
return None
|
||||
workspace = resolve_within_root(".")
|
||||
if workspace is None:
|
||||
return None
|
||||
return os.path.join(workspace, ".praisonai_tool_output")
|
||||
|
||||
|
||||
def _bound_output(output: str, kind: str) -> str:
|
||||
"""Cap over-budget output, spilling the full buffer to a retrievable file.
|
||||
|
||||
No-op with zero overhead when ``output`` fits inside ``_MAX_OUTPUT_SIZE``.
|
||||
"""
|
||||
if len(output) <= _MAX_OUTPUT_SIZE:
|
||||
return output
|
||||
from ._output_overflow import spill, bounded_with_pointer
|
||||
path = spill(output, kind, spill_dir=_workspace_spill_dir())
|
||||
return bounded_with_pointer(output, _MAX_OUTPUT_SIZE, path)
|
||||
|
||||
def is_ast_grep_available() -> bool:
|
||||
"""Check if ast-grep (sg) CLI is available.
|
||||
|
||||
@@ -115,13 +151,20 @@ def ast_grep_search(
|
||||
if not pattern:
|
||||
return "Error: Pattern cannot be empty"
|
||||
|
||||
safe_path = resolve_within_root(path or ".")
|
||||
if safe_path is None:
|
||||
return f"Error: path '{path}' is outside the workspace"
|
||||
|
||||
try:
|
||||
cmd = ['sg', '--pattern', pattern, '--lang', lang]
|
||||
|
||||
if json_output:
|
||||
cmd.append('--json')
|
||||
|
||||
cmd.append(path)
|
||||
# `--` stops flag parsing so a path beginning with '-' is treated as a
|
||||
# literal path rather than silently consumed as a flag.
|
||||
cmd.append('--')
|
||||
cmd.append(safe_path)
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
@@ -137,7 +180,7 @@ def ast_grep_search(
|
||||
if not output:
|
||||
return "No matches found"
|
||||
|
||||
return output
|
||||
return _bound_output(output, "ast_grep_search")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Search timed out after 60 seconds"
|
||||
@@ -202,6 +245,10 @@ def ast_grep_rewrite(
|
||||
if not replacement:
|
||||
return "Error: Replacement cannot be empty"
|
||||
|
||||
safe_path = resolve_within_root(path or ".")
|
||||
if safe_path is None:
|
||||
return f"Error: path '{path}' is outside the workspace"
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
'sg', '--pattern', pattern,
|
||||
@@ -212,7 +259,10 @@ def ast_grep_rewrite(
|
||||
if not dry_run:
|
||||
cmd.append('--update-all')
|
||||
|
||||
cmd.append(path)
|
||||
# `--` stops flag parsing so a path beginning with '-' is treated as a
|
||||
# literal path rather than silently consumed as a flag.
|
||||
cmd.append('--')
|
||||
cmd.append(safe_path)
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
@@ -221,10 +271,14 @@ def ast_grep_rewrite(
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if result.returncode != 0 and result.stderr:
|
||||
return f"Error: {result.stderr}"
|
||||
if result.returncode != 0:
|
||||
return f"Error: {result.stderr or result.stdout}".strip()
|
||||
|
||||
output = result.stdout.strip()
|
||||
# `sg --update-all` writes its receipt ("Applied N changes") to stderr
|
||||
# and leaves stdout empty. Gating stderr on a non-zero exit discarded the
|
||||
# only evidence the rewrite happened, so a successful destructive rewrite
|
||||
# reported "No changes made" and the agent would retry it.
|
||||
output = (result.stdout.strip() or result.stderr.strip())
|
||||
if not output:
|
||||
if dry_run:
|
||||
return "No matches found for rewrite"
|
||||
@@ -232,7 +286,7 @@ def ast_grep_rewrite(
|
||||
return "No changes made"
|
||||
|
||||
prefix = "[DRY RUN] " if dry_run else ""
|
||||
return f"{prefix}{output}"
|
||||
return _bound_output(f"{prefix}{output}", "ast_grep_rewrite")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Rewrite timed out after 120 seconds"
|
||||
@@ -274,13 +328,23 @@ def ast_grep_scan(
|
||||
if not is_ast_grep_available():
|
||||
return _get_not_installed_message()
|
||||
|
||||
safe_path = resolve_within_root(path or ".")
|
||||
if safe_path is None:
|
||||
return f"Error: path '{path}' is outside the workspace"
|
||||
|
||||
try:
|
||||
cmd = ['sg', 'scan']
|
||||
|
||||
if rule_file:
|
||||
cmd.extend(['--rule', rule_file])
|
||||
safe_rule = resolve_within_root(rule_file)
|
||||
if safe_rule is None:
|
||||
return f"Error: rule_file '{rule_file}' is outside the workspace"
|
||||
cmd.extend(['--rule', safe_rule])
|
||||
|
||||
cmd.append(path)
|
||||
# `--` stops flag parsing so a path beginning with '-' is treated as a
|
||||
# literal path rather than silently consumed as a flag.
|
||||
cmd.append('--')
|
||||
cmd.append(safe_path)
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
@@ -297,7 +361,7 @@ def ast_grep_scan(
|
||||
if not output:
|
||||
return "No issues found"
|
||||
|
||||
return output
|
||||
return _bound_output(output, "ast_grep_scan")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Scan timed out after 120 seconds"
|
||||
|
||||
@@ -207,6 +207,13 @@ class BaseTool(ABC):
|
||||
# Optional class attributes
|
||||
version: str = "1.0.0"
|
||||
parameters: Optional[Dict[str, Any]] = None # JSON Schema, auto-generated if None
|
||||
# Restart-safety contract for durable resume. ``None`` (the default) means
|
||||
# "undeclared": durable resume falls back to a read-only name heuristic and,
|
||||
# when uncertain, fails closed rather than replaying a side effect. ``True``
|
||||
# marks a read-only/idempotent tool that is safe to re-run after a crash;
|
||||
# ``False`` marks an effectful tool that must never be silently re-executed
|
||||
# on resume.
|
||||
restart_safe: Optional[bool] = None
|
||||
|
||||
def __init__(self, dynamic_schema_overrides: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None):
|
||||
"""Initialize the tool and validate configuration.
|
||||
|
||||
@@ -28,6 +28,28 @@ from praisonaiagents._logging import get_logger
|
||||
import asyncio
|
||||
from importlib import util
|
||||
|
||||
def _is_safe_crawl_url(url: str) -> bool:
|
||||
"""Return True when *url* passes SSRF checks for crawling.
|
||||
|
||||
Mirrors ``web_crawl_tools._is_safe_crawl_url`` so every crawl entry point is
|
||||
guarded consistently. ``raw://`` inline-HTML inputs bypass the network and
|
||||
are exempt. (#4189)
|
||||
"""
|
||||
if isinstance(url, str) and url.startswith("raw://"):
|
||||
return True
|
||||
from praisonaiagents.tools.url_safety import is_safe_http_url
|
||||
|
||||
return is_safe_http_url(url)
|
||||
|
||||
|
||||
def _blocked_url_result(url: str) -> Dict[str, Any]:
|
||||
"""Uniform error payload for a URL rejected by SSRF validation."""
|
||||
return {
|
||||
"error": f"URL blocked by SSRF safety check: {url}",
|
||||
"success": False,
|
||||
}
|
||||
|
||||
|
||||
def _check_crawl4ai_available() -> tuple[bool, Optional[str]]:
|
||||
"""Check if Crawl4AI and its Playwright browser binaries are available.
|
||||
|
||||
@@ -169,6 +191,9 @@ class Crawl4AITools:
|
||||
Returns:
|
||||
Dict with 'markdown', 'html', 'cleaned_html', 'links', 'media', etc.
|
||||
"""
|
||||
if not _is_safe_crawl_url(url):
|
||||
self.logger.warning("Crawl4AI crawl blocked unsafe URL: %s", url)
|
||||
return _blocked_url_result(url)
|
||||
try:
|
||||
crawler = await self._get_crawler()
|
||||
|
||||
@@ -237,6 +262,26 @@ class Crawl4AITools:
|
||||
Returns:
|
||||
List of crawl results
|
||||
"""
|
||||
# Partition while preserving positional correspondence: callers that pair
|
||||
# inputs to outputs by index must see blocked entries in their original
|
||||
# slot, not moved to the front (#4189).
|
||||
safe_urls = []
|
||||
for u in urls:
|
||||
if not _is_safe_crawl_url(u):
|
||||
self.logger.warning("Crawl4AI crawl_many blocked unsafe URL: %s", u)
|
||||
else:
|
||||
safe_urls.append(u)
|
||||
|
||||
def _ordered(fetched: Dict[str, Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
fetched.get(u, _blocked_url_result(u))
|
||||
if _is_safe_crawl_url(u)
|
||||
else _blocked_url_result(u)
|
||||
for u in urls
|
||||
]
|
||||
|
||||
if not safe_urls:
|
||||
return [_blocked_url_result(u) for u in urls]
|
||||
try:
|
||||
crawler = await self._get_crawler()
|
||||
|
||||
@@ -248,42 +293,32 @@ class Crawl4AITools:
|
||||
cache_mode=CacheMode.BYPASS if bypass_cache else CacheMode.ENABLED,
|
||||
stream=stream
|
||||
)
|
||||
|
||||
results = []
|
||||
|
||||
|
||||
fetched: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def _record(result) -> None:
|
||||
if result.success:
|
||||
fetched[result.url] = {
|
||||
"success": True,
|
||||
"url": result.url,
|
||||
"markdown": result.markdown.raw_markdown if hasattr(result.markdown, 'raw_markdown') else str(result.markdown),
|
||||
"html": result.html
|
||||
}
|
||||
else:
|
||||
fetched[result.url] = {
|
||||
"success": False,
|
||||
"url": result.url,
|
||||
"error": result.error_message
|
||||
}
|
||||
|
||||
if stream:
|
||||
async for result in await crawler.arun_many(urls, config=config):
|
||||
if result.success:
|
||||
results.append({
|
||||
"success": True,
|
||||
"url": result.url,
|
||||
"markdown": result.markdown.raw_markdown if hasattr(result.markdown, 'raw_markdown') else str(result.markdown),
|
||||
"html": result.html
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
"success": False,
|
||||
"url": result.url,
|
||||
"error": result.error_message
|
||||
})
|
||||
async for result in await crawler.arun_many(safe_urls, config=config):
|
||||
_record(result)
|
||||
else:
|
||||
raw_results = await crawler.arun_many(urls, config=config)
|
||||
for result in raw_results:
|
||||
if result.success:
|
||||
results.append({
|
||||
"success": True,
|
||||
"url": result.url,
|
||||
"markdown": result.markdown.raw_markdown if hasattr(result.markdown, 'raw_markdown') else str(result.markdown),
|
||||
"html": result.html
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
"success": False,
|
||||
"url": result.url,
|
||||
"error": result.error_message
|
||||
})
|
||||
|
||||
return results
|
||||
for result in await crawler.arun_many(safe_urls, config=config):
|
||||
_record(result)
|
||||
|
||||
return _ordered(fetched)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Crawl4AI crawl_many error: {e}")
|
||||
@@ -320,6 +355,9 @@ class Crawl4AITools:
|
||||
]
|
||||
}
|
||||
"""
|
||||
if not _is_safe_crawl_url(url):
|
||||
self.logger.warning("Crawl4AI extract_css blocked unsafe URL: %s", url)
|
||||
return _blocked_url_result(url)
|
||||
try:
|
||||
crawler = await self._get_crawler()
|
||||
|
||||
@@ -383,6 +421,9 @@ class Crawl4AITools:
|
||||
Returns:
|
||||
Dict with 'data' containing extracted content
|
||||
"""
|
||||
if not _is_safe_crawl_url(url):
|
||||
self.logger.warning("Crawl4AI extract_llm blocked unsafe URL: %s", url)
|
||||
return _blocked_url_result(url)
|
||||
try:
|
||||
crawler = await self._get_crawler()
|
||||
|
||||
|
||||
@@ -113,9 +113,12 @@ class FunctionTool(BaseTool):
|
||||
retry_policy: Optional[Any] = None,
|
||||
approval: Optional[Union[bool, str]] = None,
|
||||
requires_approval: Union[bool, str] = _UNSET,
|
||||
to_model_output: Optional[Callable[[Any], Any]] = None
|
||||
to_model_output: Optional[Callable[[Any], Any]] = None,
|
||||
restart_safe: Optional[bool] = None
|
||||
):
|
||||
self._func = func
|
||||
# Restart-safety contract for durable resume (see BaseTool.restart_safe).
|
||||
self.restart_safe = restart_safe
|
||||
# Optional compact model-facing view builder: result -> compact view.
|
||||
self._to_model_output = to_model_output
|
||||
self.name = name or func.__name__
|
||||
@@ -240,7 +243,8 @@ def tool(
|
||||
retry_policy: Optional[Any] = None,
|
||||
approval: Optional[Union[bool, str]] = None,
|
||||
requires_approval: Union[bool, str] = _UNSET,
|
||||
to_model_output: Optional[Callable[[Any], Any]] = None
|
||||
to_model_output: Optional[Callable[[Any], Any]] = None,
|
||||
restart_safe: Optional[bool] = None
|
||||
) -> Union[FunctionTool, Callable[[Callable], FunctionTool]]:
|
||||
"""Decorator to convert a function into a tool.
|
||||
|
||||
@@ -293,6 +297,13 @@ def tool(
|
||||
the executor feeds this compact view to the LLM (context economy)
|
||||
while the full result stays available to display, hooks, and
|
||||
downstream consumers. Defaults to ``None`` (model sees full output).
|
||||
restart_safe: Declare the tool's replay behaviour for durable resume.
|
||||
``True`` marks a read-only/idempotent tool that is safe to re-run
|
||||
after a crash; ``False`` marks an effectful tool that must never be
|
||||
silently re-executed on resume (an in-flight call surfaces a
|
||||
recorded, operator-visible outcome to reconcile instead). Defaults
|
||||
to ``None`` (undeclared): durable resume falls back to a read-only
|
||||
name heuristic and fails closed when uncertain.
|
||||
|
||||
Returns:
|
||||
FunctionTool instance that wraps the function
|
||||
@@ -310,7 +321,8 @@ def tool(
|
||||
dynamic_schema_overrides=dynamic_schema_overrides,
|
||||
retry_policy=retry_policy,
|
||||
approval=resolved_approval,
|
||||
to_model_output=to_model_output
|
||||
to_model_output=to_model_output,
|
||||
restart_safe=restart_safe
|
||||
)
|
||||
|
||||
# Register approval requirement with the global registry so local,
|
||||
|
||||
@@ -52,7 +52,6 @@ class DelegationTools:
|
||||
role=role,
|
||||
goal=f"Complete delegated {agent_type} tasks accurately.",
|
||||
llm=llm,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
spawn = create_subagent_tool(agent_factory=_agent_factory)["function"]
|
||||
|
||||
@@ -275,18 +275,51 @@ class MentionsParser:
|
||||
try:
|
||||
from praisonaiagents.tools.spider_tools import SpiderTools
|
||||
|
||||
if not SpiderTools()._validate_url(url):
|
||||
spider = SpiderTools()
|
||||
if not spider._validate_url(url):
|
||||
return f"# URL: {url}\n[Blocked: URL is not allowed]"
|
||||
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={'User-Agent': 'Mozilla/5.0 (compatible; PraisonAI/1.0)'}
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
content = response.read().decode('utf-8', errors='ignore')
|
||||
import urllib.error
|
||||
from urllib.parse import urljoin
|
||||
|
||||
# Follow redirects manually, re-validating EVERY hop. urllib's default
|
||||
# opener follows redirects transparently, so validating only the first
|
||||
# hop lets a redirect land on a metadata IP or internal host (#4189).
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
opener = urllib.request.build_opener(_NoRedirect)
|
||||
current_url = url
|
||||
redirect_statuses = {301, 302, 303, 307, 308}
|
||||
max_redirects = 5
|
||||
content = None
|
||||
for _ in range(max_redirects + 1):
|
||||
if not spider._validate_url(current_url):
|
||||
return f"# URL: {url}\n[Blocked: redirect target is not allowed: {current_url}]"
|
||||
req = urllib.request.Request(
|
||||
current_url,
|
||||
headers={'User-Agent': 'Mozilla/5.0 (compatible; PraisonAI/1.0)'}
|
||||
)
|
||||
try:
|
||||
response = opener.open(req, timeout=10)
|
||||
except urllib.error.HTTPError as http_err:
|
||||
if http_err.code in redirect_statuses:
|
||||
location = http_err.headers.get("Location")
|
||||
if not location:
|
||||
return f"# URL: {url}\n[Error fetching URL: redirect without Location]"
|
||||
current_url = urljoin(current_url, location)
|
||||
continue
|
||||
raise
|
||||
with response:
|
||||
content = response.read().decode('utf-8', errors='ignore')
|
||||
break
|
||||
else:
|
||||
return f"# URL: {url}\n[Error fetching URL: too many redirects]"
|
||||
|
||||
if content is None:
|
||||
return f"# URL: {url}\n[Error fetching URL: no response]"
|
||||
|
||||
# Limit content size (0 means no limit)
|
||||
if self.max_file_chars > 0 and len(content) > self.max_file_chars:
|
||||
|
||||
@@ -105,6 +105,47 @@ def _validate_code_ast(code: str):
|
||||
return None
|
||||
|
||||
|
||||
def _make_resource_preexec(limits: ResourceLimits):
|
||||
"""Build a POSIX preexec_fn that applies ResourceLimits via setrlimit.
|
||||
|
||||
Caps address space (memory), process count, open files, file size and
|
||||
CPU time in the child process so untrusted code cannot exhaust host
|
||||
resources. CPU time is capped in seconds (derived from timeout_seconds)
|
||||
so sustained CPU-bound code is stopped by the kernel rather than only by
|
||||
the wall-clock timeout. Each RLIMIT_* constant is resolved defensively:
|
||||
constants absent on a given POSIX platform, and individual caps the
|
||||
platform rejects, are skipped without aborting the launch (the wall-clock
|
||||
timeout still applies as a backstop).
|
||||
"""
|
||||
import resource
|
||||
|
||||
def _limit():
|
||||
# CPU-time cap in whole seconds; derived from timeout_seconds so the
|
||||
# kernel terminates CPU-bound code near the wall-clock budget instead
|
||||
# of letting it saturate a core until the parent times out.
|
||||
cpu_seconds = max(1, int(limits.timeout_seconds)) if limits.timeout_seconds else 0
|
||||
caps = (
|
||||
("RLIMIT_AS", limits.memory_mb, 1024 * 1024),
|
||||
("RLIMIT_NPROC", limits.max_processes, 1),
|
||||
("RLIMIT_NOFILE", limits.max_open_files, 1),
|
||||
("RLIMIT_FSIZE", limits.disk_write_mb, 1024 * 1024),
|
||||
("RLIMIT_CPU", cpu_seconds, 1),
|
||||
)
|
||||
for res_name, value, scale in caps:
|
||||
if not value:
|
||||
continue
|
||||
res = getattr(resource, res_name, None)
|
||||
if res is None:
|
||||
continue
|
||||
cap = value * scale
|
||||
try:
|
||||
resource.setrlimit(res, (cap, cap))
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
return _limit
|
||||
|
||||
|
||||
def _execute_code_sandboxed(
|
||||
code: str,
|
||||
timeout: int = 30,
|
||||
@@ -287,7 +328,20 @@ if __name__ == "__main__":
|
||||
|
||||
# Configure subprocess with resource limits
|
||||
env = {} # Clean environment (no access to parent process env vars)
|
||||
|
||||
|
||||
# Enforce the computed ResourceLimits in the child process. On POSIX we
|
||||
# apply them via setrlimit in a preexec_fn so untrusted code cannot
|
||||
# exhaust host memory/CPU/processes/files (see ResourceLimits.minimal()).
|
||||
preexec_fn = None
|
||||
if os.name == "posix":
|
||||
preexec_fn = _make_resource_preexec(limits)
|
||||
else:
|
||||
logging.warning(
|
||||
"Code sandbox: resource limits (memory/CPU/process/file caps) "
|
||||
"are only enforced on POSIX; on this platform only the "
|
||||
"wall-clock timeout applies."
|
||||
)
|
||||
|
||||
# Run subprocess with timeout and resource limits
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, temp_file],
|
||||
@@ -296,7 +350,8 @@ if __name__ == "__main__":
|
||||
env=env,
|
||||
shell=False, # Security: prevent shell injection
|
||||
text=True,
|
||||
cwd=tempfile.gettempdir() # Run in temp dir, not current dir
|
||||
cwd=tempfile.gettempdir(), # Run in temp dir, not current dir
|
||||
preexec_fn=preexec_fn,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -28,6 +28,7 @@ from praisonaiagents.ui.agui.types import (
|
||||
StateSnapshotEvent,
|
||||
)
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
|
||||
from praisonaiagents.streaming.events import StreamEventType as _SE
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -39,15 +40,35 @@ class EventBuffer:
|
||||
current_text_message_id: str = ""
|
||||
next_text_message_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
pending_tool_calls_parent_id: str = ""
|
||||
current_tool_call_id: str = ""
|
||||
|
||||
def start_tool_call(self, tool_call_id: str) -> None:
|
||||
"""Start a new tool call."""
|
||||
self.active_tool_call_ids.add(tool_call_id)
|
||||
self.current_tool_call_id = tool_call_id
|
||||
|
||||
def end_tool_call(self, tool_call_id: str) -> None:
|
||||
"""End a tool call."""
|
||||
self.active_tool_call_ids.discard(tool_call_id)
|
||||
self.ended_tool_call_ids.add(tool_call_id)
|
||||
if self.current_tool_call_id == tool_call_id:
|
||||
self.current_tool_call_id = ""
|
||||
|
||||
def resolve_tool_call_id(self, provided_id: Optional[str]) -> str:
|
||||
"""Resolve the tool_call_id for a delta that may omit it.
|
||||
|
||||
Providers commonly send the id only on the first chunk of a tool call
|
||||
and omit it on subsequent argument deltas. Falling back to the last
|
||||
started tool call keeps every chunk correlated to one invocation instead
|
||||
of minting an orphan UUID per chunk.
|
||||
"""
|
||||
if provided_id:
|
||||
return provided_id
|
||||
if self.current_tool_call_id:
|
||||
return self.current_tool_call_id
|
||||
new_id = str(uuid.uuid4())
|
||||
self.current_tool_call_id = new_id
|
||||
return new_id
|
||||
|
||||
def start_text_message(self) -> str:
|
||||
"""Start a new text message and return its ID."""
|
||||
@@ -260,6 +281,38 @@ async def async_stream_response(
|
||||
yield create_run_error_event(str(e))
|
||||
|
||||
|
||||
# --- AG-UI disposition of every StreamEventType -------------------------------
|
||||
# Membership in these tables is a decision, not an omission. The adapter raises on
|
||||
# any type absent from all three, so adding a StreamEventType without choosing a
|
||||
# disposition fails tests/agui/test_streaming_completeness.py rather than silently
|
||||
# dropping the event at runtime.
|
||||
|
||||
# Process-local timing and lifecycle markers with no AG-UI counterpart.
|
||||
# RUN_FINISHED is emitted by the enclosing run loop, so STREAM_END would double it.
|
||||
NOT_WIRE_VISIBLE = frozenset({
|
||||
_SE.REQUEST_START,
|
||||
_SE.HEADERS_RECEIVED,
|
||||
_SE.FIRST_TOKEN,
|
||||
_SE.LAST_TOKEN,
|
||||
_SE.STREAM_END,
|
||||
})
|
||||
|
||||
# The run continues, but a client that is never told cannot explain a stall or a
|
||||
# silently changed model. Carried as CustomEvent, not RunErrorEvent, because
|
||||
# RunErrorEvent is terminal in AG-UI and these are recovered conditions.
|
||||
_RECOVERABLE_EVENTS = {
|
||||
_SE.RETRY: "praisonai.retry",
|
||||
_SE.MODEL_FALLBACK: "praisonai.model_fallback",
|
||||
_SE.STREAM_UNAVAILABLE: "praisonai.stream_unavailable",
|
||||
}
|
||||
|
||||
# User-visible progress that AG-UI has no first-class frame for.
|
||||
_PROGRESS_EVENTS = {
|
||||
_SE.TOOL_PROGRESS: "praisonai.tool_progress",
|
||||
_SE.TODO_UPDATED: "praisonai.todo_updated",
|
||||
}
|
||||
|
||||
|
||||
def stream_event_to_agui_events(
|
||||
event: Any,
|
||||
message_id: str,
|
||||
@@ -313,7 +366,60 @@ def stream_event_to_agui_events(
|
||||
)
|
||||
return events
|
||||
|
||||
return []
|
||||
if event.type == StreamEventType.DELTA_TOOL_CALL:
|
||||
tool_call = event.tool_call or {}
|
||||
tool_call_id = buffer.resolve_tool_call_id(tool_call.get("id"))
|
||||
delta = tool_call.get("arguments", event.content or "")
|
||||
return [
|
||||
ToolCallArgsEvent(
|
||||
tool_call_id=tool_call_id,
|
||||
delta=delta if isinstance(delta, str) else json.dumps(delta),
|
||||
)
|
||||
]
|
||||
|
||||
if event.type == StreamEventType.TOOL_CALL_END:
|
||||
tool_call = event.tool_call or {}
|
||||
tool_call_id = buffer.resolve_tool_call_id(tool_call.get("id"))
|
||||
buffer.end_tool_call(tool_call_id)
|
||||
return [ToolCallEndEvent(tool_call_id=tool_call_id)]
|
||||
|
||||
if event.type == StreamEventType.ERROR:
|
||||
return [create_run_error_event(event.error or event.content or "unknown error")]
|
||||
|
||||
# Recoverable conditions. These are not RunErrorEvent: the run continues, but
|
||||
# a client that is never told cannot explain a stall or a changed model.
|
||||
if event.type in _RECOVERABLE_EVENTS:
|
||||
return [
|
||||
CustomEvent(
|
||||
name=_RECOVERABLE_EVENTS[event.type],
|
||||
value={
|
||||
"message": event.error or event.content or "",
|
||||
"metadata": event.metadata or {},
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
if event.type in _PROGRESS_EVENTS:
|
||||
return [
|
||||
CustomEvent(
|
||||
name=_PROGRESS_EVENTS[event.type],
|
||||
value={
|
||||
"content": event.content,
|
||||
"tool_call": event.tool_call,
|
||||
"metadata": event.metadata or {},
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
if event.type in NOT_WIRE_VISIBLE:
|
||||
return []
|
||||
|
||||
# Fail loudly rather than dropping: an unmapped type reaching here means a new
|
||||
# StreamEventType was added without deciding its disposition.
|
||||
raise ValueError(
|
||||
f"StreamEventType.{getattr(event.type, 'name', event.type)} has no AG-UI "
|
||||
"mapping and is not declared in NOT_WIRE_VISIBLE"
|
||||
)
|
||||
|
||||
|
||||
def _infer_a2ui_surface_id(result: Dict[str, Any]) -> str:
|
||||
|
||||
@@ -707,10 +707,12 @@ class AgentFlow:
|
||||
|
||||
def __post_init__(self):
|
||||
"""Resolve consolidated params to internal values."""
|
||||
from ..utils.model_alias import resolve_model_alias
|
||||
from ..utils.model_alias import resolve_model_name
|
||||
|
||||
# One rule for the alias pair, shared with Agent and AgentTeam.
|
||||
self.llm = resolve_model_alias(self.llm, self.model, type(self).__name__)
|
||||
# One rule for the alias pair, shared with Agent and AgentTeam. Must
|
||||
# UNWRAP an LLMConfig to its model string: this value seeds the agents
|
||||
# AgentFlow builds, and an agent holding an LLMConfig fails inside chat().
|
||||
self.llm = resolve_model_name(self.llm, self.model, type(self).__name__)
|
||||
self.model = self.llm
|
||||
|
||||
from ..agent.placement import resolve_placement
|
||||
@@ -1362,6 +1364,13 @@ class AgentFlow:
|
||||
results.extend(parallel_result["steps"])
|
||||
previous_output = parallel_result["output"]
|
||||
all_variables.update(parallel_result.get("variables", {}))
|
||||
# A nested step with on_error="stop" halts the whole workflow,
|
||||
# not just the parallel block: honor the propagated stop signal.
|
||||
if parallel_result.get("stop"):
|
||||
self.status = "failed"
|
||||
if verbose:
|
||||
print("🛑 Workflow stopped by nested parallel step")
|
||||
break
|
||||
i += 1
|
||||
continue
|
||||
|
||||
@@ -2419,7 +2428,9 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
|
||||
return {
|
||||
"step": f"parallel_{index}",
|
||||
"output": parallel_result.get("output", ""),
|
||||
"stop": False,
|
||||
# Propagate a nested stop request so an on_error="stop" step
|
||||
# inside the parallel block halts the enclosing workflow.
|
||||
"stop": parallel_result.get("stop", False),
|
||||
"variables": parallel_result.get("variables", all_variables)
|
||||
}
|
||||
|
||||
@@ -2915,6 +2926,7 @@ CONCISE SUMMARY:"""
|
||||
futures.append((idx, future))
|
||||
|
||||
errors = []
|
||||
parallel_stopped = False
|
||||
for idx, future in futures:
|
||||
try:
|
||||
step_result = future.result()
|
||||
@@ -2930,6 +2942,11 @@ CONCISE SUMMARY:"""
|
||||
# Parallel on_failure modes still fire instead of silently
|
||||
# treating the exception text as a real branch output.
|
||||
branch_error = step_result.get("error")
|
||||
# A branch step with on_error="stop" (Task default) requests a
|
||||
# workflow-wide halt. Capture it so the enclosing workflow stops
|
||||
# after this parallel block, matching Loop/Route/Repeat/If.
|
||||
if step_result.get("stop"):
|
||||
parallel_stopped = True
|
||||
|
||||
if branch_error is None:
|
||||
results.append({"step": step_result["step"], "output": step_result["output"]})
|
||||
@@ -2955,6 +2972,14 @@ CONCISE SUMMARY:"""
|
||||
# Record the failure but continue with other branches. Do not
|
||||
# fold the exception text into outputs as if it were data.
|
||||
results.append({"step": f"parallel_{idx}", "output": None, "error": str(branch_error)})
|
||||
# Keep `outputs` index-aligned with parallel_step.steps so a
|
||||
# downstream reader of parallel_outputs[idx] cannot silently
|
||||
# receive a later branch's result once an earlier one fails.
|
||||
outputs.append(None)
|
||||
# A failed branch whose step requested on_error="stop" halts the
|
||||
# enclosing workflow even under partial_ok.
|
||||
if step_result is not None and step_result.get("stop"):
|
||||
parallel_stopped = True
|
||||
|
||||
# Check if we should fail after all branches completed
|
||||
if errors and parallel_step.on_failure == "fail_all":
|
||||
@@ -2971,7 +2996,7 @@ CONCISE SUMMARY:"""
|
||||
if verbose:
|
||||
print(f"✅ Parallel complete: {len(outputs)} results")
|
||||
|
||||
return {"steps": results, "output": combined_output, "variables": all_variables}
|
||||
return {"steps": results, "output": combined_output, "variables": all_variables, "stop": parallel_stopped}
|
||||
|
||||
def _execute_loop(
|
||||
self,
|
||||
@@ -3234,43 +3259,69 @@ CONCISE SUMMARY:"""
|
||||
List of items, or [text] if no list found
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
|
||||
if not text or not text.strip():
|
||||
return []
|
||||
|
||||
text = text.strip()
|
||||
|
||||
# 1. Try direct JSON parse (pure JSON array)
|
||||
# 1. Try direct JSON parse (pure JSON array).
|
||||
# RecursionError guards against deeply-nested bracket input, which the
|
||||
# C/Python JSON scanner surfaces instead of JSONDecodeError (CWE-674).
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
if isinstance(parsed, list):
|
||||
return parsed
|
||||
except json.JSONDecodeError:
|
||||
except (json.JSONDecodeError, RecursionError):
|
||||
pass
|
||||
|
||||
# 2. Try to extract JSON array from text using regex
|
||||
# Match JSON arrays like ["item1", "item2"] or ['item1', 'item2']
|
||||
json_array_pattern = r'\[(?:[^\[\]]*(?:"[^"]*"|\'[^\']*\')?[^\[\]]*)*\]'
|
||||
matches = re.findall(json_array_pattern, text)
|
||||
# 2. Search for an embedded list without a backtracking regex.
|
||||
# The previous nested-repeat pattern could take exponential time on
|
||||
# adversarial bracket/quote input (CWE-1333/CWE-730). JSONDecoder
|
||||
# validates each candidate using the JSON grammar. raw_decode is
|
||||
# anchored with the ``idx`` argument so we never allocate an
|
||||
# overlapping ``text[start:]`` copy per bracket. To bound the total
|
||||
# work on bracket-dense adversarial output (e.g. deeply nested "[[[["
|
||||
# where each raw_decode still rescans toward the end, giving O(n^2)),
|
||||
# we cap both the input size and the number of decode attempts.
|
||||
decoder = json.JSONDecoder()
|
||||
max_scan_chars = 1_000_000
|
||||
if len(text) > max_scan_chars:
|
||||
logger.warning("Loop list text exceeds the safe parsing limit")
|
||||
return [text]
|
||||
max_decode_attempts = 10_000
|
||||
|
||||
for match in matches:
|
||||
start = text.find('[')
|
||||
attempts = 0
|
||||
while start != -1 and attempts < max_decode_attempts:
|
||||
attempts += 1
|
||||
try:
|
||||
# Try parsing as JSON
|
||||
parsed = json.loads(match)
|
||||
if isinstance(parsed, list) and len(parsed) > 0:
|
||||
return parsed
|
||||
except json.JSONDecodeError:
|
||||
# Try replacing single quotes with double quotes
|
||||
try:
|
||||
fixed = match.replace("'", '"')
|
||||
parsed = json.loads(fixed)
|
||||
if isinstance(parsed, list) and len(parsed) > 0:
|
||||
return parsed
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
parsed, _ = decoder.raw_decode(text, start)
|
||||
except (json.JSONDecodeError, RecursionError):
|
||||
start = text.find('[', start + 1)
|
||||
continue
|
||||
if isinstance(parsed, list) and len(parsed) > 0:
|
||||
return parsed
|
||||
start = text.find('[', start + 1)
|
||||
|
||||
# 3. Fallback: wrap as single item
|
||||
# 3. Fallback for Python-style lists with single quotes, e.g.
|
||||
# "['topic1', 'topic2']", which strict JSON rejects. ast.literal_eval
|
||||
# only evaluates literals (no code execution) so it is safe here and
|
||||
# preserves the single-quoted parsing the previous regex supported.
|
||||
# A single attempt on the outermost bracket span keeps this linear;
|
||||
# LLM output places the list contiguously rather than fragmented.
|
||||
import ast
|
||||
first = text.find('[')
|
||||
last = text.rfind(']')
|
||||
if first != -1 and last > first:
|
||||
try:
|
||||
parsed = ast.literal_eval(text[first:last + 1])
|
||||
if isinstance(parsed, (list, tuple)) and len(parsed) > 0:
|
||||
return list(parsed)
|
||||
except (ValueError, SyntaxError, RecursionError):
|
||||
pass
|
||||
|
||||
# 4. Fallback: wrap as single item
|
||||
return [text]
|
||||
|
||||
def _execute_repeat(
|
||||
@@ -4685,6 +4736,94 @@ class WorkflowManager:
|
||||
else:
|
||||
current_step_idx += 1
|
||||
|
||||
def _prepare_step_context(
|
||||
self,
|
||||
step: Task,
|
||||
results: List[Dict[str, Any]],
|
||||
all_variables: Dict[str, Any],
|
||||
original_input: str,
|
||||
) -> Tuple[Optional["WorkflowContext"], Optional[Dict[str, Any]]]:
|
||||
"""Shared prep for the sync/async single-step runners.
|
||||
|
||||
Builds the handler ``WorkflowContext`` (only when a consumer exists) and
|
||||
evaluates ``should_run``. Returns ``(context, early_result)`` where
|
||||
``early_result`` is the skip return-dict when ``should_run`` gates the
|
||||
step out, otherwise ``None`` and ``context`` is ready for handler use.
|
||||
"""
|
||||
# Get previous step output
|
||||
previous_output = results[-1].get("output") if results else None
|
||||
|
||||
# Create context for step handlers (only when a consumer exists)
|
||||
context = None
|
||||
if step.should_run or step.handler:
|
||||
context = WorkflowContext(
|
||||
input=original_input,
|
||||
previous_result=str(previous_output) if previous_output else None,
|
||||
current_step=step.name,
|
||||
variables=all_variables.copy()
|
||||
)
|
||||
|
||||
# Check should_run condition if provided
|
||||
if step.should_run:
|
||||
try:
|
||||
if not step.should_run(context):
|
||||
return context, {
|
||||
"success": True,
|
||||
"output": None,
|
||||
"skipped": True,
|
||||
"stop": False
|
||||
}
|
||||
except Exception as e:
|
||||
self._log(f"should_run check for step '{step.name}' failed: {e}")
|
||||
|
||||
return context, None
|
||||
|
||||
def _build_step_action(
|
||||
self,
|
||||
step: Task,
|
||||
step_idx: int,
|
||||
results: List[Dict[str, Any]],
|
||||
all_variables: Dict[str, Any],
|
||||
) -> str:
|
||||
"""Shared prep: build step context, substitute variables, prepend context."""
|
||||
# Build context from previous steps
|
||||
context = self._build_step_context(step, step_idx, results, all_variables)
|
||||
|
||||
# Substitute variables in action
|
||||
action = self._substitute_variables(step.action, all_variables)
|
||||
|
||||
# Prepend context to action if available
|
||||
if context:
|
||||
action = f"{context}# Current Task:\n{action}"
|
||||
|
||||
return action
|
||||
|
||||
def _finalize_step_result(
|
||||
self,
|
||||
step: Task,
|
||||
output: Optional[str],
|
||||
step_success: bool,
|
||||
error: Optional[str],
|
||||
all_variables: Dict[str, Any],
|
||||
results: List[Dict[str, Any]],
|
||||
on_result: Optional[Callable[[Task, str], None]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Shared finalize for the sync/async single-step runners."""
|
||||
# Update variables with step output
|
||||
if output and step_success:
|
||||
self._update_variables_with_output(step, output, all_variables, results)
|
||||
|
||||
# Callback after step
|
||||
if on_result and output:
|
||||
on_result(step, output)
|
||||
|
||||
return {
|
||||
"success": step_success,
|
||||
"output": output,
|
||||
"stop": False, # Normal execution doesn't request stop
|
||||
"error": error
|
||||
}
|
||||
|
||||
def _execute_single_step(
|
||||
self,
|
||||
step: Task,
|
||||
@@ -4702,32 +4841,13 @@ class WorkflowManager:
|
||||
original_input: str = ""
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a single workflow step."""
|
||||
# Get previous step output
|
||||
previous_output = results[-1].get("output") if results else None
|
||||
|
||||
# Create context for step handlers (only when a consumer exists)
|
||||
context = None
|
||||
if step.should_run or step.handler:
|
||||
context = WorkflowContext(
|
||||
input=original_input,
|
||||
previous_result=str(previous_output) if previous_output else None,
|
||||
current_step=step.name,
|
||||
variables=all_variables.copy()
|
||||
)
|
||||
|
||||
# Check should_run condition if provided
|
||||
if step.should_run:
|
||||
try:
|
||||
if not step.should_run(context):
|
||||
return {
|
||||
"success": True,
|
||||
"output": None,
|
||||
"skipped": True,
|
||||
"stop": False
|
||||
}
|
||||
except Exception as e:
|
||||
self._log(f"should_run check for step '{step.name}' failed: {e}")
|
||||
|
||||
# Shared prep: handler context + should_run gating
|
||||
context, early_result = self._prepare_step_context(
|
||||
step, results, all_variables, original_input
|
||||
)
|
||||
if early_result is not None:
|
||||
return early_result
|
||||
|
||||
# If step has a custom handler function
|
||||
if step.handler:
|
||||
try:
|
||||
@@ -4758,15 +4878,8 @@ class WorkflowManager:
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
# Build context from previous steps
|
||||
context = self._build_step_context(step, step_idx, results, all_variables)
|
||||
|
||||
# Substitute variables in action
|
||||
action = self._substitute_variables(step.action, all_variables)
|
||||
|
||||
# Prepend context to action if available
|
||||
if context:
|
||||
action = f"{context}# Current Task:\n{action}"
|
||||
# Shared prep: build step context + substitute variables
|
||||
action = self._build_step_action(step, step_idx, results, all_variables)
|
||||
|
||||
# Get or create executor for this step
|
||||
step_executor = executor
|
||||
@@ -4819,20 +4932,10 @@ class WorkflowManager:
|
||||
if retries <= step.max_retries:
|
||||
self._log(f"Step '{step.name}' failed, retrying ({retries}/{step.max_retries})")
|
||||
|
||||
# Update variables with step output
|
||||
if output and step_success:
|
||||
self._update_variables_with_output(step, output, all_variables, results)
|
||||
|
||||
# Callback after step
|
||||
if on_result and output:
|
||||
on_result(step, output)
|
||||
|
||||
return {
|
||||
"success": step_success,
|
||||
"output": output,
|
||||
"stop": False, # Normal execution doesn't request stop
|
||||
"error": error
|
||||
}
|
||||
# Shared finalize: variable propagation + on_result callback + return dict
|
||||
return self._finalize_step_result(
|
||||
step, output, step_success, error, all_variables, results, on_result
|
||||
)
|
||||
|
||||
async def aexecute(
|
||||
self,
|
||||
@@ -4942,31 +5045,12 @@ class WorkflowManager:
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# Get previous step output
|
||||
previous_output = results[-1].get("output") if results else None
|
||||
|
||||
# Create context for step handlers (only when a consumer exists)
|
||||
context = None
|
||||
if step.should_run or step.handler:
|
||||
context = WorkflowContext(
|
||||
input=original_input,
|
||||
previous_result=str(previous_output) if previous_output else None,
|
||||
current_step=step.name,
|
||||
variables=all_variables.copy()
|
||||
)
|
||||
|
||||
# Check should_run condition if provided
|
||||
if step.should_run:
|
||||
try:
|
||||
if not step.should_run(context):
|
||||
return {
|
||||
"success": True,
|
||||
"output": None,
|
||||
"skipped": True,
|
||||
"stop": False
|
||||
}
|
||||
except Exception as e:
|
||||
self._log(f"should_run check for step '{step.name}' failed: {e}")
|
||||
# Shared prep: handler context + should_run gating
|
||||
context, early_result = self._prepare_step_context(
|
||||
step, results, all_variables, original_input
|
||||
)
|
||||
if early_result is not None:
|
||||
return early_result
|
||||
|
||||
# If step has a custom handler function
|
||||
if step.handler:
|
||||
@@ -4999,15 +5083,8 @@ class WorkflowManager:
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
# Build context from previous steps
|
||||
context = self._build_step_context(step, step_idx, results, all_variables)
|
||||
|
||||
# Substitute variables in action
|
||||
action = self._substitute_variables(step.action, all_variables)
|
||||
|
||||
# Prepend context to action if available
|
||||
if context:
|
||||
action = f"{context}# Current Task:\n{action}"
|
||||
# Shared prep: build step context + substitute variables
|
||||
action = self._build_step_action(step, step_idx, results, all_variables)
|
||||
|
||||
# Get or create executor for this step
|
||||
step_executor = executor
|
||||
@@ -5069,20 +5146,10 @@ class WorkflowManager:
|
||||
if retries <= step.max_retries:
|
||||
self._log(f"Step '{step.name}' failed, retrying ({retries}/{step.max_retries})")
|
||||
|
||||
# Update variables with step output
|
||||
if output and step_success:
|
||||
self._update_variables_with_output(step, output, all_variables, results)
|
||||
|
||||
# Callback after step
|
||||
if on_result and output:
|
||||
on_result(step, output)
|
||||
|
||||
return {
|
||||
"success": step_success,
|
||||
"output": output,
|
||||
"stop": False,
|
||||
"error": error
|
||||
}
|
||||
# Shared finalize: variable propagation + on_result callback + return dict
|
||||
return self._finalize_step_result(
|
||||
step, output, step_success, error, all_variables, results, on_result
|
||||
)
|
||||
|
||||
def _create_step_agent(
|
||||
self,
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.0"
|
||||
version = "1.7.1"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Every StreamEventType must have a declared AG-UI disposition.
|
||||
|
||||
The adapter's fall-through `return []` means an unmapped event type is dropped
|
||||
with no error and no log. That is invisible for cosmetic events and data loss
|
||||
for ERROR, RETRY, MODEL_FALLBACK and STREAM_UNAVAILABLE: the client sees a run
|
||||
that ends cleanly having never been told anything went wrong.
|
||||
|
||||
This suite pins the disposition of all sixteen members. Adding a new
|
||||
StreamEventType without deciding whether it crosses the wire fails the first
|
||||
test, by construction.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
|
||||
from praisonaiagents.ui.agui.streaming import (
|
||||
EventBuffer,
|
||||
NOT_WIRE_VISIBLE,
|
||||
stream_event_to_agui_events,
|
||||
)
|
||||
|
||||
|
||||
def _event(event_type: StreamEventType) -> StreamEvent:
|
||||
"""A populated event, so a drop is never merely an empty payload."""
|
||||
return StreamEvent(
|
||||
type=event_type,
|
||||
content="text",
|
||||
tool_call={"id": "call-1", "name": "f", "arguments": {}, "result": "r"},
|
||||
error="boom",
|
||||
)
|
||||
|
||||
|
||||
def test_every_event_type_has_a_declared_disposition():
|
||||
undeclared = [
|
||||
t.name
|
||||
for t in StreamEventType
|
||||
if not stream_event_to_agui_events(_event(t), "m1", EventBuffer())
|
||||
and t not in NOT_WIRE_VISIBLE
|
||||
]
|
||||
assert not undeclared, (
|
||||
"these StreamEventTypes are silently dropped by the AG-UI adapter and are "
|
||||
f"not declared in NOT_WIRE_VISIBLE: {undeclared}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event_type",
|
||||
[
|
||||
StreamEventType.ERROR,
|
||||
StreamEventType.STREAM_UNAVAILABLE,
|
||||
StreamEventType.RETRY,
|
||||
StreamEventType.MODEL_FALLBACK,
|
||||
],
|
||||
)
|
||||
def test_failure_events_reach_the_client(event_type):
|
||||
"""A failure the engine recovered from is still news the client must get."""
|
||||
out = stream_event_to_agui_events(_event(event_type), "m1", EventBuffer())
|
||||
assert out, f"{event_type.name} produced no AG-UI event: the client is never told"
|
||||
|
||||
|
||||
def test_not_wire_visible_is_a_closed_set_of_real_members():
|
||||
"""Guards against silencing a type by adding a typo to the allowlist."""
|
||||
for member in NOT_WIRE_VISIBLE:
|
||||
assert isinstance(member, StreamEventType)
|
||||
|
||||
|
||||
def test_mapped_events_still_map():
|
||||
"""Positive control: the three handled types must keep working."""
|
||||
for event_type in (
|
||||
StreamEventType.DELTA_TEXT,
|
||||
StreamEventType.TOOL_CALL_START,
|
||||
StreamEventType.TOOL_CALL_RESULT,
|
||||
):
|
||||
assert stream_event_to_agui_events(_event(event_type), "m1", EventBuffer())
|
||||
|
||||
|
||||
def test_tool_call_delta_id_survives_id_less_chunks():
|
||||
"""Providers send the id once; later argument chunks must keep that id.
|
||||
|
||||
A fresh UUID per chunk would orphan the arguments from the invocation the
|
||||
AG-UI client is assembling.
|
||||
"""
|
||||
buffer = EventBuffer()
|
||||
|
||||
start = StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_START,
|
||||
tool_call={"id": "call-42", "name": "f", "arguments": {}},
|
||||
)
|
||||
(start_out := stream_event_to_agui_events(start, "m1", buffer))
|
||||
assert any(getattr(e, "tool_call_id", None) == "call-42" for e in start_out)
|
||||
|
||||
chunk = StreamEvent(
|
||||
type=StreamEventType.DELTA_TOOL_CALL,
|
||||
tool_call={"arguments": '{"x":'},
|
||||
)
|
||||
chunk_out = stream_event_to_agui_events(chunk, "m1", buffer)
|
||||
assert [e.tool_call_id for e in chunk_out] == ["call-42"]
|
||||
|
||||
end = StreamEvent(type=StreamEventType.TOOL_CALL_END, tool_call={})
|
||||
end_out = stream_event_to_agui_events(end, "m1", buffer)
|
||||
assert [e.tool_call_id for e in end_out] == ["call-42"]
|
||||
@@ -241,6 +241,36 @@ These topics are trending this week.
|
||||
assert len(result) == 1
|
||||
assert result == ['just a plain string']
|
||||
|
||||
def test_parse_single_quoted_python_list(self):
|
||||
"""Python-style single-quoted lists must not collapse to one item."""
|
||||
from praisonaiagents.workflows import Workflow
|
||||
|
||||
workflow = Workflow(steps=[])
|
||||
result = workflow._parse_list_from_string("['topic1', 'topic2', 'topic3']")
|
||||
|
||||
assert result == ['topic1', 'topic2', 'topic3']
|
||||
|
||||
def test_parse_single_quoted_list_embedded_in_text(self):
|
||||
"""Single-quoted list embedded in surrounding text is extracted."""
|
||||
from praisonaiagents.workflows import Workflow
|
||||
|
||||
workflow = Workflow(steps=[])
|
||||
result = workflow._parse_list_from_string("Topics: ['a', 'b'] found")
|
||||
|
||||
assert result == ['a', 'b']
|
||||
|
||||
def test_parse_adversarial_bracket_input_is_bounded(self):
|
||||
"""Deeply nested/bracket-dense input must not hang or crash."""
|
||||
import time
|
||||
from praisonaiagents.workflows import Workflow
|
||||
|
||||
workflow = Workflow(steps=[])
|
||||
text = '[' * 100_000 + 'x'
|
||||
start = time.time()
|
||||
result = workflow._parse_list_from_string(text)
|
||||
assert time.time() - start < 15
|
||||
assert result == [text]
|
||||
|
||||
|
||||
class TestBeginnerFriendlyYAML:
|
||||
"""Tests for beginner-friendly YAML syntax."""
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""A per-channel clone must keep the guardrail the original was given.
|
||||
|
||||
`clone_for_channel()` rebuilds the agent from `getattr(self, '_guardrails_config',
|
||||
None)`, but `_guardrails_config` used to be only a local inside `__init__` and was
|
||||
never assigned to the instance. The getattr therefore always yielded None and the
|
||||
clone was constructed with no guardrail at all.
|
||||
|
||||
This matters because `clone_for_channel()` is the code path that serves traffic:
|
||||
one clone per channel in the bot gateway, one per HTTP invocation in the API.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
|
||||
def _reject(output):
|
||||
"""Guardrail that always refuses."""
|
||||
return (False, "contains PII")
|
||||
|
||||
|
||||
def _agent():
|
||||
return Agent(name="Support", instructions="i", llm="gpt-4o-mini", guardrails=_reject)
|
||||
|
||||
|
||||
def test_original_agent_withholds():
|
||||
"""Control: without this passing, the rest proves nothing."""
|
||||
assert _agent()._guardrail_fn is not None
|
||||
|
||||
|
||||
def test_clone_keeps_the_guardrail():
|
||||
clone = _agent().clone_for_channel()
|
||||
assert clone._guardrail_fn is not None, (
|
||||
"clone_for_channel() produced an agent with no output guardrail; "
|
||||
"the gateway serves one such clone per channel"
|
||||
)
|
||||
|
||||
|
||||
def test_resolved_config_is_persisted_for_the_clone_to_read():
|
||||
"""clone_for_channel() reads this attribute; it must exist."""
|
||||
agent = _agent()
|
||||
assert hasattr(agent, "_guardrails_config")
|
||||
assert agent._guardrails_config is not None
|
||||
|
||||
|
||||
def test_no_guardrail_stays_no_guardrail():
|
||||
"""A plain agent must not acquire one, and its clone must not either."""
|
||||
plain = Agent(name="Plain", instructions="i", llm="gpt-4o-mini")
|
||||
assert plain._guardrail_fn is None
|
||||
assert plain.clone_for_channel()._guardrail_fn is None
|
||||
|
||||
|
||||
def test_approval_config_is_persisted_for_the_clone_to_read():
|
||||
"""clone_for_channel() reads _approval_config; it must exist so the served
|
||||
clone keeps the operator's approval policy."""
|
||||
agent = Agent(
|
||||
name="Approving", instructions="i", llm="gpt-4o-mini", approval="read_only"
|
||||
)
|
||||
assert hasattr(agent, "_approval_config")
|
||||
assert agent._approval_config == "read_only"
|
||||
|
||||
|
||||
def test_clone_keeps_the_approval_policy():
|
||||
"""A served clone must reconstruct with the same approval policy, otherwise
|
||||
the gateway serves untrusted traffic with the operator's policy dropped."""
|
||||
agent = Agent(
|
||||
name="Approving", instructions="i", llm="gpt-4o-mini", approval="read_only"
|
||||
)
|
||||
clone = agent.clone_for_channel()
|
||||
assert clone._approval_config == "read_only", (
|
||||
"clone_for_channel() produced an agent without the approval policy; "
|
||||
"the gateway serves one such clone per channel"
|
||||
)
|
||||
assert clone._perm_deny == agent._perm_deny, (
|
||||
"the read_only preset's enforced deny set was not preserved on the clone"
|
||||
)
|
||||
|
||||
|
||||
def test_no_approval_stays_no_approval():
|
||||
"""A plain agent must not acquire a policy, and neither must its clone."""
|
||||
plain = Agent(name="Plain", instructions="i", llm="gpt-4o-mini")
|
||||
assert plain._approval_config is None
|
||||
assert plain.clone_for_channel()._approval_config is None
|
||||
@@ -320,6 +320,119 @@ async def test_async_wrapper_records_and_replays_result():
|
||||
journal.close()
|
||||
|
||||
|
||||
def test_inflight_effectful_tool_is_not_replayed_on_resume():
|
||||
from praisonaiagents import Agent, tool
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
@tool(restart_safe=False)
|
||||
def send_invoice(amount: int) -> str:
|
||||
calls["n"] += 1
|
||||
return "charged"
|
||||
|
||||
agent = Agent(name="agent", instructions="test", tools=[send_invoice])
|
||||
|
||||
journal = RunJournal(":memory:")
|
||||
journal.open_run("run-1", task="task")
|
||||
original = DurableRunContext(journal, "run-1", replaying=False)
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
original.wrap_sync(MagicMock(side_effect=KeyboardInterrupt))(
|
||||
"send_invoice", {"amount": 10}, tool_call_id="call-1"
|
||||
)
|
||||
|
||||
resumed = DurableRunContext(journal, "run-1", replaying=True)
|
||||
result = resumed.wrap_sync(agent.execute_tool)(
|
||||
"send_invoice", {"amount": 10}, tool_call_id="call-1"
|
||||
)
|
||||
|
||||
assert calls["n"] == 0
|
||||
assert result["not_safely_resumable"] is True
|
||||
assert result["error_type"] == "NotSafelyResumable"
|
||||
journal.close()
|
||||
|
||||
|
||||
def test_inflight_restart_safe_tool_is_replayed_on_resume():
|
||||
from praisonaiagents import Agent, tool
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
@tool(restart_safe=True)
|
||||
def fetch_report(id: int, idempotency_key: str = "") -> str:
|
||||
calls["n"] += 1
|
||||
return "report"
|
||||
|
||||
agent = Agent(name="agent", instructions="test", tools=[fetch_report])
|
||||
|
||||
journal = RunJournal(":memory:")
|
||||
journal.open_run("run-1", task="task")
|
||||
original = DurableRunContext(journal, "run-1", replaying=False)
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
original.wrap_sync(MagicMock(side_effect=KeyboardInterrupt))(
|
||||
"fetch_report", {"id": 7}, tool_call_id="call-1"
|
||||
)
|
||||
|
||||
resumed = DurableRunContext(journal, "run-1", replaying=True)
|
||||
result = resumed.wrap_sync(agent.execute_tool)(
|
||||
"fetch_report", {"id": 7}, tool_call_id="call-1"
|
||||
)
|
||||
|
||||
assert calls["n"] == 1
|
||||
assert result == "report"
|
||||
journal.close()
|
||||
|
||||
|
||||
def test_inflight_undeclared_effectful_name_fails_closed_on_resume():
|
||||
journal = RunJournal(":memory:")
|
||||
journal.open_run("run-1", task="task")
|
||||
original = DurableRunContext(journal, "run-1", replaying=False)
|
||||
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
original.wrap_sync(MagicMock(side_effect=KeyboardInterrupt))(
|
||||
"delete_account", {"user": "u1"}, tool_call_id="call-1"
|
||||
)
|
||||
|
||||
resumed = DurableRunContext(journal, "run-1", replaying=True)
|
||||
effect = MagicMock(return_value="deleted")
|
||||
result = resumed.wrap_sync(effect)(
|
||||
"delete_account", {"user": "u1"}, tool_call_id="call-1"
|
||||
)
|
||||
|
||||
effect.assert_not_called()
|
||||
assert result["not_safely_resumable"] is True
|
||||
journal.close()
|
||||
|
||||
|
||||
def test_inflight_malformed_restart_safe_declaration_fails_closed():
|
||||
from praisonaiagents import Agent, tool
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
@tool
|
||||
def push_payment(amount: int) -> str:
|
||||
calls["n"] += 1
|
||||
return "charged"
|
||||
|
||||
push_payment.restart_safe = "False"
|
||||
agent = Agent(name="agent", instructions="test", tools=[push_payment])
|
||||
|
||||
journal = RunJournal(":memory:")
|
||||
journal.open_run("run-1", task="task")
|
||||
original = DurableRunContext(journal, "run-1", replaying=False)
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
original.wrap_sync(MagicMock(side_effect=KeyboardInterrupt))(
|
||||
"push_payment", {"amount": 5}, tool_call_id="call-1"
|
||||
)
|
||||
|
||||
resumed = DurableRunContext(journal, "run-1", replaying=True)
|
||||
result = resumed.wrap_sync(agent.execute_tool)(
|
||||
"push_payment", {"amount": 5}, tool_call_id="call-1"
|
||||
)
|
||||
|
||||
assert calls["n"] == 0
|
||||
assert result["not_safely_resumable"] is True
|
||||
journal.close()
|
||||
|
||||
|
||||
def test_resume_requires_known_running_run_and_matching_prompt(tmp_path):
|
||||
path = str(tmp_path / "journal.db")
|
||||
agent = SimpleNamespace(
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""A model response with no content must not become the SDK object's repr.
|
||||
|
||||
`_extract_llm_response_content` ended in `return str(response)`, reached whenever
|
||||
`message.content` is falsy and there are no tool_calls -- a content filter, a
|
||||
refusal, or `finish_reason="length"` with zero output tokens. `achat()` uses it,
|
||||
so the user's answer became several hundred characters of `ChatCompletion(...)`
|
||||
repr, which was then written into chat_history and replayed to the model.
|
||||
|
||||
The sync path already does the right thing (`content.strip() if content else ""`).
|
||||
"""
|
||||
import asyncio
|
||||
import types
|
||||
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
|
||||
def _filtered_response():
|
||||
"""A real SDK object as returned when the content filter trips."""
|
||||
return ChatCompletion(
|
||||
id="chatcmpl-test", created=0, model="gpt-4o-mini", object="chat.completion",
|
||||
choices=[Choice(finish_reason="content_filter", index=0, logprobs=None,
|
||||
message=ChatCompletionMessage(content=None, role="assistant"))],
|
||||
)
|
||||
|
||||
|
||||
def test_extractor_returns_empty_not_repr():
|
||||
agent = Agent(name="A", instructions="x", llm="gpt-4o-mini")
|
||||
out = agent._extract_llm_response_content(_filtered_response())
|
||||
assert not out.startswith("ChatCompletion("), (
|
||||
f"got {len(out)} chars of object repr as the assistant's answer: {out[:80]!r}")
|
||||
assert out == ""
|
||||
|
||||
|
||||
def test_extractor_matches_the_sync_path():
|
||||
"""Sync uses `content.strip() if content else ''`; async must agree."""
|
||||
agent = Agent(name="A", instructions="x", llm="gpt-4o-mini")
|
||||
resp = _filtered_response()
|
||||
content = resp.choices[0].message.content
|
||||
sync_style = content.strip() if content else ""
|
||||
assert agent._extract_llm_response_content(resp) == sync_style
|
||||
|
||||
|
||||
def test_achat_does_not_write_object_repr_into_chat_history():
|
||||
agent = Agent(name="A", instructions="x", llm="gpt-4o-mini")
|
||||
|
||||
async def _create(**kwargs):
|
||||
return _filtered_response()
|
||||
|
||||
def _create_sync(**kwargs):
|
||||
return _filtered_response()
|
||||
|
||||
client = OpenAIClient(api_key="sk-not-a-real-key")
|
||||
client._sync_client = types.SimpleNamespace(
|
||||
chat=types.SimpleNamespace(completions=types.SimpleNamespace(create=_create_sync)),
|
||||
beta=types.SimpleNamespace(chat=types.SimpleNamespace(
|
||||
completions=types.SimpleNamespace(parse=_create_sync))),
|
||||
responses=None)
|
||||
client._async_client = types.SimpleNamespace(
|
||||
chat=types.SimpleNamespace(completions=types.SimpleNamespace(create=_create)),
|
||||
beta=types.SimpleNamespace(chat=types.SimpleNamespace(
|
||||
completions=types.SimpleNamespace(parse=_create))),
|
||||
responses=None)
|
||||
agent._Agent__openai_client = client
|
||||
agent._unified_dispatcher = None
|
||||
|
||||
result = asyncio.run(agent.achat("say something blocked"))
|
||||
|
||||
assert result == "", f"expected empty async result, got {result!r}"
|
||||
for m in agent.chat_history:
|
||||
assert not str(m.get("content", "")).startswith("ChatCompletion("), (
|
||||
f"object repr persisted into chat_history: {str(m['content'])[:80]!r}")
|
||||
assistant_msgs = [m for m in agent.chat_history if m.get("role") == "assistant"]
|
||||
assert assistant_msgs, "no assistant message written to chat_history"
|
||||
assert assistant_msgs[-1].get("content", None) == "", (
|
||||
f"assistant chat_history entry must be empty, got "
|
||||
f"{assistant_msgs[-1].get('content')!r}")
|
||||
|
||||
|
||||
def test_extractor_warns_on_refusal_even_when_finish_reason_stop(caplog):
|
||||
"""A refusal with finish_reason='stop' must still surface a warning."""
|
||||
agent = Agent(name="A", instructions="x", llm="gpt-4o-mini")
|
||||
resp = ChatCompletion(
|
||||
id="chatcmpl-test", created=0, model="gpt-4o-mini", object="chat.completion",
|
||||
choices=[Choice(finish_reason="stop", index=0, logprobs=None,
|
||||
message=ChatCompletionMessage(
|
||||
content=None, role="assistant",
|
||||
refusal="I can't help with that."))],
|
||||
)
|
||||
with caplog.at_level("WARNING"):
|
||||
out = agent._extract_llm_response_content(resp)
|
||||
assert out == ""
|
||||
assert any("refused=True" in r.getMessage() for r in caplog.records), (
|
||||
"expected a warning when the model refused")
|
||||
assert not any("I can't help with that." in r.getMessage() for r in caplog.records), (
|
||||
"refusal text must not be logged")
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Sync self-reflection must stop retrying an unparseable reflection.
|
||||
|
||||
The async handler bounds the retry with `if reflection_count >= self.max_reflect`.
|
||||
The sync handler increments the counter and `continue`s unconditionally, so a
|
||||
reflection that never parses -- the realistic trigger is an OpenAI
|
||||
structured-output refusal, where `message.parsed` is None and reading
|
||||
`.reflection` off it raises AttributeError -- loops forever at two LLM calls a
|
||||
turn. `chat()` never returns.
|
||||
"""
|
||||
import asyncio
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import ReflectionConfig
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
CAP = 40 # generous: a bounded loop uses <10
|
||||
|
||||
|
||||
class _CallCap(BaseException):
|
||||
"""BaseException so the agent's own `except Exception` cannot swallow it."""
|
||||
|
||||
|
||||
class _Msg:
|
||||
role = "assistant"
|
||||
refusal = None
|
||||
parsed = None
|
||||
content = None
|
||||
tool_calls = None
|
||||
|
||||
def __init__(self, content=None):
|
||||
self.content = content
|
||||
|
||||
def model_dump(self):
|
||||
return {"role": "assistant", "content": self.content}
|
||||
|
||||
|
||||
class _Refusal(_Msg):
|
||||
"""Structured-output refusal: `parsed` is None, so `.reflection` raises."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(content=None)
|
||||
self.refusal = "I cannot comply."
|
||||
|
||||
|
||||
class _Usage:
|
||||
prompt_tokens = 10
|
||||
completion_tokens = 5
|
||||
total_tokens = 15
|
||||
completion_tokens_details = None
|
||||
prompt_tokens_details = None
|
||||
|
||||
def model_dump(self):
|
||||
return {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||
|
||||
|
||||
class _Resp:
|
||||
id = "chatcmpl-test"
|
||||
created = 0
|
||||
object = "chat.completion"
|
||||
model = "gpt-4o-mini"
|
||||
|
||||
def __init__(self, msg):
|
||||
self.choices = [types.SimpleNamespace(message=msg, finish_reason="stop",
|
||||
index=0, delta=None)]
|
||||
self.usage = _Usage()
|
||||
|
||||
|
||||
class _Transport:
|
||||
"""Answers `create` with text and `parse` with a refusal, counting both."""
|
||||
|
||||
def __init__(self):
|
||||
self.n = 0
|
||||
|
||||
def _tick(self):
|
||||
self.n += 1
|
||||
if self.n > CAP:
|
||||
raise _CallCap(f"exceeded {CAP} LLM calls -- loop is unbounded")
|
||||
|
||||
def create(self, **kwargs):
|
||||
self._tick()
|
||||
return _Resp(_Msg(content=f"A{self.n}"))
|
||||
|
||||
async def acreate(self, **kwargs):
|
||||
return self.create(**kwargs)
|
||||
|
||||
def parse(self, **kwargs):
|
||||
self._tick()
|
||||
return _Resp(_Refusal())
|
||||
|
||||
async def aparse(self, **kwargs):
|
||||
return self.parse(**kwargs)
|
||||
|
||||
|
||||
def _agent():
|
||||
t = _Transport()
|
||||
agent = Agent(name="A", instructions="x", llm="gpt-4o-mini",
|
||||
reflection=ReflectionConfig(min_iterations=1, max_iterations=3))
|
||||
|
||||
sync_c = types.SimpleNamespace(name="create", create=t.create)
|
||||
sync_p = types.SimpleNamespace(parse=t.parse)
|
||||
async_c = types.SimpleNamespace(create=t.acreate)
|
||||
async_p = types.SimpleNamespace(parse=t.aparse)
|
||||
|
||||
client = OpenAIClient(api_key="sk-not-a-real-key")
|
||||
client._sync_client = types.SimpleNamespace(
|
||||
chat=types.SimpleNamespace(completions=sync_c),
|
||||
beta=types.SimpleNamespace(chat=types.SimpleNamespace(completions=sync_p)),
|
||||
responses=None)
|
||||
client._async_client = types.SimpleNamespace(
|
||||
chat=types.SimpleNamespace(completions=async_c),
|
||||
beta=types.SimpleNamespace(chat=types.SimpleNamespace(completions=async_p)),
|
||||
responses=None)
|
||||
agent._Agent__openai_client = client
|
||||
agent._unified_dispatcher = None
|
||||
return agent, t
|
||||
|
||||
|
||||
def test_sync_reflection_terminates_when_the_reflection_never_parses():
|
||||
agent, _t = _agent()
|
||||
try:
|
||||
result = agent.chat("q")
|
||||
except _CallCap as exc:
|
||||
pytest.fail(str(exc))
|
||||
assert result, "bounded loop must still return the un-reflected answer"
|
||||
|
||||
|
||||
def test_sync_reflection_call_count_is_bounded_like_async():
|
||||
agent, t = _agent()
|
||||
try:
|
||||
agent.chat("q")
|
||||
except _CallCap as exc:
|
||||
pytest.fail(str(exc))
|
||||
sync_calls = t.n
|
||||
|
||||
agent, t = _agent()
|
||||
asyncio.run(agent.achat("q"))
|
||||
async_calls = t.n
|
||||
|
||||
# The sync path costs more per reflection round (a separate defect), but it
|
||||
# must at least stay within a small constant of the async bound.
|
||||
assert sync_calls <= async_calls * 3, (
|
||||
f"sync used {sync_calls} LLM calls vs async {async_calls}")
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Streaming must still produce an answer when a tool is invoked.
|
||||
|
||||
The streaming path collects tool calls, executes them, appends the results to
|
||||
chat history -- and then ends. It never asks the model for the final answer, so
|
||||
`start(stream=True)` yields nothing while the identical non-streaming call
|
||||
answers normally.
|
||||
|
||||
There is no error. The caller sees an empty generator and cannot tell "the model
|
||||
had nothing to say" from "the answer was never requested".
|
||||
|
||||
Exercised against the real path rather than a mocked client: the defect lives in
|
||||
the interaction between tool execution and the follow-up completion, which is
|
||||
precisely what a hand-built mock of that client would define away.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.environ.get("OPENAI_API_KEY"),
|
||||
reason="exercises the real streaming path; needs a provider credential",
|
||||
)
|
||||
|
||||
SENTINEL = "PRAISONAI-TOOL-SENTINEL"
|
||||
|
||||
|
||||
def sentinel_tool() -> str:
|
||||
"""Return a fixed marker so a passing assertion cannot be a coincidence."""
|
||||
return SENTINEL
|
||||
|
||||
|
||||
def _agent():
|
||||
return Agent(name="T", role="Assistant", goal="Answer using your tools.",
|
||||
llm="gpt-4o-mini", tools=[sentinel_tool])
|
||||
|
||||
|
||||
def test_a_plain_turn_streams():
|
||||
"""Positive control: without tools the path must be unaffected."""
|
||||
chunks = [c for c in Agent(name="P", role="Assistant", goal="Answer.",
|
||||
llm="gpt-4o-mini").start("Say hi", stream=True) if c]
|
||||
assert chunks, "a plain streamed turn produced nothing; the harness is broken"
|
||||
|
||||
|
||||
def test_a_tool_using_turn_still_streams_an_answer():
|
||||
"""The regression: a tool round-trip must not swallow the reply.
|
||||
|
||||
Asserting the sentinel flows through the streamed answer proves the tool
|
||||
branch actually ran -- a plain answer that never called the tool cannot
|
||||
reproduce the marker, so a green test here cannot be a coincidence.
|
||||
"""
|
||||
streamed = "".join(c for c in _agent().start(
|
||||
"Call sentinel_tool and tell me exactly what it returned.", stream=True) if c)
|
||||
assert streamed, (
|
||||
"streaming yielded nothing after a tool call; the follow-up completion "
|
||||
"that produces the final answer was never requested"
|
||||
)
|
||||
assert SENTINEL in streamed, (
|
||||
"the sentinel never reached the streamed answer; the tool result was "
|
||||
"not carried into the follow-up completion's request context"
|
||||
)
|
||||
|
||||
|
||||
def test_streaming_and_non_streaming_agree_when_a_tool_runs():
|
||||
"""The two paths must not disagree about whether an answer exists."""
|
||||
blocking = _agent().start("Call sentinel_tool and report its output.")
|
||||
streamed = "".join(c for c in _agent().start(
|
||||
"Call sentinel_tool and report its output.", stream=True) if c)
|
||||
assert blocking, "non-streaming produced nothing; the harness is broken"
|
||||
assert streamed, "streaming produced nothing while non-streaming answered"
|
||||
@@ -0,0 +1,172 @@
|
||||
"""A tool failure must reach the caller as a tool failure.
|
||||
|
||||
`_chat_completion` wraps its body in a blanket `except Exception`, which also
|
||||
catches the `ToolExecutionError` raised by the sync tool executor and re-raises
|
||||
it as `LLMError`. Three consequences, all covered here:
|
||||
|
||||
* `chat()` swallows the error and returns None instead of raising.
|
||||
* The tool's message is fed to the *LLM* error classifier, so a tool whose
|
||||
text resembles a rate limit makes the framework sleep and re-drive the model.
|
||||
* On that re-drive the model is called again with no tool result at all, and
|
||||
its answer is returned to the user as a success.
|
||||
"""
|
||||
import json
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.errors import ToolExecutionError
|
||||
from praisonaiagents.llm.openai_client import OpenAIClient
|
||||
|
||||
|
||||
class _Fn:
|
||||
def __init__(self, name, args):
|
||||
self.name = name
|
||||
self.arguments = json.dumps(args)
|
||||
|
||||
|
||||
class _ToolCall:
|
||||
def __init__(self, id, name, args):
|
||||
self.id = id
|
||||
self.type = "function"
|
||||
self.function = _Fn(name, args)
|
||||
|
||||
def model_dump(self):
|
||||
return {"id": self.id, "type": "function",
|
||||
"function": {"name": self.function.name,
|
||||
"arguments": self.function.arguments}}
|
||||
|
||||
|
||||
class _Msg:
|
||||
role = "assistant"
|
||||
refusal = None
|
||||
parsed = None
|
||||
|
||||
def __init__(self, content=None, tool_calls=None):
|
||||
self.content = content
|
||||
self.tool_calls = tool_calls
|
||||
|
||||
def model_dump(self):
|
||||
d = {"role": "assistant", "content": self.content}
|
||||
if self.tool_calls:
|
||||
d["tool_calls"] = [t.model_dump() for t in self.tool_calls]
|
||||
return d
|
||||
|
||||
|
||||
class _Usage:
|
||||
prompt_tokens = 10
|
||||
completion_tokens = 5
|
||||
total_tokens = 15
|
||||
completion_tokens_details = None
|
||||
prompt_tokens_details = None
|
||||
|
||||
def model_dump(self):
|
||||
return {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||
|
||||
|
||||
class _Resp:
|
||||
id = "chatcmpl-test"
|
||||
created = 0
|
||||
object = "chat.completion"
|
||||
model = "gpt-4o-mini"
|
||||
|
||||
def __init__(self, msg, finish_reason="stop"):
|
||||
self.choices = [types.SimpleNamespace(message=msg, finish_reason=finish_reason,
|
||||
index=0, delta=None)]
|
||||
self.usage = _Usage()
|
||||
|
||||
|
||||
class _Recorder:
|
||||
"""Scripted transport. The sync agent path dispatches through
|
||||
``asyncio.run(achat_completion(...))``, so both spellings are needed."""
|
||||
|
||||
def __init__(self, script):
|
||||
self.script = list(script)
|
||||
self.requests = []
|
||||
|
||||
def _next(self, kwargs):
|
||||
self.requests.append(kwargs)
|
||||
i = len(self.requests) - 1
|
||||
return self.script[i] if i < len(self.script) else _Resp(_Msg(content="fallback"))
|
||||
|
||||
def create(self, **kwargs):
|
||||
return self._next(kwargs)
|
||||
|
||||
|
||||
class _ARecorder:
|
||||
def __init__(self, rec):
|
||||
self.rec = rec
|
||||
|
||||
async def create(self, **kwargs):
|
||||
return self.rec._next(kwargs)
|
||||
|
||||
|
||||
def _agent_with_failing_tool(message, monkeypatch):
|
||||
"""Agent whose only tool always raises `message`, with a scripted transport."""
|
||||
runs = []
|
||||
|
||||
def send_email(to: str) -> str:
|
||||
"""Send an email to a recipient."""
|
||||
runs.append(to)
|
||||
raise RuntimeError(message)
|
||||
|
||||
agent = Agent(name="A", instructions="x", llm="gpt-4o-mini", tools=[send_email])
|
||||
|
||||
rec = _Recorder([
|
||||
_Resp(_Msg(tool_calls=[_ToolCall("c1", "send_email", {"to": "[email protected]"})]),
|
||||
finish_reason="tool_calls"),
|
||||
_Resp(_Msg(content="CONFIDENT ANSWER WITH NO TOOL RESULT")),
|
||||
])
|
||||
client = OpenAIClient(api_key="sk-not-a-real-key")
|
||||
fake = types.SimpleNamespace(
|
||||
chat=types.SimpleNamespace(completions=rec),
|
||||
beta=types.SimpleNamespace(chat=types.SimpleNamespace(completions=rec)),
|
||||
responses=None,
|
||||
)
|
||||
arec = _ARecorder(rec)
|
||||
afake = types.SimpleNamespace(
|
||||
chat=types.SimpleNamespace(completions=arec),
|
||||
beta=types.SimpleNamespace(chat=types.SimpleNamespace(completions=arec)),
|
||||
responses=None,
|
||||
)
|
||||
client._sync_client = fake
|
||||
client._async_client = afake
|
||||
agent._Agent__openai_client = client
|
||||
agent._unified_dispatcher = None
|
||||
|
||||
# never actually sleep out a backoff in a unit test
|
||||
monkeypatch.setattr("time.sleep", lambda _d: None)
|
||||
return agent, rec, runs
|
||||
|
||||
|
||||
def test_chat_raises_tool_execution_error(monkeypatch):
|
||||
"""A failing tool must surface as ToolExecutionError, not as None."""
|
||||
agent, _rec, runs = _agent_with_failing_tool("smtp down", monkeypatch)
|
||||
with pytest.raises(ToolExecutionError):
|
||||
agent.chat("send the email")
|
||||
# The failing tool must have actually run, proving the error surfaced
|
||||
# *because of* the tool call rather than before it. The tool executor retries
|
||||
# a failing tool up to 3 times, and every attempt targets the same recipient.
|
||||
assert runs and all(to == "[email protected]" for to in runs)
|
||||
|
||||
|
||||
def test_tool_error_text_is_not_classified_as_an_llm_error(monkeypatch):
|
||||
"""A tool whose message looks like a rate limit must not re-drive the model.
|
||||
|
||||
Without the fix the framework classifies the *tool's* text as a retryable
|
||||
LLM error, sleeps, and calls the model a second time with no tool result --
|
||||
returning that answer to the user as a success.
|
||||
"""
|
||||
agent, rec, runs = _agent_with_failing_tool("429 too many requests", monkeypatch)
|
||||
with pytest.raises(ToolExecutionError):
|
||||
agent.chat("send the email")
|
||||
|
||||
assert runs and all(to == "[email protected]" for to in runs), (
|
||||
"the failing tool must have executed"
|
||||
)
|
||||
assert len(rec.requests) == 1, (
|
||||
f"model was re-driven {len(rec.requests)} times after a tool failure; "
|
||||
f"last request carried roles "
|
||||
f"{[m['role'] for m in rec.requests[-1]['messages']]}"
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""runtime= must normalise spelling and reject typos, not degrade silently.
|
||||
|
||||
Regression for #4229: an unrecognised runtime name was stored verbatim and
|
||||
silently dropped nine of twelve capabilities. Spelling variants of a known
|
||||
runtime must all resolve to the same canonical name; a close typo must raise;
|
||||
a genuinely unknown plugin runtime name must still be accepted (it falls back
|
||||
to the reduced harness through the runtime registry, not a closed literal).
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.config.feature_configs import (
|
||||
RuntimeConfig,
|
||||
canonical_runtime_name,
|
||||
resolve_runtime,
|
||||
)
|
||||
|
||||
|
||||
NATIVE_SPELLINGS = ["native", "NATIVE", " native ", "Native", "nAtIvE"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spelling", NATIVE_SPELLINGS)
|
||||
def test_native_spelling_variants_canonicalise(spelling):
|
||||
assert canonical_runtime_name(spelling) == "native"
|
||||
assert resolve_runtime(spelling).preferred_runtime == "native"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"spelling", ["plugin-harness", "plugin_harness", "harness", "plugin", "reduced"]
|
||||
)
|
||||
def test_harness_aliases_canonicalise(spelling):
|
||||
assert canonical_runtime_name(spelling) == "plugin-harness"
|
||||
assert resolve_runtime(spelling).preferred_runtime == "plugin-harness"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("typo", ["nativ", "natve", "reducd", "harnes"])
|
||||
def test_close_typo_raises_with_suggestion(typo):
|
||||
with pytest.raises(ValueError):
|
||||
canonical_runtime_name(typo)
|
||||
|
||||
|
||||
def test_unknown_plugin_runtime_is_accepted_verbatim():
|
||||
"""A genuinely unknown name is an opaque plugin runtime, not a typo."""
|
||||
assert canonical_runtime_name("my-plugin-runtime") == "my-plugin-runtime"
|
||||
cfg = resolve_runtime("my-plugin-runtime")
|
||||
assert cfg.preferred_runtime == "my-plugin-runtime"
|
||||
|
||||
|
||||
def test_runtime_config_input_is_normalised():
|
||||
cfg = resolve_runtime(RuntimeConfig(preferred_runtime="NATIVE"))
|
||||
assert cfg.preferred_runtime == "native"
|
||||
|
||||
|
||||
def test_dict_input_is_normalised():
|
||||
cfg = resolve_runtime({"preferred_runtime": " native "})
|
||||
assert cfg.preferred_runtime == "native"
|
||||
|
||||
|
||||
def test_runtime_config_input_is_not_mutated():
|
||||
"""resolve_runtime returns a normalised copy; the caller's object stands."""
|
||||
original = RuntimeConfig(preferred_runtime="NATIVE")
|
||||
resolved = resolve_runtime(original)
|
||||
assert original.preferred_runtime == "NATIVE"
|
||||
assert resolved is not original
|
||||
assert resolved.preferred_runtime == "native"
|
||||
|
||||
|
||||
def test_close_typo_error_suggests_matched_alias():
|
||||
with pytest.raises(ValueError) as exc:
|
||||
canonical_runtime_name("reducd")
|
||||
assert "reduced" in str(exc.value)
|
||||
|
||||
|
||||
def test_agent_normalises_runtime_spelling():
|
||||
"""Smoke: Agent construction canonicalises a spelling variant."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(instructions="test", runtime="NATIVE")
|
||||
assert agent._runtime_config.preferred_runtime == "native"
|
||||
|
||||
|
||||
def test_agent_rejects_runtime_config_typo():
|
||||
"""A typo in a direct RuntimeConfig must raise, not degrade silently."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
Agent(instructions="test", runtime=RuntimeConfig(preferred_runtime="nativ"))
|
||||
@@ -135,6 +135,99 @@ class TestEmergencyTruncation:
|
||||
assert result_tokens <= target_tokens * 1.5
|
||||
|
||||
|
||||
class TestApplyContextManagementFallback:
|
||||
"""Regression tests for issue #4240.
|
||||
|
||||
When ``ContextManager`` raises, ``_apply_context_management`` must still
|
||||
apply emergency truncation instead of silently returning the over-budget
|
||||
history. The threshold and target must budget the *whole* request
|
||||
(history + system prompt + tool schemas), or a large system prompt / tool
|
||||
block can push the call over the model window undetected.
|
||||
"""
|
||||
|
||||
def _make_agent(self, strategy_error=False):
|
||||
from praisonaiagents.agent.chat_mixin import ChatMixin
|
||||
from praisonaiagents.context import ContextManager
|
||||
|
||||
class _Agent(ChatMixin):
|
||||
def __init__(self):
|
||||
self.name = "test"
|
||||
self.llm = "gpt-4o-mini"
|
||||
self._memory_instance = None
|
||||
self.context_manager = ContextManager(model="gpt-4o-mini")
|
||||
|
||||
agent = _Agent()
|
||||
if strategy_error:
|
||||
def _boom(*args, **kwargs):
|
||||
raise RuntimeError("simulated context-management failure")
|
||||
agent.context_manager.process = _boom
|
||||
return agent
|
||||
|
||||
def test_overhead_counts_system_prompt_and_tools(self):
|
||||
"""_estimate_request_overhead_tokens includes prompt + tool schemas."""
|
||||
agent = self._make_agent()
|
||||
|
||||
assert agent._estimate_request_overhead_tokens("", None) == 0
|
||||
|
||||
prompt_only = agent._estimate_request_overhead_tokens("word " * 500, None)
|
||||
assert prompt_only > 0
|
||||
|
||||
tools = [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"description": "d" * 4000,
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}]
|
||||
with_tools = agent._estimate_request_overhead_tokens("word " * 500, tools)
|
||||
assert with_tools > prompt_only
|
||||
|
||||
def test_fallback_truncates_when_system_prompt_overflows(self):
|
||||
"""Large system prompt over budget must trigger emergency truncation.
|
||||
|
||||
History alone is well under the limit; only the system prompt pushes
|
||||
the request over. Before the fix this branch skipped truncation.
|
||||
"""
|
||||
from praisonaiagents.context.budgeter import get_model_limit
|
||||
from praisonaiagents.context.tokens import (
|
||||
estimate_messages_tokens,
|
||||
estimate_tokens_heuristic,
|
||||
)
|
||||
|
||||
agent = self._make_agent(strategy_error=True)
|
||||
model_limit = get_model_limit("gpt-4o-mini")
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "short question"},
|
||||
{"role": "assistant", "content": "short answer"},
|
||||
]
|
||||
# History is tiny; a massive system prompt consumes the whole window.
|
||||
big_prompt = "token " * (model_limit) # far above the model limit
|
||||
assert estimate_messages_tokens(messages) < model_limit * 0.95
|
||||
assert estimate_tokens_heuristic(big_prompt) > model_limit
|
||||
|
||||
out, result = agent._apply_context_management(
|
||||
messages, system_prompt=big_prompt, tools=None
|
||||
)
|
||||
assert result is not None
|
||||
assert result.get("emergency_truncated") is True
|
||||
|
||||
def test_fallback_noop_when_whole_request_fits(self):
|
||||
"""No truncation when the full request is comfortably under budget."""
|
||||
agent = self._make_agent(strategy_error=True)
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
]
|
||||
out, result = agent._apply_context_management(
|
||||
messages, system_prompt="You are helpful.", tools=None
|
||||
)
|
||||
assert out == messages
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestToolOutputLimits:
|
||||
"""Tests for tool output truncation at source."""
|
||||
|
||||
|
||||
@@ -206,3 +206,63 @@ class TestOptimizationResult:
|
||||
|
||||
result = OptimizationResult(original_tokens=0)
|
||||
assert result.reduction_percent == 0.0
|
||||
|
||||
|
||||
class TestGetOptimizerKwargsFilter:
|
||||
"""Regression tests for issue #4240.
|
||||
|
||||
``ContextManager`` passes the union of every optimizer's kwargs to
|
||||
``get_optimizer``. Only ``SmartOptimizer`` accepted that full signature;
|
||||
the other six raised ``TypeError`` and context management was silently
|
||||
disabled. ``get_optimizer`` must filter kwargs to each class's signature.
|
||||
"""
|
||||
|
||||
def _union_kwargs(self):
|
||||
return {
|
||||
"preserve_recent": 5,
|
||||
"protected_tools": ["search"],
|
||||
"llm_summarize_fn": None,
|
||||
"smart_tool_summarize": True,
|
||||
"tool_summarize_limits": {},
|
||||
"llm_analyze_fn": None,
|
||||
"min_compaction_ratio": 0.5,
|
||||
"analyzer_strategy": "default",
|
||||
}
|
||||
|
||||
def test_every_strategy_instantiates_with_union_kwargs(self):
|
||||
from praisonaiagents.context.optimizer import (
|
||||
get_optimizer, OPTIMIZER_REGISTRY,
|
||||
)
|
||||
|
||||
for strategy in OPTIMIZER_REGISTRY:
|
||||
optimizer = get_optimizer(strategy, **self._union_kwargs())
|
||||
assert optimizer is not None
|
||||
|
||||
def test_every_strategy_reduces_over_budget_history(self):
|
||||
from praisonaiagents.context.manager import ContextManager
|
||||
from praisonaiagents.context.models import ContextConfig, OptimizerStrategy
|
||||
from praisonaiagents.context.tokens import estimate_messages_tokens
|
||||
|
||||
def history():
|
||||
msgs = [{"role": "system", "content": "You are a helpful assistant."}]
|
||||
for i in range(120):
|
||||
msgs.append({"role": "user", "content": f"[u{i}] " + " ".join(f"w{i}_{j}" for j in range(400))})
|
||||
msgs.append({"role": "assistant", "content": f"[a{i}] " + " ".join(f"r{i}_{j}" for j in range(400))})
|
||||
return msgs
|
||||
|
||||
# Strategies that reduce plain user/assistant text. prune_tools and
|
||||
# non_destructive target tool messages, which this transcript lacks.
|
||||
for strategy in (
|
||||
OptimizerStrategy.TRUNCATE,
|
||||
OptimizerStrategy.SLIDING_WINDOW,
|
||||
OptimizerStrategy.SUMMARIZE,
|
||||
OptimizerStrategy.SMART,
|
||||
OptimizerStrategy.CONVERSATION,
|
||||
):
|
||||
cfg = ContextConfig(strategy=strategy, auto_compact=True)
|
||||
mgr = ContextManager(model="gpt-4o", config=cfg)
|
||||
msgs = history()
|
||||
before = estimate_messages_tokens(msgs)
|
||||
out = mgr.process(msgs)["messages"]
|
||||
after = estimate_messages_tokens(out)
|
||||
assert after < before, f"{strategy.value} did not reduce over-budget history"
|
||||
@@ -194,14 +194,15 @@ REASONING: Way too low"""
|
||||
assert result.score == 1.0
|
||||
|
||||
def test_parse_response_invalid_score(self):
|
||||
"""Test parsing response with invalid score."""
|
||||
"""An unparseable score must fail closed, not fabricate a passing 5.0 (#4195)."""
|
||||
grader = BaseLLMGrader()
|
||||
|
||||
response = """SCORE: not a number
|
||||
REASONING: Invalid"""
|
||||
|
||||
result = grader._parse_response(response, "", "", None)
|
||||
assert result.score == 5.0 # Default
|
||||
assert result.score == 1.0 # Fail closed: no trustworthy number parsed
|
||||
assert result.reasoning == "Invalid"
|
||||
|
||||
def test_get_litellm_import_error(self):
|
||||
"""Test that missing litellm raises ImportError."""
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Regression tests for the HOL Guard tool policy example.
|
||||
|
||||
Proves the `wrap_tool_call` middleware in
|
||||
`examples/middleware/hol_guard_tool_policy.py` is fail-closed: the wrapped tool
|
||||
executor is only reached when HOL Guard returns an explicit allow verdict.
|
||||
Every other verdict/error path short-circuits with zero downstream execution.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from praisonaiagents.hooks import ToolRequest
|
||||
|
||||
|
||||
def _find_example_path():
|
||||
for parent in Path(__file__).resolve().parents:
|
||||
candidate = parent / "examples" / "middleware" / "hol_guard_tool_policy.py"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
raise FileNotFoundError("hol_guard_tool_policy.py example not found")
|
||||
|
||||
|
||||
_EXAMPLE_PATH = _find_example_path()
|
||||
|
||||
|
||||
def _load_example():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"hol_guard_tool_policy_example", _EXAMPLE_PATH
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _make_request(command="echo hi", tool_name="run_shell"):
|
||||
return ToolRequest(tool_name=tool_name, arguments={"command": command})
|
||||
|
||||
|
||||
class _FakeCompleted:
|
||||
def __init__(self, returncode=0, stdout="", stderr=""):
|
||||
self.returncode = returncode
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
def test_explicit_allow_reaches_tool():
|
||||
module = _load_example()
|
||||
executed = []
|
||||
|
||||
def call_next(req):
|
||||
executed.append(req)
|
||||
return "ran"
|
||||
|
||||
with patch.object(module.shutil, "which", return_value="/usr/bin/hol-guard"), \
|
||||
patch.object(
|
||||
module.subprocess,
|
||||
"run",
|
||||
return_value=_FakeCompleted(0, json.dumps({"decision": "allow"})),
|
||||
):
|
||||
result = module.hol_guard_tool_policy(_make_request(), call_next)
|
||||
|
||||
assert result == "ran"
|
||||
assert len(executed) == 1
|
||||
|
||||
|
||||
def test_block_verdict_does_not_execute():
|
||||
module = _load_example()
|
||||
executed = []
|
||||
|
||||
def call_next(req):
|
||||
executed.append(req)
|
||||
return "ran"
|
||||
|
||||
with patch.object(module.shutil, "which", return_value="/usr/bin/hol-guard"), \
|
||||
patch.object(
|
||||
module.subprocess,
|
||||
"run",
|
||||
return_value=_FakeCompleted(0, json.dumps({"decision": "block"})),
|
||||
):
|
||||
result = module.hol_guard_tool_policy(_make_request("rm -rf /"), call_next)
|
||||
|
||||
assert executed == []
|
||||
assert result.error is not None
|
||||
# The denial message is surfaced in `result` too, because the agent tool
|
||||
# executor returns `ToolResponse.result` to the model and discards `error`.
|
||||
assert result.result == result.error
|
||||
|
||||
|
||||
def test_missing_cli_fails_closed():
|
||||
module = _load_example()
|
||||
executed = []
|
||||
|
||||
def call_next(req):
|
||||
executed.append(req)
|
||||
return "ran"
|
||||
|
||||
with patch.object(module.shutil, "which", return_value=None):
|
||||
result = module.hol_guard_tool_policy(_make_request(), call_next)
|
||||
|
||||
assert executed == []
|
||||
assert result.error is not None
|
||||
|
||||
|
||||
def test_malformed_json_fails_closed():
|
||||
module = _load_example()
|
||||
executed = []
|
||||
|
||||
def call_next(req):
|
||||
executed.append(req)
|
||||
return "ran"
|
||||
|
||||
with patch.object(module.shutil, "which", return_value="/usr/bin/hol-guard"), \
|
||||
patch.object(
|
||||
module.subprocess,
|
||||
"run",
|
||||
return_value=_FakeCompleted(0, "not-json"),
|
||||
):
|
||||
result = module.hol_guard_tool_policy(_make_request(), call_next)
|
||||
|
||||
assert executed == []
|
||||
assert result.error is not None
|
||||
|
||||
|
||||
def test_non_object_json_fails_closed():
|
||||
module = _load_example()
|
||||
executed = []
|
||||
|
||||
def call_next(req):
|
||||
executed.append(req)
|
||||
return "ran"
|
||||
|
||||
for payload in ("[]", '"allow"', "null"):
|
||||
with patch.object(module.shutil, "which", return_value="/usr/bin/hol-guard"), \
|
||||
patch.object(
|
||||
module.subprocess,
|
||||
"run",
|
||||
return_value=_FakeCompleted(0, payload),
|
||||
):
|
||||
result = module.hol_guard_tool_policy(_make_request(), call_next)
|
||||
|
||||
assert executed == []
|
||||
assert result.error is not None
|
||||
|
||||
|
||||
def test_nonzero_exit_fails_closed():
|
||||
module = _load_example()
|
||||
executed = []
|
||||
|
||||
def call_next(req):
|
||||
executed.append(req)
|
||||
return "ran"
|
||||
|
||||
with patch.object(module.shutil, "which", return_value="/usr/bin/hol-guard"), \
|
||||
patch.object(
|
||||
module.subprocess,
|
||||
"run",
|
||||
return_value=_FakeCompleted(1, json.dumps({"decision": "allow"})),
|
||||
):
|
||||
result = module.hol_guard_tool_policy(_make_request(), call_next)
|
||||
|
||||
assert executed == []
|
||||
assert result.error is not None
|
||||
|
||||
|
||||
def test_timeout_fails_closed():
|
||||
module = _load_example()
|
||||
executed = []
|
||||
|
||||
def call_next(req):
|
||||
executed.append(req)
|
||||
return "ran"
|
||||
|
||||
def _raise_timeout(*args, **kwargs):
|
||||
raise module.subprocess.TimeoutExpired(cmd="hol-guard", timeout=10)
|
||||
|
||||
with patch.object(module.shutil, "which", return_value="/usr/bin/hol-guard"), \
|
||||
patch.object(module.subprocess, "run", side_effect=_raise_timeout):
|
||||
result = module.hol_guard_tool_policy(_make_request(), call_next)
|
||||
|
||||
assert executed == []
|
||||
assert result.error is not None
|
||||
|
||||
|
||||
def test_non_command_tool_passes_through_untouched():
|
||||
module = _load_example()
|
||||
executed = []
|
||||
|
||||
def call_next(req):
|
||||
executed.append(req)
|
||||
return "ran"
|
||||
|
||||
with patch.object(module.shutil, "which") as which_mock:
|
||||
result = module.hol_guard_tool_policy(
|
||||
_make_request(tool_name="get_weather"), call_next
|
||||
)
|
||||
|
||||
which_mock.assert_not_called()
|
||||
assert result == "ran"
|
||||
assert len(executed) == 1
|
||||
@@ -0,0 +1,92 @@
|
||||
"""`Memory` must not fall through to legacy SQL that bypasses the active adapter.
|
||||
|
||||
Two public methods still target the pre-adapter schema:
|
||||
|
||||
* `get_all_memories()` selects from `short_mem` / `long_mem`. Nothing in the
|
||||
codebase creates those tables -- the adapter creates `short_term_memory` /
|
||||
`long_term_memory` -- so the OperationalError is swallowed and it returns []
|
||||
for every provider, always.
|
||||
* `delete_memory(id)` with no `memory_type` returns on the first tier that
|
||||
reports a hit. The SQLite adapter gives each tier its own AUTOINCREMENT
|
||||
sequence, so the Nth short-term row and the Nth long-term row always share an
|
||||
id. Deleting the id `remember()` handed back therefore destroys an unrelated
|
||||
short-term row and leaves the caller's target in place, returning success.
|
||||
|
||||
Sibling methods (`delete_short_term`, `reset_short_term`) already delegate to the
|
||||
adapter with explicit comments saying why; these two were missed.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.memory.memory import Memory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path, monkeypatch):
|
||||
"""A Memory backed by a SQLite store isolated to this test.
|
||||
|
||||
The adapter resolves its database under ``./.praisonai``, so isolation means
|
||||
changing the working directory -- passing ``storage_path`` in the config does
|
||||
not move it, and without the chdir every test shares one store.
|
||||
"""
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "home"))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return Memory(config={"provider": "sqlite"})
|
||||
|
||||
|
||||
def _tier(mem, which):
|
||||
return [r for r in mem.memory_adapter.get_all_memories()
|
||||
if (r.get("memory_type") or r.get("type")) == which]
|
||||
|
||||
|
||||
def test_adapter_holds_the_data(store):
|
||||
"""Control: without this passing, the other assertions prove nothing."""
|
||||
store.store_short_term("STM: user prefers dark mode")
|
||||
store.store_long_term("LTM: user is in Chennai")
|
||||
assert len(store.memory_adapter.get_all_memories()) == 2
|
||||
|
||||
|
||||
def test_get_all_memories_returns_what_is_stored(store):
|
||||
store.store_short_term("STM: user prefers dark mode")
|
||||
store.store_long_term("LTM: user is in Chennai")
|
||||
|
||||
got = store.get_all_memories()
|
||||
assert got, (
|
||||
"get_all_memories() returned nothing while the adapter holds "
|
||||
f"{len(store.memory_adapter.get_all_memories())} rows"
|
||||
)
|
||||
kinds = {r.get("memory_type") or r.get("type") for r in got}
|
||||
assert kinds == {"short_term", "long_term"}
|
||||
|
||||
# Backward compatibility: the legacy path exposed each record's tier under
|
||||
# the public `type` key, so adapter-backed records must too.
|
||||
assert all(r.get("type") for r in got), (
|
||||
"adapter-backed records dropped the public `type` field callers rely on"
|
||||
)
|
||||
assert {r["type"] for r in got} == {"short_term", "long_term"}
|
||||
|
||||
|
||||
def test_delete_by_id_erases_the_long_term_record(store):
|
||||
"""The id `remember()` returns must delete the record it refers to."""
|
||||
short_id = store.store_short_term("turn 1: hello there")
|
||||
long_id = store.remember("User SSN is 123-45-6789")
|
||||
|
||||
# Precondition. If the ids do not collide this test cannot detect the bug.
|
||||
assert short_id == long_id, (
|
||||
f"expected an id collision across tiers, got {short_id!r} / {long_id!r}"
|
||||
)
|
||||
|
||||
store.forget(memory_id=long_id)
|
||||
|
||||
assert _tier(store, "long_term") == [], (
|
||||
"the record the caller asked to erase is still present"
|
||||
)
|
||||
assert _tier(store, "short_term") == [], (
|
||||
"the colliding short-term record was left behind"
|
||||
)
|
||||
|
||||
|
||||
def test_delete_without_collision_still_works(store):
|
||||
"""Control: the no-collision case must keep behaving as before."""
|
||||
long_id = store.remember("SECRET fact")
|
||||
assert store.forget(memory_id=long_id) == 1
|
||||
assert _tier(store, "long_term") == []
|
||||
@@ -0,0 +1,156 @@
|
||||
"""A decision string with surrounding whitespace must route like a clean one.
|
||||
|
||||
`process.py` derives the routing key as `current_task.result.raw.lower()` -- eight
|
||||
sites, none of which `.strip()`. `result.raw` is raw model text, which routinely
|
||||
carries a trailing newline. The condition lookup then misses, and a miss is
|
||||
treated as "exit condition met": the workflow finishes silently, the rejected
|
||||
task is never retried, no feedback is produced, and no exception is raised.
|
||||
|
||||
`run_outcome.validate_decision_string` -- the typed classifier the same code path
|
||||
calls -- does `.lower().strip()`, so the two disagree about the same string.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import Task, TaskOutput
|
||||
from praisonaiagents.process import Process
|
||||
from praisonaiagents.run_outcome import validate_decision_string
|
||||
|
||||
|
||||
class _ScriptedAgent:
|
||||
"""Returns a fixed string. No LLM, no network."""
|
||||
|
||||
def __init__(self, name, out):
|
||||
self.name = name
|
||||
self.out = out
|
||||
self.role = "r"
|
||||
self.goal = "g"
|
||||
self.tools = []
|
||||
self.llm = "gpt-4o-mini"
|
||||
|
||||
def execute_task(self, task, context=None, **kw):
|
||||
return self.out
|
||||
|
||||
async def aexecute_task(self, task, context=None, **kw):
|
||||
return self.out
|
||||
|
||||
|
||||
def _run(decision_text, limit=25):
|
||||
"""Drive the real workflow to natural completion; return (visited, collect task).
|
||||
|
||||
`limit` is a runaway guard only. This workflow self-terminates after 10 task
|
||||
visits, so the guard must sit above that -- breaking out at exactly 10 cuts the
|
||||
generator off before its final step and the clean-decision control fails for
|
||||
the harness's reasons rather than the code's.
|
||||
"""
|
||||
collect = _ScriptedAgent("collector", "Collected only 3 items")
|
||||
judge = _ScriptedAgent("judge", decision_text)
|
||||
|
||||
t1 = Task(name="collect_data", description="Collect data", expected_output="Data",
|
||||
agent=collect, is_start=True, next_tasks=["validate_data"])
|
||||
t2 = Task(name="validate_data", description="Validate", expected_output="verdict",
|
||||
agent=judge, task_type="decision",
|
||||
condition={"valid": [], "invalid": ["collect_data"]})
|
||||
t2.previous_tasks = ["collect_data"]
|
||||
|
||||
process = Process(agents={"collector": collect, "judge": judge},
|
||||
tasks={t1.id: t1, t2.id: t2}, verbose=0)
|
||||
|
||||
visited = []
|
||||
|
||||
async def drive():
|
||||
async for tid in process.aworkflow():
|
||||
task = process.tasks[tid]
|
||||
visited.append(task.name)
|
||||
task.result = TaskOutput(description="d", raw=task.agent.out,
|
||||
agent=task.agent.name)
|
||||
task.status = "completed"
|
||||
if len(visited) >= limit:
|
||||
break
|
||||
|
||||
asyncio.run(drive())
|
||||
return visited, t1
|
||||
|
||||
|
||||
def _run_sync(decision_text, limit=25):
|
||||
"""Same scenario against the synchronous ``workflow()`` generator.
|
||||
|
||||
``workflow()`` is a separately duplicated code path from ``aworkflow()`` --
|
||||
its routing sites were edited independently -- so it needs its own
|
||||
regression coverage. A whitespace regression in only one of the two paths
|
||||
would otherwise leave this suite green.
|
||||
"""
|
||||
collect = _ScriptedAgent("collector", "Collected only 3 items")
|
||||
judge = _ScriptedAgent("judge", decision_text)
|
||||
|
||||
t1 = Task(name="collect_data", description="Collect data", expected_output="Data",
|
||||
agent=collect, is_start=True, next_tasks=["validate_data"])
|
||||
t2 = Task(name="validate_data", description="Validate", expected_output="verdict",
|
||||
agent=judge, task_type="decision",
|
||||
condition={"valid": [], "invalid": ["collect_data"]})
|
||||
t2.previous_tasks = ["collect_data"]
|
||||
|
||||
process = Process(agents={"collector": collect, "judge": judge},
|
||||
tasks={t1.id: t1, t2.id: t2}, verbose=0)
|
||||
|
||||
visited = []
|
||||
for tid in process.workflow():
|
||||
task = process.tasks[tid]
|
||||
visited.append(task.name)
|
||||
task.result = TaskOutput(description="d", raw=task.agent.out,
|
||||
agent=task.agent.name)
|
||||
task.status = "completed"
|
||||
if len(visited) >= limit:
|
||||
break
|
||||
return visited, t1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("decision", ["invalid", "invalid\n", " invalid", "invalid ",
|
||||
"\ninvalid\n"])
|
||||
def test_rejected_output_is_retried_regardless_of_whitespace(decision):
|
||||
visited, collect_task = _run(decision)
|
||||
assert visited.count("collect_data") > 1, (
|
||||
f"judge returned {decision!r} (a rejection) but collect_data was never "
|
||||
f"retried; workflow ran {visited} and finished silently")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("decision", ["invalid\n", " invalid"])
|
||||
def test_validation_feedback_survives_whitespace(decision):
|
||||
_visited, collect_task = _run(decision)
|
||||
assert collect_task.validation_feedback is not None, (
|
||||
f"judge returned {decision!r} but the retried task got no feedback about "
|
||||
f"what was wrong")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("decision", ["invalid\n", " invalid", "invalid "])
|
||||
def test_router_and_typed_classifier_agree(decision):
|
||||
"""The routing key and the typed classifier must read the same string the same way."""
|
||||
routing_key = decision.lower().strip()
|
||||
assert validate_decision_string(decision) == validate_decision_string(routing_key)
|
||||
assert routing_key in Process.VALIDATION_FAILURE_DECISIONS, (
|
||||
f"{decision!r} classifies as {validate_decision_string(decision)!r} but its "
|
||||
f"routing key {routing_key!r} is not recognised as a failure decision")
|
||||
|
||||
|
||||
def test_clean_decision_still_works():
|
||||
"""Control: the unpadded spelling must keep behaving exactly as before."""
|
||||
visited, collect_task = _run("invalid")
|
||||
assert visited.count("collect_data") > 1
|
||||
assert collect_task.validation_feedback is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("decision", ["invalid", "invalid\n", " invalid", "invalid ",
|
||||
"\ninvalid\n"])
|
||||
def test_sync_rejected_output_is_retried_regardless_of_whitespace(decision):
|
||||
"""The synchronous ``workflow()`` path must strip routing keys too."""
|
||||
visited, _collect_task = _run_sync(decision)
|
||||
assert visited.count("collect_data") > 1, (
|
||||
f"sync judge returned {decision!r} (a rejection) but collect_data was "
|
||||
f"never retried; workflow ran {visited} and finished silently")
|
||||
|
||||
|
||||
def test_sync_clean_decision_still_works():
|
||||
"""Control for the synchronous path."""
|
||||
visited, _collect_task = _run_sync("invalid")
|
||||
assert visited.count("collect_data") > 1
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Tests for portable session export/import/migration (Issue #4267).
|
||||
|
||||
Covers the ``PortableSessionStoreProtocol`` contract and its reference
|
||||
implementation on ``DefaultSessionStore`` (inherited by ``SqliteSessionStore``):
|
||||
round-trip export/import, lineage-aware single export, live-field reset on
|
||||
import, and the hardened import guards (caps, duplicates, malformed payloads).
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_store(tmp):
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
|
||||
return DefaultSessionStore(session_dir=tmp)
|
||||
|
||||
|
||||
class TestPortableProtocol:
|
||||
def test_protocol_importable(self):
|
||||
from praisonaiagents.session import PortableSessionStoreProtocol
|
||||
assert PortableSessionStoreProtocol is not None
|
||||
|
||||
def test_import_report_importable(self):
|
||||
from praisonaiagents.session import ImportReport
|
||||
rep = ImportReport(imported=2)
|
||||
assert rep.skipped_count == 0
|
||||
assert rep.as_dict()["imported"] == 2
|
||||
|
||||
def test_default_store_conforms(self):
|
||||
from praisonaiagents.session import PortableSessionStoreProtocol
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = _make_store(tmp)
|
||||
assert isinstance(store, PortableSessionStoreProtocol)
|
||||
|
||||
def test_sqlite_store_conforms(self):
|
||||
from praisonaiagents.session import PortableSessionStoreProtocol
|
||||
from praisonaiagents.session.sqlite_store import SqliteSessionStore
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = SqliteSessionStore(session_dir=tmp)
|
||||
assert isinstance(store, PortableSessionStoreProtocol)
|
||||
|
||||
|
||||
class TestExport:
|
||||
def test_export_all_versioned(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = _make_store(tmp)
|
||||
store.add_message("s1", "user", "hi")
|
||||
store.add_message("s2", "user", "yo")
|
||||
payload = store.export_all()
|
||||
assert payload["version"] == store.PORTABLE_VERSION
|
||||
assert len(payload["sessions"]) == 2
|
||||
|
||||
def test_export_single(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = _make_store(tmp)
|
||||
store.add_message("s1", "user", "hi")
|
||||
payload = store.export_session("s1")
|
||||
assert len(payload["sessions"]) == 1
|
||||
assert payload["sessions"][0]["session_id"] == "s1"
|
||||
|
||||
def test_export_unknown_is_empty(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = _make_store(tmp)
|
||||
assert store.export_session("nope")["sessions"] == []
|
||||
|
||||
|
||||
class TestImportRoundTrip:
|
||||
def test_roundtrip_preserves_history(self):
|
||||
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
|
||||
src = _make_store(a)
|
||||
src.add_message("s1", "user", "hello")
|
||||
src.add_message("s1", "assistant", "world")
|
||||
dst = _make_store(b)
|
||||
report = dst.import_sessions(src.export_all())
|
||||
assert report.imported == 1
|
||||
assert dst.session_exists("s1")
|
||||
hist = dst.get_chat_history("s1")
|
||||
assert [m["content"] for m in hist] == ["hello", "world"]
|
||||
|
||||
def test_import_resets_live_fields(self):
|
||||
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
|
||||
src = _make_store(a)
|
||||
src.add_message("s1", "user", "hi")
|
||||
src.set_gateway_info("s1", gateway_session_id="live-1", agent_id="agentX")
|
||||
dst = _make_store(b)
|
||||
dst.import_sessions(src.export_all())
|
||||
sd = dst.get_session("s1")
|
||||
assert sd.gateway_session_id is None
|
||||
assert sd.agent_id is None
|
||||
|
||||
def test_import_keep_live_fields_opt_out(self):
|
||||
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
|
||||
src = _make_store(a)
|
||||
src.add_message("s1", "user", "hi")
|
||||
src.set_gateway_info("s1", gateway_session_id="live-1", agent_id="agentX")
|
||||
dst = _make_store(b)
|
||||
dst.import_sessions(src.export_all(), reset_live_fields=False)
|
||||
sd = dst.get_session("s1")
|
||||
assert sd.gateway_session_id == "live-1"
|
||||
|
||||
|
||||
class TestImportHardening:
|
||||
def test_existing_skipped_without_overwrite(self):
|
||||
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
|
||||
src = _make_store(a)
|
||||
src.add_message("s1", "user", "hi")
|
||||
payload = src.export_all()
|
||||
dst = _make_store(b)
|
||||
assert dst.import_sessions(payload).imported == 1
|
||||
report = dst.import_sessions(payload)
|
||||
assert report.imported == 0
|
||||
assert report.skipped_count == 1
|
||||
|
||||
def test_overwrite(self):
|
||||
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
|
||||
src = _make_store(a)
|
||||
src.add_message("s1", "user", "hi")
|
||||
payload = src.export_all()
|
||||
dst = _make_store(b)
|
||||
dst.import_sessions(payload)
|
||||
assert dst.import_sessions(payload, overwrite=True).imported == 1
|
||||
|
||||
def test_max_sessions_cap(self):
|
||||
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
|
||||
src = _make_store(a)
|
||||
src.add_message("s1", "user", "hi")
|
||||
src.add_message("s2", "user", "yo")
|
||||
dst = _make_store(b)
|
||||
report = dst.import_sessions(src.export_all(), max_sessions=1)
|
||||
assert report.imported == 1
|
||||
assert report.skipped_count >= 1
|
||||
|
||||
def test_duplicate_in_payload(self):
|
||||
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
|
||||
src = _make_store(a)
|
||||
src.add_message("s1", "user", "hi")
|
||||
payload = src.export_all()
|
||||
payload["sessions"].append(dict(payload["sessions"][0]))
|
||||
dst = _make_store(b)
|
||||
report = dst.import_sessions(payload)
|
||||
assert report.imported == 1
|
||||
assert any("duplicate" in s["reason"] for s in report.skipped)
|
||||
|
||||
@pytest.mark.parametrize("bad", [{}, {"sessions": "x"}, {"sessions": [1, 2]}, "notadict"])
|
||||
def test_malformed_payloads(self, bad):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = _make_store(tmp)
|
||||
report = store.import_sessions(bad)
|
||||
assert report.imported == 0
|
||||
|
||||
def test_rejects_newer_version(self):
|
||||
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
|
||||
src = _make_store(a)
|
||||
src.add_message("s1", "user", "hi")
|
||||
payload = src.export_all()
|
||||
payload["version"] = src.PORTABLE_VERSION + 1
|
||||
dst = _make_store(b)
|
||||
report = dst.import_sessions(payload)
|
||||
assert report.imported == 0
|
||||
assert not dst.session_exists("s1")
|
||||
assert any("unsupported payload version" in s["reason"] for s in report.skipped)
|
||||
|
||||
def test_import_does_not_mutate_payload(self):
|
||||
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
|
||||
src = _make_store(a)
|
||||
src.add_message("s1", "user", "hi")
|
||||
src.set_gateway_info("s1", gateway_session_id="live-1", agent_id="agentX")
|
||||
payload = src.export_all()
|
||||
record = payload["sessions"][0]
|
||||
gsid_before = record.get("gateway_session_id")
|
||||
meta_before = dict(record.get("metadata") or {})
|
||||
dst = _make_store(b)
|
||||
dst.import_sessions(payload) # reset_live_fields=True by default
|
||||
assert record.get("gateway_session_id") == gsid_before
|
||||
assert (record.get("metadata") or {}) == meta_before
|
||||
|
||||
def test_import_preserves_history_over_destination_window(self):
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
|
||||
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
|
||||
src = _make_store(a)
|
||||
for i in range(6):
|
||||
src.add_message("s1", "user", f"m{i}")
|
||||
payload = src.export_all()
|
||||
# Destination truncates to a small window; a restore must not lose
|
||||
# the exported history before it lands.
|
||||
dst = DefaultSessionStore(
|
||||
session_dir=b, active_window=2, retention="truncate"
|
||||
)
|
||||
assert dst.import_sessions(payload).imported == 1
|
||||
hist = dst.get_chat_history("s1")
|
||||
assert len(hist) == 6
|
||||
@@ -185,3 +185,42 @@ class TestAgentAsToolIntegration:
|
||||
# The handoffs should be processed into the tools list
|
||||
assert writer.name == "Writer"
|
||||
# Note: Handoffs are processed in _process_handoffs, so we check the agent was created
|
||||
|
||||
def test_as_tool_actually_runs_the_sub_agent(self, monkeypatch):
|
||||
"""'Handoff completed' is not 'the sub-agent ran'. Assert the call, not
|
||||
the string.
|
||||
|
||||
``as_tool()`` pins ``ContextPolicy.NONE`` so there is no history to
|
||||
harvest a prompt from; the ``task`` argument must drive the sub-agent.
|
||||
A test that only checks ``"not found" not in result`` passes even when
|
||||
the sub-agent is never invoked — this one records the actual call.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
seen = []
|
||||
monkeypatch.setattr(
|
||||
Agent, "chat", lambda self, p, **k: seen.append((self.name, p)) or "done"
|
||||
)
|
||||
|
||||
coder = Agent(name="Coder", instructions="x")
|
||||
writer = Agent(
|
||||
name="Writer",
|
||||
instructions="x",
|
||||
tools=[coder.as_tool(tool_name="invoke_coder")],
|
||||
)
|
||||
writer.execute_tool("invoke_coder", {"task": "write a haiku"})
|
||||
|
||||
assert seen == [("Coder", "write a haiku")]
|
||||
|
||||
def test_as_tool_advertises_a_task_parameter(self):
|
||||
"""The generated tool must expose a ``task`` parameter so the LLM knows
|
||||
to fill it — a ``(**kwargs)`` signature advertises nothing.
|
||||
"""
|
||||
import inspect
|
||||
from praisonaiagents import Agent
|
||||
|
||||
coder = Agent(name="Coder", instructions="x")
|
||||
tool_fn = coder.as_tool(tool_name="invoke_coder").to_tool_function(
|
||||
Agent(name="Writer", instructions="x")
|
||||
)
|
||||
assert "task" in inspect.signature(tool_fn).parameters
|
||||
@@ -0,0 +1,57 @@
|
||||
"""An unknown Agent kwarg must say what to do instead, not just that it is wrong.
|
||||
|
||||
`Agent` deliberately rejects unknown keyword arguments rather than swallowing
|
||||
them -- that part is correct and worth keeping. But several names are near
|
||||
universal in this ecosystem (`verbose` is accepted by six sibling classes here,
|
||||
and by crewai and langchain), so users reach for them constantly. A bare
|
||||
"unexpected keyword argument" leaves them with no next step, and the answer is
|
||||
not discoverable from the 41-parameter signature.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
|
||||
def _err(**kwargs) -> str:
|
||||
with pytest.raises(TypeError) as exc:
|
||||
Agent(name="A", role="R", goal="G", **kwargs)
|
||||
return str(exc.value)
|
||||
|
||||
|
||||
def test_unknown_kwargs_are_still_rejected():
|
||||
"""Positive control: the fail-fast behaviour must not be softened into a warning."""
|
||||
assert "definitely_not_a_param" in _err(definitely_not_a_param=1)
|
||||
|
||||
|
||||
def test_verbose_explains_where_verbosity_actually_lives():
|
||||
"""Verbosity was consolidated into `output=`; the error must say so.
|
||||
|
||||
Naming the wrong destination is worse than naming none: it sends the user
|
||||
to a setting that does not control this.
|
||||
"""
|
||||
message = _err(verbose=False)
|
||||
assert "verbose" in message
|
||||
assert "output" in message.lower(), (
|
||||
"the error names the bad argument but not the supported alternative; "
|
||||
f"got: {message}"
|
||||
)
|
||||
|
||||
|
||||
def test_stream_also_points_at_output():
|
||||
"""`stream` moved the same way, and internal callers were passing both."""
|
||||
assert "output" in _err(stream=False).lower()
|
||||
|
||||
|
||||
def test_the_guidance_names_the_offending_argument_not_a_generic_hint():
|
||||
"""Guidance must be specific, or it is noise on every unrelated typo."""
|
||||
generic = _err(some_typo=1)
|
||||
assert "some_typo" in generic
|
||||
assert "output=" not in generic, (
|
||||
"an unrelated typo received the migration guidance meant for `verbose`"
|
||||
)
|
||||
|
||||
|
||||
def test_multiple_unknown_kwargs_are_all_listed():
|
||||
message = _err(verbose=True, another_bad_one=2)
|
||||
assert "verbose" in message and "another_bad_one" in message
|
||||
@@ -157,28 +157,34 @@ class TestConcurrentMemoryAccess:
|
||||
|
||||
|
||||
class TestLazyCacheThreadSafety:
|
||||
"""Test _lazy_cache thread safety."""
|
||||
"""Test lazy loading thread safety."""
|
||||
|
||||
def test_lazy_cache_access_thread_safe(self):
|
||||
"""_lazy_cache access should be thread-safe."""
|
||||
from praisonaiagents import _get_lazy_cache
|
||||
"""Concurrent lazy attribute access should resolve to a single cached object."""
|
||||
import praisonaiagents
|
||||
|
||||
# Evict any pre-cached value so the concurrent access below actually
|
||||
# exercises the synchronized first-load path (double-checked lock in
|
||||
# _lazy.lazy_import) instead of taking the unlocked cache fast path.
|
||||
praisonaiagents._lazy_cache.pop('Task', None)
|
||||
|
||||
errors = []
|
||||
results = []
|
||||
|
||||
def access_cache():
|
||||
def access_lazy_attr():
|
||||
try:
|
||||
cache = _get_lazy_cache()
|
||||
assert isinstance(cache, dict)
|
||||
results.append(praisonaiagents.Task)
|
||||
except Exception as e:
|
||||
errors.append(str(e))
|
||||
|
||||
threads = [threading.Thread(target=access_cache) for _ in range(10)]
|
||||
threads = [threading.Thread(target=access_lazy_attr) for _ in range(10)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert len(errors) == 0, f"Thread errors: {errors}"
|
||||
assert all(r is results[0] for r in results)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -95,6 +95,29 @@ class TestConfigurableModelProvider:
|
||||
)
|
||||
assert agent is not None
|
||||
|
||||
def test_agent_with_evolink_openai_compatible_config(self):
|
||||
"""Agent should pass EvoLink OpenAI-compatible settings to LLM."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
with patch("praisonaiagents.llm.llm.LLM") as mock_llm:
|
||||
agent = Agent(
|
||||
name="Test",
|
||||
instructions="Test agent",
|
||||
llm={
|
||||
"model": "openai/gpt-5.2",
|
||||
"api_key": "test-evolink-key",
|
||||
"base_url": "https://direct.evolink.ai/v1",
|
||||
},
|
||||
)
|
||||
|
||||
assert agent is not None
|
||||
assert agent.llm == "openai/gpt-5.2"
|
||||
assert agent._llm_init_params["model"] == "openai/gpt-5.2"
|
||||
assert agent._llm_init_params["api_key"] == "test-evolink-key"
|
||||
assert agent._llm_init_params["base_url"] == "https://direct.evolink.ai/v1"
|
||||
assert agent._using_custom_llm is True
|
||||
mock_llm.assert_not_called()
|
||||
|
||||
|
||||
class TestConfigurableModelThreadSafety:
|
||||
"""Test thread safety of agent model usage."""
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Unit tests for the global operator emergency-stop brake (Issue #4220).
|
||||
|
||||
Covers the pure, core-side contract: the ``EmergencyStopProtocol``, the
|
||||
zero-cost ``NullEmergencyStop`` default (never engaged), the durable
|
||||
fail-safe ``FileEmergencyStop`` sentinel, and the ``EmergencyStopConfig``
|
||||
selector wired into ``GatewayConfig``.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.gateway import (
|
||||
EmergencyStopConfig,
|
||||
EmergencyStopProtocol,
|
||||
EmergencyStopState,
|
||||
FileEmergencyStop,
|
||||
GatewayConfig,
|
||||
NullEmergencyStop,
|
||||
)
|
||||
|
||||
|
||||
def test_null_and_file_conform_to_protocol(tmp_path):
|
||||
assert isinstance(NullEmergencyStop(), EmergencyStopProtocol)
|
||||
assert isinstance(
|
||||
FileEmergencyStop(str(tmp_path / "x.pause")), EmergencyStopProtocol
|
||||
)
|
||||
|
||||
|
||||
def test_null_brake_never_engaged():
|
||||
brake = NullEmergencyStop()
|
||||
assert brake.is_engaged() is False
|
||||
brake.engage(reason="cost spike", actor="op")
|
||||
assert brake.is_engaged() is False
|
||||
assert brake.state().engaged is False
|
||||
|
||||
|
||||
def test_file_brake_engage_disengage_roundtrip(tmp_path):
|
||||
sentinel = tmp_path / "gateway.pause"
|
||||
brake = FileEmergencyStop(str(sentinel))
|
||||
assert brake.is_engaged() is False
|
||||
|
||||
brake.engage(reason="provider incident", actor="mervin")
|
||||
assert brake.is_engaged() is True
|
||||
st = brake.state()
|
||||
assert st.engaged is True
|
||||
assert st.reason == "provider incident"
|
||||
assert st.actor == "mervin"
|
||||
assert st.at > 0
|
||||
|
||||
brake.disengage()
|
||||
assert brake.is_engaged() is False
|
||||
assert brake.state().engaged is False
|
||||
|
||||
|
||||
def test_file_brake_is_durable_across_instances(tmp_path):
|
||||
sentinel = tmp_path / "gateway.pause"
|
||||
FileEmergencyStop(str(sentinel)).engage(reason="hold")
|
||||
# A fresh instance (as after a restart) sees the persisted engaged state.
|
||||
assert FileEmergencyStop(str(sentinel)).is_engaged() is True
|
||||
|
||||
|
||||
def test_file_brake_disengage_is_idempotent(tmp_path):
|
||||
brake = FileEmergencyStop(str(tmp_path / "gateway.pause"))
|
||||
brake.disengage() # no sentinel yet -> no error
|
||||
assert brake.is_engaged() is False
|
||||
|
||||
|
||||
def test_file_brake_corrupt_sentinel_fails_safe_engaged(tmp_path):
|
||||
sentinel = tmp_path / "gateway.pause"
|
||||
sentinel.write_text("{ this is not valid json")
|
||||
brake = FileEmergencyStop(str(sentinel))
|
||||
# Unreadable/corrupt sentinel counts as engaged, never "run freely".
|
||||
assert brake.is_engaged() is True
|
||||
assert brake.state().engaged is True
|
||||
|
||||
|
||||
def test_file_brake_structured_at_fails_safe_engaged(tmp_path):
|
||||
# Valid JSON object but a non-numeric ``at`` (list) must not raise from
|
||||
# float(); state() must still fail-safe engaged rather than propagate.
|
||||
sentinel = tmp_path / "gateway.pause"
|
||||
sentinel.write_text('{"engaged": true, "at": []}')
|
||||
brake = FileEmergencyStop(str(sentinel))
|
||||
assert brake.is_engaged() is True
|
||||
assert brake.state().engaged is True
|
||||
|
||||
|
||||
def test_file_brake_requires_path():
|
||||
with pytest.raises(ValueError):
|
||||
FileEmergencyStop("")
|
||||
|
||||
|
||||
def test_config_defaults_off_and_disabled():
|
||||
cfg = EmergencyStopConfig()
|
||||
assert cfg.backend == "off"
|
||||
assert cfg.enabled is False
|
||||
assert isinstance(cfg.to_estop(), NullEmergencyStop)
|
||||
|
||||
|
||||
def test_config_file_backend_requires_path():
|
||||
with pytest.raises(ValueError):
|
||||
EmergencyStopConfig(backend="file")
|
||||
|
||||
|
||||
def test_config_rejects_unknown_backend():
|
||||
with pytest.raises(ValueError):
|
||||
EmergencyStopConfig(backend="bogus")
|
||||
|
||||
|
||||
def test_config_file_backend_builds_file_brake(tmp_path):
|
||||
cfg = EmergencyStopConfig(backend="file", path=str(tmp_path / "p.pause"))
|
||||
assert cfg.enabled is True
|
||||
assert isinstance(cfg.to_estop(), FileEmergencyStop)
|
||||
|
||||
|
||||
def test_config_from_dict_tolerates_none():
|
||||
assert EmergencyStopConfig.from_dict(None).backend == "off"
|
||||
|
||||
|
||||
def test_config_roundtrip_dict(tmp_path):
|
||||
cfg = EmergencyStopConfig(backend="file", path=str(tmp_path / "p.pause"))
|
||||
restored = EmergencyStopConfig.from_dict(cfg.to_dict())
|
||||
assert restored.backend == "file"
|
||||
assert restored.path == cfg.path
|
||||
|
||||
|
||||
def test_gateway_config_default_control_is_off():
|
||||
gw = GatewayConfig()
|
||||
assert gw.control.backend == "off"
|
||||
assert gw.control.enabled is False
|
||||
assert gw.to_dict()["control"] == {"backend": "off", "path": None}
|
||||
|
||||
|
||||
def test_estop_state_to_dict():
|
||||
state = EmergencyStopState(engaged=True, reason="r", actor="a", at=1.5)
|
||||
assert state.to_dict() == {
|
||||
"engaged": True,
|
||||
"reason": "r",
|
||||
"actor": "a",
|
||||
"at": 1.5,
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Unit tests for gateway saturation/back-pressure telemetry (Issue #4265).
|
||||
|
||||
Covers the pure, core-side contract: the frozen :class:`HealthPressure`
|
||||
dataclass, its JSON shape, and the deterministic :func:`evaluate_pressure`
|
||||
classifier (``nominal`` / ``elevated`` / ``saturated``). No I/O.
|
||||
"""
|
||||
|
||||
from praisonaiagents.gateway import HealthPressure, evaluate_pressure
|
||||
|
||||
|
||||
def test_defaults_are_nominal():
|
||||
p = HealthPressure()
|
||||
assert p.pressure == "nominal"
|
||||
assert p.admission_max == 0
|
||||
assert p.event_loop_lag_p99_ms == 0.0
|
||||
|
||||
|
||||
def test_to_dict_has_closed_shape():
|
||||
p = HealthPressure(admission_max=16, admission_in_flight=4, pressure="elevated")
|
||||
d = p.to_dict()
|
||||
assert d["admission_max"] == 16
|
||||
assert d["admission_in_flight"] == 4
|
||||
assert d["pressure"] == "elevated"
|
||||
assert set(d) == {
|
||||
"admission_max",
|
||||
"admission_in_flight",
|
||||
"admission_queued",
|
||||
"admission_shed_total",
|
||||
"inbox_pending",
|
||||
"outbox_pending",
|
||||
"outbox_dead_letter",
|
||||
"event_loop_lag_p99_ms",
|
||||
"pressure",
|
||||
}
|
||||
|
||||
|
||||
def test_empty_is_nominal():
|
||||
assert evaluate_pressure().pressure == "nominal"
|
||||
assert evaluate_pressure(admission={}).pressure == "nominal"
|
||||
|
||||
|
||||
def test_high_utilisation_is_elevated():
|
||||
p = evaluate_pressure(
|
||||
admission={"max_concurrent_runs": 10, "in_flight": 8, "queued": 0, "shed": 0}
|
||||
)
|
||||
assert p.pressure == "elevated"
|
||||
assert p.admission_in_flight == 8
|
||||
|
||||
|
||||
def test_below_utilisation_threshold_is_nominal():
|
||||
p = evaluate_pressure(
|
||||
admission={"max_concurrent_runs": 10, "in_flight": 7, "queued": 0}
|
||||
)
|
||||
assert p.pressure == "nominal"
|
||||
|
||||
|
||||
def test_full_and_queued_is_saturated():
|
||||
p = evaluate_pressure(
|
||||
admission={"max_concurrent_runs": 4, "in_flight": 4, "queued": 3, "shed": 1}
|
||||
)
|
||||
assert p.pressure == "saturated"
|
||||
assert p.admission_queued == 3
|
||||
assert p.admission_shed_total == 1
|
||||
|
||||
|
||||
def test_dead_letter_forces_saturated():
|
||||
p = evaluate_pressure(outbox_dead_letter=1)
|
||||
assert p.pressure == "saturated"
|
||||
assert p.outbox_dead_letter == 1
|
||||
|
||||
|
||||
def test_backlog_is_elevated():
|
||||
assert evaluate_pressure(inbox_pending=5).pressure == "elevated"
|
||||
assert evaluate_pressure(outbox_pending=5).pressure == "elevated"
|
||||
|
||||
|
||||
def test_loop_lag_thresholds():
|
||||
assert evaluate_pressure(loop_lag_p99_ms=50.0).pressure == "nominal"
|
||||
assert evaluate_pressure(loop_lag_p99_ms=150.0).pressure == "elevated"
|
||||
assert evaluate_pressure(loop_lag_p99_ms=800.0).pressure == "saturated"
|
||||
|
||||
|
||||
def test_saturated_dominates_elevated():
|
||||
p = evaluate_pressure(
|
||||
admission={"max_concurrent_runs": 2, "in_flight": 2, "queued": 1},
|
||||
inbox_pending=100,
|
||||
outbox_pending=50,
|
||||
)
|
||||
assert p.pressure == "saturated"
|
||||
|
||||
|
||||
def test_tolerates_junk_inputs():
|
||||
p = evaluate_pressure(
|
||||
admission={"max_concurrent_runs": None, "in_flight": "x"},
|
||||
inbox_pending="nope",
|
||||
outbox_pending=None,
|
||||
outbox_dead_letter=-3,
|
||||
loop_lag_p99_ms="bad",
|
||||
)
|
||||
assert p.pressure == "nominal"
|
||||
assert p.admission_max == 0
|
||||
assert p.admission_in_flight == 0
|
||||
assert p.outbox_dead_letter == 0
|
||||
assert p.event_loop_lag_p99_ms == 0.0
|
||||
|
||||
|
||||
def test_unbounded_admission_never_saturates_on_util_alone():
|
||||
# No ceiling (max == 0): utilisation is undefined; only queued/backlog/lag
|
||||
# can raise pressure, never a divide-by-zero.
|
||||
p = evaluate_pressure(admission={"max_concurrent_runs": 0, "in_flight": 999})
|
||||
assert p.pressure == "nominal"
|
||||
|
||||
|
||||
def test_is_frozen():
|
||||
p = HealthPressure()
|
||||
import dataclasses
|
||||
|
||||
try:
|
||||
p.pressure = "saturated" # type: ignore[misc]
|
||||
except dataclasses.FrozenInstanceError:
|
||||
return
|
||||
raise AssertionError("HealthPressure should be frozen")
|
||||
@@ -78,6 +78,71 @@ def test_healthy_loop_not_tripped():
|
||||
assert wd.armed is False
|
||||
|
||||
|
||||
def test_last_lag_ms_measured_on_healthy_loop():
|
||||
"""A responsive loop reports a small, finite scheduling lag (Issue #4265)."""
|
||||
wd = LoopWatchdog()
|
||||
assert wd.last_lag_ms == 0.0 # nothing measured yet
|
||||
loop = asyncio.new_event_loop()
|
||||
policy = LoopWatchdogPolicy(
|
||||
probe_interval_s=0.02,
|
||||
missed_probes_before_wedged=3,
|
||||
on_wedge="dump_only",
|
||||
)
|
||||
wd = LoopWatchdog(policy)
|
||||
wd.arm(loop)
|
||||
try:
|
||||
_run_loop_for(loop, 0.3)
|
||||
finally:
|
||||
wd.disarm()
|
||||
loop.close()
|
||||
# A healthy loop ran the ack promptly: lag is measured and small.
|
||||
assert wd.last_lag_ms >= 0.0
|
||||
assert wd.last_lag_ms < 1000.0
|
||||
|
||||
|
||||
def test_last_lag_ms_anchored_to_oldest_unacked_probe():
|
||||
"""Multi-interval stalls are measured from the *oldest* unacked probe.
|
||||
|
||||
Regression for Issue #4265: when the loop is stalled the watchdog keeps
|
||||
scheduling fresh acks that all queue behind the wedge. If each new probe
|
||||
overwrote the send-time anchor, the ack that finally runs would report only
|
||||
the gap since the newest probe, understating a severe stall. The anchor
|
||||
must stay pinned to the first still-unacked probe until an ack consumes it.
|
||||
"""
|
||||
loop = asyncio.new_event_loop()
|
||||
wd = LoopWatchdog(
|
||||
LoopWatchdogPolicy(probe_interval_s=0.02, on_wedge="dump_only")
|
||||
)
|
||||
wd._loop = loop # attach without arming the OS thread; drive probes by hand
|
||||
try:
|
||||
# Three probes scheduled while the loop is stalled (no ack runs between
|
||||
# them). The real _schedule_probe must keep the anchor pinned to the
|
||||
# first probe rather than advancing it on every call.
|
||||
wd._schedule_probe()
|
||||
first_anchor = wd._probe_sent_at
|
||||
assert first_anchor > 0.0
|
||||
time.sleep(0.02)
|
||||
wd._schedule_probe()
|
||||
time.sleep(0.02)
|
||||
wd._schedule_probe()
|
||||
assert wd._probe_sent_at == first_anchor # never advanced past the oldest
|
||||
|
||||
# The loop finally drains the burst of acks. The first _ack measures the
|
||||
# full stall from the oldest anchor and consumes it; later acks in the
|
||||
# burst see a zeroed anchor and leave the measured lag intact.
|
||||
loop.call_soon(wd._ack)
|
||||
loop.call_soon(wd._ack)
|
||||
loop.call_soon(wd._ack)
|
||||
loop.call_soon(loop.stop)
|
||||
loop.run_forever()
|
||||
# Elapsed since the oldest probe is ~0.04s+ (two 0.02s sleeps): the
|
||||
# reported lag reflects the full multi-interval stall, not ~0.
|
||||
assert wd.last_lag_ms >= 40.0
|
||||
assert wd._probe_sent_at == 0.0 # consumed → next probe re-anchors fresh
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
def test_wedged_loop_detected():
|
||||
"""A loop blocked inside a sync call is detected (dump_only, no exit)."""
|
||||
loop = asyncio.new_event_loop()
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Every WebSocket message type the gateway dispatches must be scope-classified.
|
||||
|
||||
`resolve_required_scope` fails closed to ADMIN for anything it does not know, which
|
||||
is the right default and is why it cannot simply be switched on: four of the six
|
||||
message types the dispatcher handles today are unregistered, so enabling it would
|
||||
demand ADMIN for the connection handshake and lock out every client.
|
||||
|
||||
This suite pins the two lists together. It is a lockstep test in jan's style --
|
||||
it does not exercise behaviour, it asserts that two independently-maintained lists
|
||||
agree, which is a failure mode no behavioural test can see: the code compiles, the
|
||||
unit tests pass, and the omission only appears as a permission error at runtime.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.gateway.protocols import (
|
||||
GATEWAY_METHODS,
|
||||
EventType,
|
||||
OperatorScope,
|
||||
resolve_required_scope,
|
||||
)
|
||||
|
||||
_SERVER = (
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "praisonai-bot"
|
||||
/ "praisonai_bot"
|
||||
/ "gateway"
|
||||
/ "server.py"
|
||||
)
|
||||
|
||||
# Types handled by the transport itself, before any authorization decision.
|
||||
_TRANSPORT_TYPES = {"pong", "ping"}
|
||||
|
||||
|
||||
_OPERAND = r'"[a-z_.]+"|EventType\.[A-Z_]+\.value'
|
||||
|
||||
|
||||
def _resolve_operand(token: str) -> str | None:
|
||||
"""Map one ``msg_type`` comparison operand to the wire string it matches.
|
||||
|
||||
A string literal is its own wire value; an ``EventType.<NAME>.value``
|
||||
reference resolves through the enum exactly as Python would at runtime.
|
||||
Anything else (an unknown enum member, a non-literal expression) yields
|
||||
``None`` so it can be surfaced rather than silently dropped.
|
||||
"""
|
||||
literal = re.fullmatch(r'"([a-z_.]+)"', token)
|
||||
if literal:
|
||||
return literal.group(1)
|
||||
enum_ref = re.fullmatch(r"EventType\.([A-Z_]+)\.value", token)
|
||||
if enum_ref:
|
||||
member = getattr(EventType, enum_ref.group(1), None)
|
||||
if member is not None:
|
||||
return member.value
|
||||
return None
|
||||
|
||||
|
||||
def _dispatched_message_types() -> set[str]:
|
||||
"""Extract the msg_type branches the gateway's WS dispatcher acts on.
|
||||
|
||||
The dispatcher compares ``msg_type`` in two interchangeable styles:
|
||||
|
||||
* string literals — ``msg_type == "hello"``, ``msg_type in ("abort", ...)``;
|
||||
* enum-derived — ``msg_type == EventType.PING.value``,
|
||||
``msg_type in ("abort", EventType.MESSAGE_ABORT.value)``.
|
||||
|
||||
Recognising only the first style would let a branch written purely in the
|
||||
second (as ``ping``/``pong`` already are) drift past this lockstep — a
|
||||
classified method could go unregistered and surface only as a runtime
|
||||
permission error. So every ``EventType.<NAME>.value`` operand is resolved
|
||||
through the enum to its wire value and considered alongside the literals.
|
||||
|
||||
Within a single ``in (...)`` branch the operands are *aliases* for one
|
||||
dispatch path (``"abort"`` and ``EventType.MESSAGE_ABORT.value`` route to
|
||||
the same handler). The registry classifies the canonical name, so each
|
||||
branch contributes one representative — the literal if present, else the
|
||||
resolved enum value — rather than every alias as a distinct method.
|
||||
"""
|
||||
source = _SERVER.read_text()
|
||||
found: set[str] = set()
|
||||
|
||||
for token in re.findall(rf"msg_type == ({_OPERAND})", source):
|
||||
resolved = _resolve_operand(token)
|
||||
if resolved is not None:
|
||||
found.add(resolved)
|
||||
|
||||
for group in re.findall(r"msg_type in \(([^)]*)\)", source):
|
||||
operands = [_resolve_operand(t) for t in re.findall(_OPERAND, group)]
|
||||
resolved = [o for o in operands if o is not None]
|
||||
if not resolved:
|
||||
continue
|
||||
literals = [
|
||||
re.fullmatch(r'"([a-z_.]+)"', t).group(1)
|
||||
for t in re.findall(_OPERAND, group)
|
||||
if re.fullmatch(r'"[a-z_.]+"', t)
|
||||
]
|
||||
found.add(literals[0] if literals else resolved[0])
|
||||
|
||||
return found - _TRANSPORT_TYPES
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _SERVER.exists(), reason="praisonai-bot not present in this checkout")
|
||||
def test_the_dispatcher_handles_types_this_test_can_see():
|
||||
"""Positive control: if the extraction breaks, every other test here passes vacuously."""
|
||||
types = _dispatched_message_types()
|
||||
assert len(types) >= 4, f"extraction found only {types}; the regex has drifted from the source"
|
||||
assert "message" in types, "the most basic message type was not extracted"
|
||||
# ``abort`` is dispatched via a mixed-style branch
|
||||
# (``msg_type in ("abort", EventType.MESSAGE_ABORT.value)``); seeing it
|
||||
# proves the enum-derived operand resolution works, so a future branch
|
||||
# written enum-only cannot silently drift past this lockstep.
|
||||
assert "abort" in types, "the enum-derived branch resolution has drifted from the source"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _SERVER.exists(), reason="praisonai-bot not present in this checkout")
|
||||
def test_every_dispatched_message_type_is_scope_classified():
|
||||
unregistered = sorted(t for t in _dispatched_message_types() if t not in GATEWAY_METHODS)
|
||||
assert not unregistered, (
|
||||
"these WebSocket message types are dispatched but carry no scope classification, "
|
||||
f"so resolve_required_scope() would fail them closed to ADMIN: {unregistered}. "
|
||||
"Register each via register_gateway_method() before wiring the resolver into dispatch."
|
||||
)
|
||||
|
||||
|
||||
def test_an_unknown_method_still_fails_closed():
|
||||
"""The property the whole design rests on. Must survive any registration change."""
|
||||
for unknown in ("sessions.list", "config.write", "definitely.not.a.method", ""):
|
||||
assert resolve_required_scope(unknown) is OperatorScope.ADMIN
|
||||
|
||||
|
||||
def test_the_handshake_is_not_accidentally_admin():
|
||||
"""`hello` runs before a client can hold any scope; requiring ADMIN would lock everyone out."""
|
||||
if "hello" not in GATEWAY_METHODS:
|
||||
pytest.skip("hello not yet registered")
|
||||
assert resolve_required_scope("hello") is not OperatorScope.ADMIN
|
||||
@@ -42,7 +42,25 @@ def test_either_name_alone_is_honoured(kw, name):
|
||||
assert _cls(name)(**{kw: "gpt-4o"}, **_extra(name)).llm == "gpt-4o"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", UNWRAPPING)
|
||||
@pytest.mark.parametrize("name", ALL)
|
||||
def test_llmconfig_is_unwrapped_to_a_model_string(name):
|
||||
# Otherwise the LLMConfig object itself is handed to litellm as model=.
|
||||
# Containers push this value into every member; a member holding an
|
||||
# LLMConfig fails inside chat() and returns None, so the leak is silent.
|
||||
assert _cls(name)(llm=LLMConfig(model="gpt-4o"), **_extra(name)).llm == "gpt-4o"
|
||||
|
||||
|
||||
def test_agentteam_member_receives_the_unwrapped_model_string():
|
||||
# The #4227 failure: the container unwrapped its own field but pushed the
|
||||
# raw LLMConfig into members via _apply_default_llm, so a member's chat()
|
||||
# died on "'LLMConfig' object has no attribute 'lower'" and returned None.
|
||||
member = _cls("Agent")(instructions="t")
|
||||
_cls("AgentTeam")(agents=[member], llm=LLMConfig(model="gpt-4o"))
|
||||
assert member.llm == "gpt-4o"
|
||||
|
||||
|
||||
def test_agentflow_seeds_agents_with_the_unwrapped_model_string():
|
||||
# AgentFlow seeds the agents it builds from self.model (llm=config.get(
|
||||
# "llm", model)); an LLMConfig there would fail the same way inside chat().
|
||||
flow = _cls("AgentFlow")(llm=LLMConfig(model="gpt-4o"))
|
||||
assert flow.model == "gpt-4o"
|
||||
@@ -468,3 +468,204 @@ class TestRegistryPermissionResolver:
|
||||
defs = reg.get_tool_definitions()
|
||||
names = {d.get("function", {}).get("name") for d in defs}
|
||||
assert "read_file" in names
|
||||
|
||||
|
||||
class TestApprovalConfigNeverWeakensDefault:
|
||||
"""Issue #4228 (a): configuring approval must not drop the default denials."""
|
||||
|
||||
def test_approval_config_keeps_default_denials(self):
|
||||
"""ApprovalConfig(timeout=30) is a configuration change, not a security decision."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.approval.protocols import ApprovalConfig
|
||||
|
||||
default = Agent(name="test", instructions="test")._perm_deny
|
||||
configured = Agent(
|
||||
name="test", instructions="test", approval=ApprovalConfig(timeout=30)
|
||||
)._perm_deny
|
||||
assert configured >= default
|
||||
assert "execute_command" in configured
|
||||
assert "delete_file" in configured
|
||||
|
||||
def test_dict_config_keeps_default_denials(self):
|
||||
from praisonaiagents import Agent
|
||||
|
||||
default = Agent(name="test", instructions="test")._perm_deny
|
||||
configured = Agent(
|
||||
name="test", instructions="test", approval={"timeout": 30}
|
||||
)._perm_deny
|
||||
assert configured >= default
|
||||
assert "execute_command" in configured
|
||||
|
||||
def test_config_with_permissions_defers_to_manager(self):
|
||||
"""An explicit declarative policy owns denial; the preset must not shadow it."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.approval.protocols import ApprovalConfig
|
||||
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="test",
|
||||
approval=ApprovalConfig(permissions={"bash:rm *": "deny"}),
|
||||
)
|
||||
assert agent._perm_deny == frozenset()
|
||||
assert agent._permission_manager is not None
|
||||
|
||||
def test_env_off_switch_still_disables_denials(self, monkeypatch):
|
||||
"""PRAISONAI_TOOL_SAFETY=off remains a full bypass even with a config object."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.approval.protocols import ApprovalConfig
|
||||
|
||||
monkeypatch.setenv("PRAISONAI_TOOL_SAFETY", "off")
|
||||
agent = Agent(
|
||||
name="test", instructions="test", approval=ApprovalConfig(timeout=30)
|
||||
)
|
||||
assert agent._perm_deny == frozenset()
|
||||
|
||||
def test_explicit_empty_permissions_policy_owns_denial(self):
|
||||
"""``permissions={}`` is an intentional (empty) policy, not an absent one.
|
||||
|
||||
An empty mapping must NOT inherit the default deny set — the caller
|
||||
explicitly opted into a declarative policy that happens to allow all.
|
||||
Only a genuinely absent policy (``permissions=None``) falls back to the
|
||||
env-driven default denials.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.approval.protocols import ApprovalConfig
|
||||
|
||||
empty_config = Agent(
|
||||
name="test", instructions="test",
|
||||
approval=ApprovalConfig(permissions={}),
|
||||
)._perm_deny
|
||||
empty_dict = Agent(
|
||||
name="test", instructions="test",
|
||||
approval={"permissions": {}},
|
||||
)._perm_deny
|
||||
assert empty_config == frozenset()
|
||||
assert empty_dict == frozenset()
|
||||
|
||||
|
||||
class TestArgumentScopedDeny:
|
||||
"""Issue #4228 (b): argument-level deny rules must reach the enforcement path."""
|
||||
|
||||
def _agent_with_rule(self, tmp_path, pattern, action="deny"):
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.permissions import PermissionManager
|
||||
|
||||
mgr = PermissionManager(storage_dir=str(tmp_path))
|
||||
mgr.load_rules_from_config({pattern: action})
|
||||
agent = Agent(name="test", instructions="test")
|
||||
agent._perm_deny = frozenset()
|
||||
agent._perm_allow = None
|
||||
agent._permission_manager = mgr
|
||||
return agent
|
||||
|
||||
def test_argument_scoped_deny_blocks_only_matching_call(self, tmp_path):
|
||||
"""The rule engine matches arguments; the enforcement path must ask it."""
|
||||
agent = self._agent_with_rule(tmp_path, "bash:rm *")
|
||||
|
||||
blocked = agent._check_tool_approval_sync(
|
||||
"execute_command", {"command": "rm -rf /tmp/x"}
|
||||
)
|
||||
assert isinstance(blocked, dict)
|
||||
assert blocked.get("permission_denied") is True
|
||||
|
||||
def test_argument_scoped_deny_allows_non_matching_call(self, tmp_path):
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
|
||||
agent = self._agent_with_rule(tmp_path, "bash:rm *")
|
||||
with patch(
|
||||
"praisonaiagents.approval.get_approval_registry"
|
||||
) as get_reg:
|
||||
reg = MagicMock()
|
||||
reg.approve_sync.return_value = ApprovalDecision(
|
||||
approved=True, reason="mock"
|
||||
)
|
||||
reg.mark_approved = MagicMock()
|
||||
get_reg.return_value = reg
|
||||
result = agent._check_tool_approval_sync(
|
||||
"execute_command", {"command": "ls -la"}
|
||||
)
|
||||
assert result is None or (isinstance(result, tuple) and len(result) == 2)
|
||||
|
||||
def test_argument_scoped_deny_async(self, tmp_path):
|
||||
import asyncio
|
||||
|
||||
agent = self._agent_with_rule(tmp_path, "bash:rm *")
|
||||
blocked = asyncio.run(
|
||||
agent._check_tool_approval_async(
|
||||
"execute_command", {"command": "rm -rf /tmp/x"}
|
||||
)
|
||||
)
|
||||
assert isinstance(blocked, dict)
|
||||
assert blocked.get("permission_denied") is True
|
||||
|
||||
def test_name_level_deny_still_blocks_all(self, tmp_path):
|
||||
"""Backward compat: a bare name rule keeps its all-or-nothing behaviour."""
|
||||
agent = self._agent_with_rule(tmp_path, "execute_command")
|
||||
for command in ("rm -rf /tmp/x", "ls -la"):
|
||||
result = agent._check_tool_approval_sync(
|
||||
"execute_command", {"command": command}
|
||||
)
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("permission_denied") is True
|
||||
|
||||
def test_malformed_argument_key_cannot_evade_scoped_deny(self, tmp_path):
|
||||
"""A trailing-'=' kwarg (``command=``) normalises before dispatch, so the
|
||||
deny gate must normalise it too — otherwise the scoped rule is bypassed
|
||||
while the command still runs after ``_cast_arguments`` cleans the key."""
|
||||
agent = self._agent_with_rule(tmp_path, "bash:rm *")
|
||||
blocked = agent._check_tool_approval_sync(
|
||||
"execute_command", {"command=": "rm -rf /tmp/x"}
|
||||
)
|
||||
assert isinstance(blocked, dict)
|
||||
assert blocked.get("permission_denied") is True
|
||||
|
||||
def test_modified_args_rechecked_against_scoped_deny(self, tmp_path):
|
||||
"""An approval backend rewriting args into a denied command must be
|
||||
re-authorized — the first gate only saw the original (allowed) args."""
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
|
||||
agent = self._agent_with_rule(tmp_path, "bash:rm *")
|
||||
with patch("praisonaiagents.approval.get_approval_registry") as get_reg:
|
||||
reg = MagicMock()
|
||||
reg.mark_approved = MagicMock()
|
||||
get_reg.return_value = reg
|
||||
with patch.object(
|
||||
agent, "_resolve_approval_decision",
|
||||
return_value=ApprovalDecision(
|
||||
approved=True, reason="mock",
|
||||
modified_args={"command": "rm -rf /tmp/x"},
|
||||
),
|
||||
):
|
||||
result = agent._check_tool_approval_sync(
|
||||
"execute_command", {"command": "ls -la"}
|
||||
)
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("permission_denied") is True
|
||||
|
||||
def test_modified_args_rechecked_async(self, tmp_path):
|
||||
import asyncio
|
||||
from praisonaiagents.approval.protocols import ApprovalDecision
|
||||
|
||||
agent = self._agent_with_rule(tmp_path, "bash:rm *")
|
||||
|
||||
async def _run():
|
||||
with patch("praisonaiagents.approval.get_approval_registry") as get_reg:
|
||||
reg = MagicMock()
|
||||
reg.mark_approved = MagicMock()
|
||||
get_reg.return_value = reg
|
||||
with patch.object(
|
||||
agent, "_resolve_approval_decision"
|
||||
) as resolve:
|
||||
async def _decide(*a, **k):
|
||||
return ApprovalDecision(
|
||||
approved=True, reason="mock",
|
||||
modified_args={"command": "rm -rf /tmp/x"},
|
||||
)
|
||||
resolve.side_effect = _decide
|
||||
return await agent._check_tool_approval_async(
|
||||
"execute_command", {"command": "ls -la"}
|
||||
)
|
||||
|
||||
result = asyncio.run(_run())
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("permission_denied") is True
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Unit tests for provider-quota coordination (cross-replica credential cooldowns).
|
||||
|
||||
Covers:
|
||||
- LocalQuotaCoordinator TTL/bench semantics.
|
||||
- QuotaCoordinatorConfig round-trip and build helper.
|
||||
- FailoverManager sharing benches through a coordinator so a key benched by
|
||||
one "replica" (manager) is skipped by another sharing the same coordinator.
|
||||
- Backward-compatible default (no coordinator supplied) behaves as before.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from praisonaiagents.llm.quota import (
|
||||
LocalQuotaCoordinator,
|
||||
QuotaCoordinatorConfig,
|
||||
QuotaCoordinatorProtocol,
|
||||
build_quota_coordinator,
|
||||
)
|
||||
from praisonaiagents.llm.failover import (
|
||||
AuthProfile,
|
||||
FailoverConfig,
|
||||
FailoverManager,
|
||||
ProviderStatus,
|
||||
)
|
||||
|
||||
|
||||
class TestLocalQuotaCoordinator:
|
||||
def test_satisfies_protocol(self):
|
||||
coord = LocalQuotaCoordinator()
|
||||
assert isinstance(coord, QuotaCoordinatorProtocol)
|
||||
|
||||
def test_bench_and_is_benched(self):
|
||||
coord = LocalQuotaCoordinator()
|
||||
now = 1000.0
|
||||
coord.bench("key-a", until=now + 60, reason="429")
|
||||
assert coord.is_benched("key-a", now=now) is True
|
||||
assert coord.benched_until("key-a", now=now) == now + 60
|
||||
|
||||
def test_bench_expires(self):
|
||||
coord = LocalQuotaCoordinator()
|
||||
now = 1000.0
|
||||
coord.bench("key-a", until=now + 10, reason="429")
|
||||
assert coord.is_benched("key-a", now=now + 5) is True
|
||||
# After TTL, self-clears.
|
||||
assert coord.is_benched("key-a", now=now + 20) is False
|
||||
assert coord.benched_until("key-a", now=now + 20) is None
|
||||
|
||||
def test_bench_keeps_longest(self):
|
||||
coord = LocalQuotaCoordinator()
|
||||
now = 1000.0
|
||||
coord.bench("key-a", until=now + 30)
|
||||
coord.bench("key-a", until=now + 10) # shorter, ignored
|
||||
assert coord.benched_until("key-a", now=now) == now + 30
|
||||
|
||||
def test_clear(self):
|
||||
coord = LocalQuotaCoordinator()
|
||||
now = 1000.0
|
||||
coord.bench("key-a", until=now + 60)
|
||||
coord.clear("key-a")
|
||||
assert coord.is_benched("key-a", now=now) is False
|
||||
|
||||
def test_unknown_key(self):
|
||||
coord = LocalQuotaCoordinator()
|
||||
assert coord.is_benched("missing") is False
|
||||
assert coord.benched_until("missing") is None
|
||||
|
||||
|
||||
class TestQuotaCoordinatorConfig:
|
||||
def test_defaults_local(self):
|
||||
cfg = QuotaCoordinatorConfig()
|
||||
assert cfg.backend == "local"
|
||||
assert cfg.url is None
|
||||
|
||||
def test_round_trip(self):
|
||||
cfg = QuotaCoordinatorConfig(backend="redis", url="redis://x")
|
||||
assert QuotaCoordinatorConfig.from_dict(cfg.to_dict()) == cfg
|
||||
|
||||
def test_build_defaults_local(self):
|
||||
coord = build_quota_coordinator()
|
||||
assert isinstance(coord, LocalQuotaCoordinator)
|
||||
|
||||
def test_build_unknown_backend_falls_open_to_local(self):
|
||||
coord = build_quota_coordinator(QuotaCoordinatorConfig(backend="redis"))
|
||||
assert isinstance(coord, LocalQuotaCoordinator)
|
||||
|
||||
|
||||
class TestFailoverCoordinatorWiring:
|
||||
def test_default_no_coordinator_unchanged(self):
|
||||
# No coordinator supplied -> a local one is created; behaviour identical.
|
||||
mgr = FailoverManager()
|
||||
p = AuthProfile(name="p1", provider="openai", api_key="k")
|
||||
mgr.add_profile(p)
|
||||
assert mgr.get_next_profile() is p
|
||||
|
||||
def test_bench_shared_across_managers(self):
|
||||
# Two managers = two "replicas" sharing one coordinator.
|
||||
coord = LocalQuotaCoordinator()
|
||||
cfg = FailoverConfig(cooldown_on_rate_limit=60.0)
|
||||
|
||||
mgr_a = FailoverManager(config=cfg, coordinator=coord)
|
||||
mgr_b = FailoverManager(config=cfg, coordinator=coord)
|
||||
|
||||
pa = AuthProfile(name="shared-key", provider="openai", api_key="k")
|
||||
pb = AuthProfile(name="shared-key", provider="openai", api_key="k")
|
||||
mgr_a.add_profile(pa)
|
||||
mgr_b.add_profile(pb)
|
||||
|
||||
# Replica A benches the key on a 429.
|
||||
mgr_a.mark_failure(pa, "429", is_rate_limit=True)
|
||||
|
||||
# Replica B, which never saw the failure, must now skip the same key.
|
||||
# Benches are keyed by the stable credential_id, not the profile label.
|
||||
cred_id = pa.credential_id
|
||||
assert cred_id == pb.credential_id
|
||||
assert coord.is_benched(cred_id) is True
|
||||
assert pb.is_available is True # not yet synced
|
||||
# get_next_profile syncs the fleet-wide bench onto B's local profile.
|
||||
assert mgr_b.get_next_profile() is pb # only profile, returned as best effort
|
||||
assert pb.status == ProviderStatus.RATE_LIMITED
|
||||
assert pb.is_available is False
|
||||
|
||||
def test_same_name_distinct_keys_do_not_collide(self):
|
||||
# Two different credentials that share a display name ("default") must
|
||||
# NOT bench each other: identity is derived from the key, not the label.
|
||||
coord = LocalQuotaCoordinator()
|
||||
cfg = FailoverConfig(cooldown_on_rate_limit=60.0)
|
||||
mgr = FailoverManager(config=cfg, coordinator=coord)
|
||||
|
||||
p1 = AuthProfile(name="default", provider="openai", api_key="key-1", priority=0)
|
||||
p2 = AuthProfile(name="default", provider="openai", api_key="key-2", priority=1)
|
||||
mgr.add_profile(p1)
|
||||
mgr.add_profile(p2)
|
||||
|
||||
assert p1.credential_id != p2.credential_id
|
||||
|
||||
mgr.mark_failure(p1, "429", is_rate_limit=True)
|
||||
# Benching credential 1 must leave credential 2 available.
|
||||
assert coord.is_benched(p1.credential_id) is True
|
||||
assert coord.is_benched(p2.credential_id) is False
|
||||
assert mgr.get_next_profile() is p2
|
||||
|
||||
def test_stale_success_does_not_clear_newer_bench(self):
|
||||
# A stale in-flight success must not clear a newer bench recorded by a
|
||||
# concurrent failure with a later expiry.
|
||||
coord = LocalQuotaCoordinator()
|
||||
cfg = FailoverConfig(cooldown_on_rate_limit=60.0)
|
||||
mgr = FailoverManager(config=cfg, coordinator=coord)
|
||||
p = AuthProfile(name="k1", provider="openai", api_key="k")
|
||||
mgr.add_profile(p)
|
||||
|
||||
# Simulate the profile recovering from an OLD short cooldown while a
|
||||
# NEWER, longer bench is present in the coordinator (set by a concurrent
|
||||
# failure on another request/replica).
|
||||
p.mark_rate_limited(1.0) # recovered_from ~ now+1
|
||||
newer = time.time() + 300
|
||||
coord.bench(p.credential_id, until=newer)
|
||||
|
||||
mgr.mark_success(p)
|
||||
# The newer bench must survive the stale success.
|
||||
assert coord.is_benched(p.credential_id) is True
|
||||
|
||||
def test_success_clears_own_bench(self):
|
||||
# A genuine recovery (no newer bench) still clears the shared bench.
|
||||
coord = LocalQuotaCoordinator()
|
||||
mgr = FailoverManager(coordinator=coord)
|
||||
p = AuthProfile(name="k1", provider="openai", api_key="k")
|
||||
mgr.add_profile(p)
|
||||
|
||||
mgr.mark_failure(p, "429", is_rate_limit=True)
|
||||
assert coord.is_benched(p.credential_id) is True
|
||||
mgr.mark_success(p)
|
||||
assert coord.is_benched(p.credential_id) is False
|
||||
|
||||
def test_recovery_clears_shared_bench(self):
|
||||
coord = LocalQuotaCoordinator()
|
||||
mgr = FailoverManager(coordinator=coord)
|
||||
p = AuthProfile(name="k1", provider="openai", api_key="k")
|
||||
mgr.add_profile(p)
|
||||
|
||||
mgr.mark_failure(p, "429", is_rate_limit=True)
|
||||
assert coord.is_benched(p.credential_id) is True
|
||||
|
||||
mgr.mark_success(p)
|
||||
assert coord.is_benched(p.credential_id) is False
|
||||
|
||||
def test_coordinator_failure_is_fail_open(self):
|
||||
class BrokenCoordinator:
|
||||
def bench(self, *a, **k):
|
||||
raise RuntimeError("backend down")
|
||||
|
||||
def is_benched(self, *a, **k):
|
||||
raise RuntimeError("backend down")
|
||||
|
||||
def benched_until(self, *a, **k):
|
||||
raise RuntimeError("backend down")
|
||||
|
||||
def clear(self, *a, **k):
|
||||
raise RuntimeError("backend down")
|
||||
|
||||
mgr = FailoverManager(coordinator=BrokenCoordinator())
|
||||
p = AuthProfile(name="k1", provider="openai", api_key="k")
|
||||
mgr.add_profile(p)
|
||||
|
||||
# Must not raise despite the broken backend; local cooldown still applies.
|
||||
mgr.mark_failure(p, "429", is_rate_limit=True)
|
||||
assert p.status == ProviderStatus.RATE_LIMITED
|
||||
# get_next_profile also degrades gracefully.
|
||||
assert mgr.get_next_profile() is p
|
||||
Loaded 100 of 354 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user