mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-12 18:47:44 -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
+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,21 +18,21 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 22 | 53 | 17053 |
|
||||
| ✅ 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 | 2790 |
|
||||
| ✅ Bots | 7 | 31 | 13411 |
|
||||
| ✅ Bots | 7 | 32 | 13616 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 122 | 36734 |
|
||||
| ✅ CLI | 5 | 122 | 36991 |
|
||||
| ✅ Chunking | 2 | 2 | 420 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 2 | 14 | 4855 |
|
||||
| ✅ Conditions | 1 | 3 | 1347 |
|
||||
| ✅ Configuration | 3 | 8 | 3139 |
|
||||
| ✅ Configuration | 3 | 8 | 3173 |
|
||||
| ✅ Context Management | 16 | 36 | 13988 |
|
||||
| ✅ Deep Research | 8 | 2 | 587 |
|
||||
| ✅ Display | 6 | 3 | 741 |
|
||||
@@ -43,16 +43,16 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| ✅ Failover | 2 | 1 | 452 |
|
||||
| ✅ Files | 2 | 7 | 2275 |
|
||||
| ✅ Flow | 1 | 3 | 788 |
|
||||
| ✅ Gateway | 7 | 81 | 29955 |
|
||||
| ✅ Guardrails | 4 | 4 | 2205 |
|
||||
| ✅ Gateway | 7 | 82 | 30269 |
|
||||
| ✅ Guardrails | 4 | 4 | 2228 |
|
||||
| ✅ Handoffs | 11 | 6 | 2626 |
|
||||
| ✅ Hooks | 2 | 9 | 4509 |
|
||||
| ✅ Image | 1 | 11 | 1255 |
|
||||
| ✅ Knowledge | 4 | 16 | 4942 |
|
||||
| ✅ Knowledge | 4 | 16 | 4967 |
|
||||
| ✅ LLM | 3 | 15 | 5372 |
|
||||
| ✅ Loops | 4 | 5 | 1475 |
|
||||
| ✅ MCP | 1 | 61 | 14135 |
|
||||
| ✅ Memory | 6 | 18 | 7187 |
|
||||
| ✅ MCP | 1 | 61 | 14160 |
|
||||
| ✅ Memory | 6 | 18 | 7193 |
|
||||
| ✅ OCR | 2 | 1 | 237 |
|
||||
| ✅ Observability | 2 | 23 | 2811 |
|
||||
| ✅ Optimizer | 1 | 2 | 792 |
|
||||
@@ -66,16 +66,16 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| ✅ RAG | 5 | 15 | 3118 |
|
||||
| ✅ Realtime | 2 | 5 | 668 |
|
||||
| ✅ Reflection | 3 | 3 | 730 |
|
||||
| ✅ Retrieval | 2 | 5 | 1054 |
|
||||
| ✅ Retrieval | 2 | 5 | 1042 |
|
||||
| ✅ Routing | 1 | 2 | 430 |
|
||||
| ✅ Sandbox | 5 | 7 | 3483 |
|
||||
| ✅ Security | 1 | 3 | 3482 |
|
||||
| ✅ Sessions | 4 | 19 | 7631 |
|
||||
| ✅ Sandbox | 5 | 7 | 3556 |
|
||||
| ✅ Security | 1 | 3 | 3529 |
|
||||
| ✅ Sessions | 4 | 19 | 7644 |
|
||||
| ✅ Skills | 6 | 15 | 5638 |
|
||||
| ✅ Tasks | 2 | 6 | 2897 |
|
||||
| ✅ Telemetry | 1 | 2 | 648 |
|
||||
| ✅ Templates | 1 | 8 | 1663 |
|
||||
| ✅ Tools | 12 | 144 | 43113 |
|
||||
| ✅ Tools | 12 | 144 | 43251 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Vector Store | 1 | 12 | 1215 |
|
||||
| ✅ Video | 2 | 6 | 649 |
|
||||
@@ -87,7 +87,7 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
|
||||
These docs exist but don't match any implemented feature category:
|
||||
|
||||
- ℹ️ Database (42 docs, 6959 lines)
|
||||
- ℹ️ Database (42 docs, 6965 lines)
|
||||
- ℹ️ Documents (1 docs, 787 lines)
|
||||
|
||||
---
|
||||
|
||||
@@ -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"])
|
||||
@@ -2574,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)
|
||||
@@ -2582,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
|
||||
@@ -5833,6 +5800,14 @@ Summary:"""
|
||||
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"))
|
||||
@@ -6431,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
|
||||
@@ -6648,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
|
||||
@@ -6689,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 ""
|
||||
@@ -6864,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)
|
||||
@@ -6874,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:
|
||||
@@ -6930,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
|
||||
@@ -7442,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)
|
||||
|
||||
|
||||
@@ -1744,7 +1744,7 @@ Your Goal: {self.goal}"""
|
||||
return max_retries
|
||||
return 2
|
||||
|
||||
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):
|
||||
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) ---
|
||||
@@ -1849,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,
|
||||
@@ -1892,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,
|
||||
@@ -2043,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}")
|
||||
@@ -2089,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
|
||||
@@ -2106,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
|
||||
@@ -2345,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.
|
||||
@@ -2377,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),
|
||||
@@ -2422,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),
|
||||
@@ -3012,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,
|
||||
@@ -3383,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)
|
||||
@@ -3483,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)
|
||||
@@ -3502,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)
|
||||
@@ -3523,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)
|
||||
@@ -3534,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)
|
||||
@@ -3558,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")
|
||||
@@ -3600,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})
|
||||
@@ -3620,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)
|
||||
@@ -3636,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
|
||||
@@ -3656,7 +3682,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
_get_display_functions()['display_self_reflection']("Maximum reflection count reached after repeated parse errors, returning current response", console=self.console)
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
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:
|
||||
@@ -3729,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}")
|
||||
@@ -4975,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:
|
||||
@@ -5068,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:
|
||||
@@ -5368,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=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):
|
||||
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
|
||||
@@ -5392,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
|
||||
@@ -5462,6 +5658,62 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1958,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
|
||||
@@ -1966,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:
|
||||
@@ -2012,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
|
||||
|
||||
@@ -2032,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(
|
||||
@@ -2050,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
|
||||
|
||||
@@ -2071,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(
|
||||
@@ -2996,7 +3043,7 @@ class ToolExecutionMixin:
|
||||
else:
|
||||
return f"Unknown bridge tool: {function_name}"
|
||||
|
||||
def _tool_declares_not_idempotent(self, tool_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
|
||||
@@ -3008,10 +3055,19 @@ class ToolExecutionMixin:
|
||||
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:
|
||||
|
||||
@@ -1546,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(
|
||||
@@ -1568,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)))
|
||||
@@ -1608,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
|
||||
@@ -1618,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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -6275,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2410,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:
|
||||
|
||||
@@ -1549,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:
|
||||
"""
|
||||
@@ -2294,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:
|
||||
|
||||
@@ -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":
|
||||
@@ -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":
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -3259,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(
|
||||
@@ -4710,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,
|
||||
@@ -4727,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:
|
||||
@@ -4783,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
|
||||
@@ -4844,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,
|
||||
@@ -4967,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:
|
||||
@@ -5024,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
|
||||
@@ -5094,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,
|
||||
|
||||
@@ -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,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,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"))
|
||||
@@ -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,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
|
||||
@@ -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,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
|
||||
@@ -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,100 @@
|
||||
"""Async twin of test_non_idempotent_tool_not_retried.py.
|
||||
|
||||
`@tool(restart_safe=False)` is the documented contract for "an effectful tool
|
||||
that must never be silently re-executed" (tools/decorator.py:300-307). The sync
|
||||
retry loops honour it; `_execute_tool_async_with_retry` never consulted the
|
||||
declaration, so an async agent re-ran the tool body up to `max_attempts` times.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import ToolConfig
|
||||
from praisonaiagents.errors import ToolExecutionError
|
||||
from praisonaiagents.tools.retry import RetryPolicy
|
||||
|
||||
# The effectful tool raises ``RuntimeError``. The async path flattens it into an
|
||||
# error dict (nothing surfaces), while the sync path re-raises it wrapped in a
|
||||
# ``ToolExecutionError``. Catch *only* those two expected failures so an unrelated
|
||||
# setup error can never masquerade as a valid execution count.
|
||||
EXPECTED_FAILURE = (RuntimeError, ToolExecutionError)
|
||||
|
||||
ATTEMPTS = 3
|
||||
FAST = ToolConfig(retry_policy=RetryPolicy(max_attempts=ATTEMPTS, initial_delay_ms=0))
|
||||
|
||||
|
||||
def _charges(declare, is_async, attr="restart_safe"):
|
||||
"""Run a failing effectful tool once; return how many times the body ran."""
|
||||
charged = []
|
||||
|
||||
def charge_card(amount: str) -> str:
|
||||
"""Charge a customer's card."""
|
||||
charged.append(amount)
|
||||
raise RuntimeError("gateway 502 from acquirer")
|
||||
|
||||
if declare is not None:
|
||||
setattr(charge_card, attr, declare)
|
||||
|
||||
agent = Agent(instructions="x", tools=[charge_card], tool_config=FAST)
|
||||
try:
|
||||
if is_async:
|
||||
asyncio.run(agent.execute_tool_async("charge_card", {"amount": "100"}))
|
||||
else:
|
||||
agent.execute_tool("charge_card", {"amount": "100"})
|
||||
except EXPECTED_FAILURE:
|
||||
pass
|
||||
return len(charged)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("attr", ["restart_safe", "idempotent"])
|
||||
def test_async_honours_the_declaration(attr):
|
||||
assert _charges(False, is_async=True, attr=attr) == 1, (
|
||||
f"a tool declared {attr}=False was re-run on the async path"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("attr", ["restart_safe", "idempotent"])
|
||||
def test_async_matches_sync(attr):
|
||||
assert _charges(False, is_async=True, attr=attr) == _charges(False, is_async=False, attr=attr)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("declare", [None, True])
|
||||
def test_undeclared_and_safe_tools_still_retry_on_async(declare):
|
||||
"""Controls: the veto must not blanket-disable async retry."""
|
||||
assert _charges(declare, is_async=True) == ATTEMPTS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("declare", [None, True])
|
||||
def test_sync_controls_unchanged(declare):
|
||||
assert _charges(declare, is_async=False) == ATTEMPTS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("attr", ["restart_safe", "idempotent"])
|
||||
def test_async_honours_declaration_on_override_only_tool(attr):
|
||||
"""A task-scoped tool supplied *only* via ``tools_override`` (never registered
|
||||
on the agent) must still be vetoed. ``_execute_tool_async_impl`` resolves it
|
||||
from the override list, so the idempotency check has to see it there too --
|
||||
otherwise the effectful body is retried ``max_attempts`` times.
|
||||
"""
|
||||
charged = []
|
||||
|
||||
def charge_card(amount: str) -> str:
|
||||
"""Charge a customer's card."""
|
||||
charged.append(amount)
|
||||
raise RuntimeError("gateway 502 from acquirer")
|
||||
|
||||
setattr(charge_card, attr, False)
|
||||
|
||||
# Agent has NO tools of its own; the effectful tool arrives only at call time.
|
||||
agent = Agent(instructions="x", tools=[], tool_config=FAST)
|
||||
try:
|
||||
asyncio.run(agent.execute_tool_async(
|
||||
"charge_card", {"amount": "100"}, tools_override=[charge_card]
|
||||
))
|
||||
except EXPECTED_FAILURE:
|
||||
pass
|
||||
|
||||
assert len(charged) == 1, (
|
||||
f"an override-only tool declared {attr}=False was re-run on the async path"
|
||||
)
|
||||
@@ -17,11 +17,44 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Coroutine, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AckScope(str, Enum):
|
||||
"""Scope gate for ack reactions.
|
||||
|
||||
Controls which inbound messages get an ack reaction so busy groups
|
||||
aren't spammed with ⏳ on ambient chatter the bot never answers.
|
||||
|
||||
- ``off``: never ack.
|
||||
- ``direct``: ack direct/DM messages only.
|
||||
- ``group_mentions``: ack DMs plus group messages that mention/reply the
|
||||
bot (default). Ambient group chatter is not acked.
|
||||
- ``group_all``: ack DMs plus any non-ambient group message.
|
||||
- ``all``: ack every message (legacy all-or-nothing behaviour).
|
||||
"""
|
||||
off = "off"
|
||||
direct = "direct"
|
||||
group_mentions = "group-mentions"
|
||||
group_all = "group-all"
|
||||
all = "all"
|
||||
|
||||
@classmethod
|
||||
def coerce(cls, value: Any, default: "AckScope") -> "AckScope":
|
||||
"""Best-effort parse from a string/enum, falling back to ``default``."""
|
||||
if isinstance(value, cls):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower().replace("_", "-")
|
||||
for member in cls:
|
||||
if member.value == normalized:
|
||||
return member
|
||||
return default
|
||||
|
||||
|
||||
@dataclass
|
||||
class AckContext:
|
||||
"""Tracks state for a single ack-reaction lifecycle."""
|
||||
@@ -35,15 +68,60 @@ class AckReactor:
|
||||
Args:
|
||||
ack_emoji: Emoji to react with on message receipt (empty = disabled).
|
||||
done_emoji: Emoji to react with on completion (default "✅").
|
||||
scope: Which messages to ack (see :class:`AckScope`). Defaults to
|
||||
``group-mentions`` so ambient group chatter is not acked.
|
||||
"""
|
||||
|
||||
def __init__(self, ack_emoji: str = "", done_emoji: str = "✅") -> None:
|
||||
def __init__(
|
||||
self,
|
||||
ack_emoji: str = "",
|
||||
done_emoji: str = "✅",
|
||||
scope: Any = AckScope.group_mentions,
|
||||
) -> None:
|
||||
self._ack_emoji = ack_emoji
|
||||
self._done_emoji = done_emoji
|
||||
self._scope = AckScope.coerce(scope, AckScope.group_mentions)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self._ack_emoji)
|
||||
return bool(self._ack_emoji) and self._scope is not AckScope.off
|
||||
|
||||
@property
|
||||
def scope(self) -> AckScope:
|
||||
return self._scope
|
||||
|
||||
def should_ack(
|
||||
self, *, is_direct: bool, is_mention: bool, is_ambient: bool
|
||||
) -> bool:
|
||||
"""Whether a message matching this addressing context should be acked."""
|
||||
if not self._ack_emoji or self._scope is AckScope.off:
|
||||
return False
|
||||
if self._scope is AckScope.all:
|
||||
return True
|
||||
if self._scope is AckScope.direct:
|
||||
return is_direct
|
||||
if self._scope is AckScope.group_mentions:
|
||||
return is_direct or is_mention
|
||||
if self._scope is AckScope.group_all:
|
||||
return is_direct or not is_ambient
|
||||
return False
|
||||
|
||||
def should_ack_message(self, message: Any, *, is_mention: bool = False) -> bool:
|
||||
"""Derive addressing context from a ``BotMessage`` and gate the ack.
|
||||
|
||||
Direct-vs-group is read from ``message.channel.channel_type``; a
|
||||
mention/reply is either passed explicitly by the adapter or inferred
|
||||
from ``message.reply_to``. Ambient == a group message that neither
|
||||
mentions nor replies the bot.
|
||||
"""
|
||||
channel = getattr(message, "channel", None)
|
||||
channel_type = getattr(channel, "channel_type", "") if channel else ""
|
||||
is_direct = str(channel_type).lower() in ("dm", "private", "direct", "")
|
||||
mentioned = bool(is_mention) or bool(getattr(message, "reply_to", None))
|
||||
is_ambient = not is_direct and not mentioned
|
||||
return self.should_ack(
|
||||
is_direct=is_direct, is_mention=mentioned, is_ambient=is_ambient
|
||||
)
|
||||
|
||||
async def ack(
|
||||
self,
|
||||
|
||||
@@ -60,6 +60,7 @@ def _serialize_request(request: ApprovalRequest) -> str:
|
||||
"session_id": request.session_id,
|
||||
"context": request.context,
|
||||
"approval_id": request.approval_id,
|
||||
"authorized_reviewers": request.authorized_reviewers,
|
||||
},
|
||||
default=str,
|
||||
)
|
||||
@@ -75,6 +76,7 @@ def _deserialize_request(raw: str) -> ApprovalRequest:
|
||||
session_id=data.get("session_id"),
|
||||
context=data.get("context", {}),
|
||||
approval_id=data.get("approval_id"),
|
||||
authorized_reviewers=data.get("authorized_reviewers"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -107,12 +107,105 @@ def chunk_message(
|
||||
return chunks if chunks else [""]
|
||||
|
||||
|
||||
def enforce_hard_cap(
|
||||
chunks: List[str],
|
||||
max_length: int,
|
||||
length_unit: str = "codepoints",
|
||||
) -> List[str]:
|
||||
"""Guarantee every chunk fits within *max_length*.
|
||||
|
||||
``chunk_message`` intentionally emits an over-cap chunk when a single code
|
||||
fence exceeds *max_length* (``preserve_fences``). No platform client splits
|
||||
an oversized payload, so sending such a chunk raises "message too long" and
|
||||
the whole reply is lost. Callers apply this final pass so delivery always
|
||||
succeeds: any over-cap chunk is hard-split, and a split fence has its
|
||||
``` markers re-balanced so each piece stays valid markdown (Issue #4319).
|
||||
|
||||
Returns:
|
||||
A list of chunks each at most *max_length* long.
|
||||
"""
|
||||
result: List[str] = []
|
||||
for chunk in chunks:
|
||||
if _calculate_length(chunk, length_unit) <= max_length:
|
||||
result.append(chunk)
|
||||
continue
|
||||
|
||||
if _is_pure_fence_block(chunk):
|
||||
result.extend(_split_fence_block(chunk, max_length, length_unit))
|
||||
else:
|
||||
result.extend(_split_long_paragraph(chunk, max_length, length_unit))
|
||||
return result
|
||||
|
||||
|
||||
def _split_fence_block(
|
||||
text: str, max_length: int, length_unit: str = "codepoints"
|
||||
) -> List[str]:
|
||||
"""Hard-split an over-cap code fence, re-balancing ``` on each piece.
|
||||
|
||||
Only ever called on a *pure* fence block (see ``_is_pure_fence_block``):
|
||||
a single opening fence, an optional trailing close, and no interior fence
|
||||
markers. Wrapping the body in a fresh fence per piece is therefore safe and
|
||||
never turns prose into code (Issue #4319).
|
||||
"""
|
||||
stripped = text.strip()
|
||||
lines = stripped.split("\n")
|
||||
# Opening fence line carries the optional language hint (e.g. ```python).
|
||||
fence_open = lines[0] if lines and lines[0].startswith("```") else "```"
|
||||
fence_lang = fence_open[3:].strip()
|
||||
reopen = "```" + fence_lang if fence_lang else "```"
|
||||
|
||||
# Body excludes the opening fence and a trailing closing fence if present.
|
||||
body_lines = lines[1:]
|
||||
if body_lines and body_lines[-1].strip() == "```":
|
||||
body_lines = body_lines[:-1]
|
||||
body = "\n".join(body_lines)
|
||||
|
||||
# Reserve room for the wrapping fences on each emitted piece.
|
||||
wrapper = _calculate_length(reopen + "\n" + "\n```", length_unit)
|
||||
inner_cap = max(1, max_length - wrapper)
|
||||
|
||||
pieces = _split_long_paragraph(body, inner_cap, length_unit)
|
||||
return [f"{reopen}\n{piece}\n```" for piece in pieces if piece]
|
||||
|
||||
|
||||
def _is_fence_block(text: str) -> bool:
|
||||
"""Check if text is (or contains) a code fence block."""
|
||||
"""Check if text starts with a code fence marker."""
|
||||
stripped = text.strip()
|
||||
return stripped.startswith("```")
|
||||
|
||||
|
||||
def _is_pure_fence_block(text: str) -> bool:
|
||||
"""True only for a single, self-contained code fence.
|
||||
|
||||
A pure fence opens with ``` on its first line and contains no further fence
|
||||
markers except an optional closing ``` on the last line. Mixed content —
|
||||
a closed fence followed by prose, or multiple fences — is NOT pure: wrapping
|
||||
it in a fresh fence would render prose as code and corrupt the reply. Such
|
||||
chunks are hard-split as plain text instead (Issue #4319).
|
||||
"""
|
||||
stripped = text.strip()
|
||||
if not stripped.startswith("```"):
|
||||
return False
|
||||
|
||||
lines = stripped.split("\n")
|
||||
# Count lines that are exactly a fence marker (``` optionally + language).
|
||||
fence_marker_lines = [
|
||||
i for i, line in enumerate(lines) if line.strip().startswith("```")
|
||||
]
|
||||
|
||||
# Opening fence must be the first line.
|
||||
if not fence_marker_lines or fence_marker_lines[0] != 0:
|
||||
return False
|
||||
|
||||
# Allowed: just the opener (unterminated fence), or opener + a closing
|
||||
# marker on the final line. Anything else means interior/extra fences.
|
||||
if len(fence_marker_lines) == 1:
|
||||
return True
|
||||
if len(fence_marker_lines) == 2 and fence_marker_lines[1] == len(lines) - 1:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _split_long_paragraph(text: str, max_length: int, length_unit: str = "codepoints") -> List[str]:
|
||||
"""Split a long paragraph at sentence boundaries, then hard-split."""
|
||||
# Try sentence boundaries first
|
||||
|
||||
@@ -128,7 +128,6 @@ class CommandRegistry:
|
||||
self.register("new", {"description": "Reset conversation session", "builtin": True})
|
||||
self.register("stop", {"description": "Cancel current agent task", "builtin": True})
|
||||
self.register("whoami", {"description": "Show your user info and permissions", "builtin": True})
|
||||
self.register("sethome", {"description": "Set this chat as the home channel for scheduled deliveries", "builtin": True})
|
||||
# New runtime control commands
|
||||
self.register("model", {"description": "Switch LLM model for this session", "builtin": True})
|
||||
self.register("usage", {"description": "Show token usage and estimated cost", "builtin": True})
|
||||
|
||||
@@ -3,9 +3,10 @@ Durable inbound webhook/trigger idempotency store for the PraisonAI gateway.
|
||||
|
||||
The gateway's inbound HTTP hook surface deduplicates provider redeliveries so a
|
||||
webhook that re-POSTs an already-processed event does not start a second agent
|
||||
run (a duplicate reply, or a duplicate tool action). The core in-memory default
|
||||
run (a duplicate reply, or a duplicate tool action). The core in-memory store
|
||||
(``InMemoryIdempotencyStore``) is per-process and empty after a restart, so this
|
||||
SQLite-backed store persists the dedup key — mirroring the durability the
|
||||
SQLite-backed store — selected by default (Issue #4339) — persists the dedup key,
|
||||
mirroring the durability the
|
||||
``InboundJournal`` and ``OutboundQueue`` already provide — so the dedup window
|
||||
survives a restart within a provider's retry window and, when the same DB file
|
||||
is shared, across replicas.
|
||||
@@ -191,7 +192,7 @@ class SqliteIdempotencyStore:
|
||||
|
||||
|
||||
def build_idempotency_store(
|
||||
backend: str,
|
||||
backend: Union[str, None] = None,
|
||||
*,
|
||||
path: Union[str, Path, None] = None,
|
||||
max_size: int = _DEFAULT_MAX_SIZE,
|
||||
@@ -200,10 +201,15 @@ def build_idempotency_store(
|
||||
):
|
||||
"""Build an idempotency store for the given ``backend``.
|
||||
|
||||
``"memory"`` (default) returns the core in-memory store; ``"sqlite"``
|
||||
returns the durable :class:`SqliteIdempotencyStore`. On any failure to build
|
||||
the durable store, falls back to the in-memory default so inbound delivery
|
||||
keeps working (dedup is best-effort within the process, as before).
|
||||
Durable by default (Issue #4339): when ``backend`` is unset (``None``) a
|
||||
durable :class:`SqliteIdempotencyStore` is chosen so an out-of-box gateway
|
||||
survives a restart within a provider's retry window rather than silently
|
||||
re-processing a redelivered webhook. ``"memory"`` stays available as an
|
||||
**explicit** opt-in for tests / ephemeral runs, and ``"sqlite"`` forces the
|
||||
durable store. If the durable store genuinely cannot be initialised, this
|
||||
records a *durability-degraded* fact (surfaced by ``gateway doctor`` /
|
||||
``gateway status``) and falls back to in-memory so inbound delivery keeps
|
||||
working — degradation is reported, not silent.
|
||||
"""
|
||||
from praisonaiagents.gateway import InMemoryIdempotencyStore
|
||||
|
||||
@@ -212,26 +218,47 @@ def build_idempotency_store(
|
||||
max_entries=max_size, ttl_seconds=ttl_seconds
|
||||
)
|
||||
|
||||
def _default_path() -> Path:
|
||||
return Path.home() / ".praisonai" / "state" / "hook_idempotency.sqlite"
|
||||
|
||||
# ``None`` means "no explicit choice" → durable by default. ``"memory"``
|
||||
# must be asked for; only then do we run non-durably by intent.
|
||||
if backend is None:
|
||||
backend = "sqlite"
|
||||
backend = (backend or "memory").lower()
|
||||
if backend == "memory":
|
||||
return _memory()
|
||||
if backend == "sqlite":
|
||||
try:
|
||||
store_path = path or (
|
||||
Path.home() / ".praisonai" / "state" / "hook_idempotency.sqlite"
|
||||
)
|
||||
return SqliteIdempotencyStore(
|
||||
store_path = Path(path) if path else _default_path()
|
||||
store = SqliteIdempotencyStore(
|
||||
store_path,
|
||||
max_size=max_size,
|
||||
ttl_seconds=ttl_seconds,
|
||||
inflight_lease_seconds=inflight_lease_seconds,
|
||||
)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
# The durable store came up (fresh start, or a recovery after a
|
||||
# prior failure/hot-reload): clear any stale degraded fact so a
|
||||
# recovered gateway stops reporting non-durable idempotency (#4339).
|
||||
from ._session import clear_durability_degraded
|
||||
|
||||
clear_durability_degraded("idempotency")
|
||||
return store
|
||||
except Exception as e:
|
||||
# Keep the raw exception (which may embed a filesystem path) in the
|
||||
# log only; the operator-facing reason stays redacted so health()
|
||||
# and ``gateway status`` never echo backend paths (#4339).
|
||||
logger.warning(
|
||||
"SqliteIdempotencyStore unavailable, falling back to in-memory "
|
||||
"inbound dedup: %s",
|
||||
e,
|
||||
)
|
||||
from ._session import record_durability_degraded
|
||||
|
||||
record_durability_degraded(
|
||||
"idempotency",
|
||||
reason="durable idempotency store unavailable (running in-memory)",
|
||||
)
|
||||
return _memory()
|
||||
# "redis" (multi-replica) is not yet implemented in the wrapper; fall back
|
||||
# to the durable SQLite store if a path is available, else in-memory.
|
||||
|
||||
@@ -16,6 +16,7 @@ calls from the same user; different users run in parallel.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
@@ -30,9 +31,95 @@ from ._admission import AdmissionRejected
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.gateway import WarmSession, MemoryPressureProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Durability degradation registry (Issue #4339)
|
||||
#
|
||||
# Gateway state (webhook idempotency, session history) is durable by default.
|
||||
# When a durable store genuinely cannot be initialised the boundary that owns
|
||||
# it records a *durability-degraded* fact here instead of only logging a
|
||||
# warning, so the gateway can surface it through the surfaces operators already
|
||||
# read (``gateway doctor`` / ``gateway status``) — the same treatment channel
|
||||
# credential degradation already gets. This reuses the core degraded-owner
|
||||
# vocabulary (``owner_kind="gateway"``) so no new protocol is introduced; the
|
||||
# gateway's ``health()`` merges this process-local registry alongside its own.
|
||||
# --------------------------------------------------------------------------
|
||||
try:
|
||||
from praisonaiagents.gateway import DegradedCapabilityRegistry as _DegRegistry
|
||||
_DURABILITY_REGISTRY: Optional[Any] = _DegRegistry()
|
||||
except Exception: # pragma: no cover - core registry unavailable
|
||||
_DURABILITY_REGISTRY = None
|
||||
|
||||
|
||||
def record_durability_degraded(
|
||||
component: str,
|
||||
*,
|
||||
reason: str,
|
||||
recovery: str = "praisonai gateway doctor --fix",
|
||||
) -> None:
|
||||
"""Record that a durable store fell back to non-durable, in-memory operation.
|
||||
|
||||
``component`` is the owned state that lost durability (e.g. ``"session"`` or
|
||||
``"idempotency"``); ``reason`` is a redacted, operator-safe explanation.
|
||||
Best-effort and never raises into the caller's hot path — if the core
|
||||
registry is unavailable this is a no-op (behaviour then matches the old
|
||||
log-only path).
|
||||
"""
|
||||
registry = _DURABILITY_REGISTRY
|
||||
if registry is None:
|
||||
return
|
||||
try:
|
||||
from praisonaiagents.gateway import DegradedOwner
|
||||
|
||||
registry.mark(
|
||||
DegradedOwner(
|
||||
owner_kind="gateway",
|
||||
owner_id=f"durability:{component}",
|
||||
state="cold",
|
||||
reason=reason,
|
||||
retry_hint=recovery,
|
||||
)
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pass
|
||||
|
||||
|
||||
def clear_durability_degraded(component: str) -> None:
|
||||
"""Clear a previously-recorded durability degradation for ``component``."""
|
||||
registry = _DURABILITY_REGISTRY
|
||||
if registry is None:
|
||||
return
|
||||
try:
|
||||
registry.clear("gateway", f"durability:{component}")
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pass
|
||||
|
||||
|
||||
def durability_degraded_owners() -> List[Any]:
|
||||
"""Return the current durability-degraded owners (empty when all durable)."""
|
||||
registry = _DURABILITY_REGISTRY
|
||||
if registry is None:
|
||||
return []
|
||||
try:
|
||||
return list(registry.list_degraded())
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return []
|
||||
|
||||
|
||||
# Turn-local resolved profile namespace (Issue #4341). A ``chat()`` turn binds
|
||||
# the profile it resolved into this ContextVar for the duration of that turn, so
|
||||
# every ``_storage_key`` recomputation after an await point uses *this* turn's
|
||||
# tenant scope — never a value another concurrent route staged in the meantime.
|
||||
# ContextVars are copied per task, so overlapping routed turns each see their own
|
||||
# binding. ``None`` means unscoped (fail-closed: an unscoped turn never borrows
|
||||
# another tenant's namespace).
|
||||
_ACTIVE_PROFILE_NS: "contextvars.ContextVar[Optional[str]]" = contextvars.ContextVar(
|
||||
"praisonai_bot_active_profile_ns", default=None
|
||||
)
|
||||
|
||||
# Matches one or more leading arrival-time prefixes of the form
|
||||
# ``[… YYYY-MM-DD HH:MM …]`` (Issue #2834). Kept deliberately narrow — it only
|
||||
# strips bracketed groups that contain a ``YYYY-MM-DD HH:MM`` date-time — so a
|
||||
@@ -139,6 +226,17 @@ class BotSessionManager:
|
||||
self._platform = platform
|
||||
self._last_active: Dict[str, float] = {}
|
||||
self._last_reset: Dict[str, float] = {} # Track last reset time per user
|
||||
# Issue #3804: per-session in-flight turn counter (storage_key -> depth).
|
||||
# A key is present with a positive count while a turn is executing
|
||||
# against that cache; the memory-pressure sweep skips any such key so a
|
||||
# live turn is never aborted. Empty in normal steady state.
|
||||
self._in_flight: Dict[str, int] = {}
|
||||
# Issue #3804: per-session durable-flush marker (storage_key present iff
|
||||
# the in-memory cache is known to match a completed store write). Cleared
|
||||
# before each store write and set only after it fully succeeds, so a
|
||||
# failed/partial persist leaves the key absent and the memory-pressure
|
||||
# sweep never evicts the only current copy (would lose data).
|
||||
self._flushed: set[str] = set()
|
||||
# N4: optional inbound DLQ — when set, failed agent.chat() calls
|
||||
# are persisted for later replay. Default ``None`` preserves
|
||||
# legacy behaviour (exception bubbles up untouched).
|
||||
@@ -205,6 +303,19 @@ class BotSessionManager:
|
||||
# Keyed by ``id(agent)`` so a shared session serving multiple agents
|
||||
# never crosses policies.
|
||||
self._pending_tool_policies: Dict[int, Any] = {}
|
||||
# Per-tenant profile namespace staged by a routing handler (Issue #4341).
|
||||
# When a route resolves to a ``profile``, the gateway stages its
|
||||
# namespace here (keyed by ``id(agent)``, exactly like the tool-policy
|
||||
# seam) so the very next ``chat()`` for that agent enters the tenant
|
||||
# scope for the turn. ``chat()`` consumes-and-clears it and binds the
|
||||
# value into a per-turn ContextVar, so overlapping routed turns never
|
||||
# share a namespace. Empty/``None`` means unscoped — a route with no
|
||||
# profile never borrows another tenant's memory (fail-closed).
|
||||
self._pending_profile_namespaces: Dict[int, str] = {}
|
||||
# Fallback staging for callers that stage without an agent (or read
|
||||
# ``_storage_key`` outside a ``chat()`` turn): the most-recently staged
|
||||
# namespace, consumed once by the next ``chat()`` on any agent.
|
||||
self._staged_profile_namespace: Optional[str] = None
|
||||
# Session reset policy for automatic lifecycle management
|
||||
self._reset_policy = reset_policy or SessionResetPolicy(mode="none")
|
||||
# Per-user last agent-emitted presentation. ``chat()`` keeps the return
|
||||
@@ -506,13 +617,34 @@ class BotSessionManager:
|
||||
if self._scope_for(effective_chat_type) == "per_chat" and chat_id:
|
||||
prefix = self._platform or "bot"
|
||||
account_key = account or "default"
|
||||
return f"{prefix}:acct:{account_key}:chat:{chat_id}:{thread_id}"
|
||||
if self._identity_resolver is not None and self._platform:
|
||||
base = f"{prefix}:acct:{account_key}:chat:{chat_id}:{thread_id}"
|
||||
elif self._identity_resolver is not None and self._platform:
|
||||
try:
|
||||
return self._identity_resolver.resolve(self._platform, user_id)
|
||||
base = self._identity_resolver.resolve(self._platform, user_id)
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
logger.warning("identity resolver failed: %s", e)
|
||||
return user_id
|
||||
base = user_id
|
||||
else:
|
||||
base = user_id
|
||||
# Per-tenant memory isolation (Issue #4341): when a route resolved to a
|
||||
# ``profile``, its namespace is bound turn-locally in ``_ACTIVE_PROFILE_NS``
|
||||
# so every storage key is prefixed with the tenant scope. Reading the
|
||||
# ContextVar (not a shared field) means a key recomputed after an await
|
||||
# point always uses *this* turn's tenant — an overlapping route for
|
||||
# another tenant can never rewrite it. This keys memory/session state per
|
||||
# profile so two routes on one process never share a transcript. A route
|
||||
# with no profile stays unprefixed and never falls into a tenant's scope.
|
||||
# Prefer the turn-local binding; fall back to the instance staging only
|
||||
# when no turn is active (e.g. a direct ``_storage_key`` read before
|
||||
# ``chat()`` runs). The ContextVar never leaks across turns because
|
||||
# ``chat()`` resets it in ``finally``; the staged field is consumed by
|
||||
# the next ``chat()`` so it cannot silently persist either.
|
||||
profile_ns = _ACTIVE_PROFILE_NS.get()
|
||||
if profile_ns is None:
|
||||
profile_ns = self._staged_profile_namespace
|
||||
if profile_ns:
|
||||
return f"profile:{profile_ns}:{base}"
|
||||
return base
|
||||
|
||||
def _persist_key(self, storage_key: str) -> str:
|
||||
"""Derive the persistent-store key from an in-memory storage key.
|
||||
@@ -717,9 +849,14 @@ class BotSessionManager:
|
||||
history = history[-self._max_history:]
|
||||
|
||||
# Always update in-memory cache (keyed by storage key)
|
||||
self._histories[self._storage_key(user_id, **route)] = history
|
||||
storage_key = self._storage_key(user_id, **route)
|
||||
self._histories[storage_key] = history
|
||||
|
||||
if self._store is not None:
|
||||
# The in-memory copy now diverges from the store until the write
|
||||
# below fully succeeds; drop the flush marker so a concurrent
|
||||
# pressure sweep cannot evict this (now sole) current copy.
|
||||
self._flushed.discard(storage_key)
|
||||
key = self._session_key(user_id, **route)
|
||||
try:
|
||||
if hasattr(self._store, "set_chat_history"):
|
||||
@@ -735,6 +872,9 @@ class BotSessionManager:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to persist session to store: %s", e)
|
||||
else:
|
||||
# Store now durably holds this exact history — safe to evict.
|
||||
self._flushed.add(storage_key)
|
||||
|
||||
def set_pending_tool_policy(
|
||||
self, agent: "Agent", tool_policy: Optional[Any]
|
||||
@@ -757,6 +897,40 @@ class BotSessionManager:
|
||||
else:
|
||||
self._pending_tool_policies[key] = tool_policy
|
||||
|
||||
def set_profile_namespace(
|
||||
self, profile: Optional[str], agent: Optional["Agent"] = None
|
||||
) -> None:
|
||||
"""Enter (or clear) a per-tenant profile scope for the next turn.
|
||||
|
||||
Used by the gateway's routing handler when a route resolves to a
|
||||
``profile`` (Issue #4341). The handler runs synchronously right before
|
||||
the adapter's own ``_session.chat()`` in the same dispatch. The resolved
|
||||
namespace is *staged* on the instance (keyed by ``id(agent)`` when an
|
||||
agent is given, so a shared session serving multiple agents never
|
||||
crosses tenants). ``chat()`` then consumes-and-clears the staged value
|
||||
and binds it turn-locally in a per-task ContextVar for the duration of
|
||||
that turn — so overlapping routed turns never share a namespace and a
|
||||
stale scope never leaks into a later turn. Every ``_storage_key`` for
|
||||
the turn is prefixed with ``profile:<name>:`` so two routes multiplexed
|
||||
on one process never share a transcript. Passing ``None``/blank clears
|
||||
the scope so a route with no profile never inherits another tenant's
|
||||
namespace (fail-closed: unscoped routes stay unscoped, never borrowed).
|
||||
"""
|
||||
cleaned = str(profile).strip() if profile is not None else ""
|
||||
# Stage on the instance (not a process-wide ContextVar) so a stage never
|
||||
# leaks into an unrelated session or a later turn: ``chat()`` consumes
|
||||
# this and binds it turn-locally, resetting on exit. Keep both a
|
||||
# per-agent slot (so a shared session serving multiple agents never
|
||||
# crosses tenants) and a last-staged fallback for agentless callers and
|
||||
# direct ``_storage_key`` reads before ``chat()`` runs.
|
||||
self._staged_profile_namespace = cleaned or None
|
||||
if agent is not None:
|
||||
key = id(agent)
|
||||
if cleaned:
|
||||
self._pending_profile_namespaces[key] = cleaned
|
||||
else:
|
||||
self._pending_profile_namespaces.pop(key, None)
|
||||
|
||||
@staticmethod
|
||||
def _apply_tool_policy(
|
||||
agent: "Agent", tool_policy: Optional[Any]
|
||||
@@ -884,6 +1058,22 @@ class BotSessionManager:
|
||||
if tool_policy is None:
|
||||
tool_policy = staged_policy
|
||||
|
||||
# Per-tenant profile scope (Issue #4341): bind the resolved namespace
|
||||
# turn-locally for the whole turn so every ``_storage_key`` recomputed
|
||||
# after an await point below uses *this* turn's tenant — an overlapping
|
||||
# route for another tenant cannot rewrite it. Prefer the value staged by
|
||||
# the routing handler for this agent (popped so it applies exactly once);
|
||||
# otherwise the last-staged instance fallback (for agentless callers).
|
||||
# Reset in the finally so it never leaks into an unrelated later turn
|
||||
# sharing this task (fail-closed: unscoped stays unscoped).
|
||||
staged_profile = self._pending_profile_namespaces.pop(id(agent), None)
|
||||
if staged_profile is None:
|
||||
staged_profile = self._staged_profile_namespace
|
||||
# Consume the last-staged fallback so it applies to exactly one turn and
|
||||
# never silently persists into a later unscoped call (fail-closed).
|
||||
self._staged_profile_namespace = None
|
||||
profile_token = _ACTIVE_PROFILE_NS.set(staged_profile or None)
|
||||
|
||||
# Mint/adopt an end-to-end correlation id and bind it for this turn so
|
||||
# ingress, the agent run, and outbound delivery share one stable id in
|
||||
# structured logs. Adopts an explicit correlation_id, else the platform
|
||||
@@ -930,6 +1120,13 @@ class BotSessionManager:
|
||||
reset_correlation_id(cid_token)
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
logger.debug("Failed to reset correlation id: %s", e)
|
||||
# Reset the turn-local profile scope on this early return too so
|
||||
# a deduped duplicate never leaves a tenant namespace bound for a
|
||||
# later unrelated call sharing this task (Issue #4341).
|
||||
try:
|
||||
_ACTIVE_PROFILE_NS.reset(profile_token)
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
logger.debug("Failed to reset profile namespace: %s", e)
|
||||
return ""
|
||||
|
||||
# Resolve the routing context once so per_chat scope (Issue #2376)
|
||||
@@ -1062,6 +1259,11 @@ class BotSessionManager:
|
||||
# finally below regardless of outcome). ``None`` when no gate is set.
|
||||
_admission_cm = None
|
||||
_admission_active = False
|
||||
# Issue #3804: mark this session in-flight for the duration of the turn
|
||||
# so the memory-pressure sweep never soft-evicts a cache with a live
|
||||
# turn executing against it. Set inside the user_lock below, cleared in
|
||||
# the outer finally on every exit path.
|
||||
_inflight_key: Optional[str] = None
|
||||
|
||||
try:
|
||||
# Claim journal entry if we have one
|
||||
@@ -1096,10 +1298,19 @@ class BotSessionManager:
|
||||
# The gate is held for the full turn; released in the outer
|
||||
# finally below so a slot is never leaked on any exit path.
|
||||
async with user_lock:
|
||||
# Issue #3804: this session now has a live turn; record it so
|
||||
# the pressure sweep skips it (never abort in-flight work).
|
||||
_inflight_key = self._storage_key(user_id, **route)
|
||||
self._in_flight[_inflight_key] = self._in_flight.get(_inflight_key, 0) + 1
|
||||
# Load history (may hit disk via run_in_executor for async safety)
|
||||
loop = asyncio.get_running_loop()
|
||||
# Run in the current context so the turn-local profile scope
|
||||
# (Issue #4341) is visible to ``_storage_key`` on the executor
|
||||
# thread — ``run_in_executor`` does not copy ContextVars, so
|
||||
# without this the history key would drop the tenant prefix.
|
||||
_load_ctx = contextvars.copy_context()
|
||||
user_history = await loop.run_in_executor(
|
||||
None, partial(self._load_history, user_id, **route)
|
||||
None, partial(_load_ctx.run, partial(self._load_history, user_id, **route))
|
||||
)
|
||||
|
||||
# Update last active timestamp AFTER history load and reset check
|
||||
@@ -1221,7 +1432,6 @@ class BotSessionManager:
|
||||
emitter.remove_callback(bridged_stream_callback)
|
||||
else:
|
||||
# Legacy non-streaming path: use agent.chat() in executor with cancel_token and timeout
|
||||
import contextvars
|
||||
import inspect
|
||||
_ctx = contextvars.copy_context()
|
||||
|
||||
@@ -1319,10 +1529,17 @@ class BotSessionManager:
|
||||
|
||||
# Persist outside the agent_lock — it's session-scoped and
|
||||
# the agent is no longer touched. ``route`` keys the shared
|
||||
# per_chat session when active (empty otherwise).
|
||||
# per_chat session when active (empty otherwise). Run in the
|
||||
# current context so the turn-local profile scope (Issue
|
||||
# #4341) reaches ``_storage_key`` on the executor thread —
|
||||
# otherwise the save would land in the unscoped key.
|
||||
_save_ctx = contextvars.copy_context()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
partial(self._save_history, user_id, updated_history, **route),
|
||||
partial(
|
||||
_save_ctx.run,
|
||||
partial(self._save_history, user_id, updated_history, **route),
|
||||
),
|
||||
)
|
||||
|
||||
# Normalise an agent-emitted presentation (if any) into
|
||||
@@ -1385,6 +1602,15 @@ class BotSessionManager:
|
||||
await claim_ctx.__aexit__(None, None, None)
|
||||
return result or ""
|
||||
finally:
|
||||
# Issue #3804: clear this turn's in-flight mark so the pressure
|
||||
# sweep can once again consider this cache for eviction. Guarded by
|
||||
# ``_inflight_key`` so a turn shed before the lock never underflows.
|
||||
if _inflight_key is not None:
|
||||
_n = self._in_flight.get(_inflight_key, 0) - 1
|
||||
if _n > 0:
|
||||
self._in_flight[_inflight_key] = _n
|
||||
else:
|
||||
self._in_flight.pop(_inflight_key, None)
|
||||
# Issue #2454: release the admission slot for this turn (frees a
|
||||
# global concurrency slot and lets a queued waiter proceed). Done
|
||||
# first so capacity is returned promptly on every exit path.
|
||||
@@ -1414,6 +1640,13 @@ class BotSessionManager:
|
||||
reset_correlation_id(cid_token)
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
logger.debug("Failed to reset correlation id: %s", e)
|
||||
# Reset the turn-local profile scope so this turn's tenant namespace
|
||||
# never leaks into an unrelated later call sharing this task, and a
|
||||
# subsequent unscoped route always fails closed (Issue #4341).
|
||||
try:
|
||||
_ACTIVE_PROFILE_NS.reset(profile_token)
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
logger.debug("Failed to reset profile namespace: %s", e)
|
||||
|
||||
async def chat_with_run_control(
|
||||
self,
|
||||
@@ -1601,6 +1834,7 @@ class BotSessionManager:
|
||||
# a fresh one instead of emitting a false invalidation (Issue #3352).
|
||||
self._prefix_sig.pop(storage_key, None)
|
||||
self._last_active.pop(storage_key, None)
|
||||
self._flushed.discard(storage_key)
|
||||
self._locks.drop(storage_key)
|
||||
if self._store is not None:
|
||||
key = self._persist_key(storage_key)
|
||||
@@ -1612,6 +1846,116 @@ class BotSessionManager:
|
||||
logger.debug("BotSessionManager: reaped %d stale sessions", len(stale))
|
||||
return len(stale)
|
||||
|
||||
def warm_sessions(self) -> "List[WarmSession]":
|
||||
"""Snapshot the warm per-session caches as :class:`WarmSession` facts.
|
||||
|
||||
Issue #3804: carries just enough state for the pure core planner
|
||||
(:func:`praisonaiagents.gateway.plan_pressure_evictions`) to name the
|
||||
coldest *rebuildable* caches to soft-evict under memory pressure:
|
||||
|
||||
* ``last_activity`` — the LRU key (coldest evicted first).
|
||||
* ``in_flight`` — a turn is executing against the cache (never evict).
|
||||
* ``flushed`` — the transcript is durably persisted and therefore
|
||||
rebuildable. Only true when a persistent ``store`` is configured *and*
|
||||
this session's last write completed (tracked per key in
|
||||
``_flushed``). Without a store, or after a failed/partial persist,
|
||||
the in-memory copy is the only current one, so it is treated as
|
||||
unflushed and never evicted (evicting it would lose data).
|
||||
"""
|
||||
from praisonaiagents.gateway import WarmSession # lazy, core contract
|
||||
|
||||
has_store = self._store is not None
|
||||
return [
|
||||
WarmSession(
|
||||
session_id=key,
|
||||
last_activity=self._last_active.get(key, 0.0),
|
||||
in_flight=self._in_flight.get(key, 0) > 0,
|
||||
flushed=has_store and key in self._flushed,
|
||||
)
|
||||
for key in list(self._histories.keys())
|
||||
]
|
||||
|
||||
def _soft_evict(self, storage_key: str) -> bool:
|
||||
"""Drop only the *in-memory* history for *storage_key* (no store touch).
|
||||
|
||||
The durable copy in the session store is left intact, so the next turn
|
||||
transparently reloads the transcript — soft eviction is a lossless,
|
||||
self-healing pressure valve, not a session teardown. Unlike
|
||||
``reap_stale`` it does NOT fire ``SESSION_END`` or clear the store.
|
||||
Returns ``True`` if an in-memory cache was actually dropped.
|
||||
"""
|
||||
if storage_key not in self._histories:
|
||||
return False
|
||||
self._histories.pop(storage_key, None)
|
||||
# Reset the prompt-prefix baseline so the reloaded session starts a
|
||||
# fresh baseline rather than firing a false invalidation (Issue #3352).
|
||||
self._prefix_sig.pop(storage_key, None)
|
||||
# The in-memory copy is gone; its flush marker no longer applies (the
|
||||
# next load re-establishes state from the store).
|
||||
self._flushed.discard(storage_key)
|
||||
return True
|
||||
|
||||
def sweep_under_pressure(
|
||||
self,
|
||||
memory_pressure: "MemoryPressureProtocol",
|
||||
headroom: float = 0.9,
|
||||
) -> int:
|
||||
"""Soft-evict the coldest rebuildable caches under memory pressure.
|
||||
|
||||
Issue #3804: the runtime enactor for the core planner. Reads the real
|
||||
container budget/RSS from *memory_pressure* (a
|
||||
:class:`praisonaiagents.gateway.MemoryPressureProtocol`), asks
|
||||
:func:`plan_pressure_evictions` which caches to shed, and drops their
|
||||
in-memory history LRU-first — re-sampling RSS as it goes so it stops as
|
||||
soon as RSS is back within ``headroom`` of the budget (avoiding
|
||||
over-shedding). In-flight and unflushed caches are never touched.
|
||||
|
||||
A no-op (returns ``0``) when the platform cannot report a budget, so a
|
||||
host without a cgroup limit keeps today's behaviour exactly. Call it on
|
||||
the gateway's existing periodic tick, alongside ``reap_stale``.
|
||||
"""
|
||||
from praisonaiagents.gateway import plan_pressure_evictions # lazy
|
||||
|
||||
try:
|
||||
budget = memory_pressure.cgroup_limit_mb()
|
||||
rss = memory_pressure.anon_rss_mb()
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
logger.debug("Memory-pressure probe failed; skipping sweep: %s", e)
|
||||
return 0
|
||||
|
||||
plan = plan_pressure_evictions(
|
||||
budget, rss, self.warm_sessions(), headroom_ratio=headroom
|
||||
)
|
||||
if not plan:
|
||||
return 0
|
||||
|
||||
try:
|
||||
target = float(budget) * float(headroom) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
target = 0.0
|
||||
|
||||
evicted = 0
|
||||
for storage_key in plan:
|
||||
# Re-check in-flight at enact time: a turn may have started against
|
||||
# this cache after the plan was computed. Never abort live work.
|
||||
if self._in_flight.get(storage_key, 0) > 0:
|
||||
continue
|
||||
if self._soft_evict(storage_key):
|
||||
evicted += 1
|
||||
# Re-sample and stop once back within budget so we shed the
|
||||
# minimum number of caches (planner returns all, coldest-first).
|
||||
try:
|
||||
if memory_pressure.anon_rss_mb() <= target:
|
||||
break
|
||||
except Exception: # pragma: no cover — defensive
|
||||
break
|
||||
if evicted:
|
||||
logger.info(
|
||||
"BotSessionManager: soft-evicted %d cold session cache(s) "
|
||||
"under memory pressure", evicted
|
||||
)
|
||||
return evicted
|
||||
|
||||
def complete_last_journal_entry(self) -> bool:
|
||||
"""Complete the last journal entry if one exists.
|
||||
|
||||
@@ -1766,6 +2110,12 @@ class BotSessionManager:
|
||||
self._clear_session_data(storage_key, self._persist_key(storage_key))
|
||||
self._last_active.pop(storage_key, None)
|
||||
self._last_reset[storage_key] = time.monotonic()
|
||||
# Clear any per-user model override (set via /model) so a "start fresh"
|
||||
# actually remedies a bad/unavailable model instead of every later turn
|
||||
# silently failing at the provider (Issue #4318). Overrides are keyed by
|
||||
# the per-user storage key (no route), exactly as /model and chat() key
|
||||
# them, so a group /new still clears the issuing user's override.
|
||||
self._model_overrides.pop(self._storage_key(user_id), None)
|
||||
|
||||
# Fire SESSION_END lifecycle hook (no-op when no hooks registered),
|
||||
# then forget the session so a subsequent message re-opens it.
|
||||
@@ -1916,6 +2266,9 @@ class BotSessionManager:
|
||||
# Clear prompt-prefix baselines alongside history so reopened sessions
|
||||
# each establish a fresh baseline (Issue #3352).
|
||||
self._prefix_sig.clear()
|
||||
# Clear per-user model overrides too so a global reset actually starts
|
||||
# every user fresh on the agent's configured model (Issue #4318).
|
||||
self._model_overrides.clear()
|
||||
return count
|
||||
|
||||
@property
|
||||
@@ -2026,7 +2379,10 @@ def build_session_manager(config, platform: str, *, run_control=None) -> BotSess
|
||||
Returns:
|
||||
Configured BotSessionManager instance
|
||||
"""
|
||||
# Try to get the default session store
|
||||
# Try to get the default session store. Sessions are durable by default;
|
||||
# a genuine store-init failure is recorded as a durability-degraded fact
|
||||
# (Issue #4339) — surfaced by ``gateway doctor`` / ``gateway status`` — not
|
||||
# just logged, so an operator is told their bot is now running non-durably.
|
||||
try:
|
||||
from praisonaiagents.session import get_default_session_store
|
||||
except ImportError:
|
||||
@@ -2036,11 +2392,22 @@ def build_session_manager(config, platform: str, *, run_control=None) -> BotSess
|
||||
try:
|
||||
store = get_default_session_store()
|
||||
except Exception as exc:
|
||||
# Raw exception (which may embed a filesystem path) stays in the log
|
||||
# only; the operator-facing reason is redacted so health() and
|
||||
# ``gateway status`` never echo backend paths (#4339).
|
||||
logger.warning(
|
||||
"Default session store unavailable; falling back to in-memory store: %s",
|
||||
exc,
|
||||
)
|
||||
record_durability_degraded(
|
||||
"session",
|
||||
reason="session store unavailable (running in-memory)",
|
||||
)
|
||||
store = None
|
||||
else:
|
||||
# Store came up (or was restored): clear any prior degraded fact so
|
||||
# a recovered gateway no longer reports non-durable sessions.
|
||||
clear_durability_degraded("session")
|
||||
|
||||
# Extract reset policy from config
|
||||
reset_policy = None
|
||||
|
||||
@@ -126,6 +126,7 @@ class DiscordBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
self._ack: AckReactor = AckReactor(
|
||||
ack_emoji=self.config.ack_emoji,
|
||||
done_emoji=self.config.done_emoji,
|
||||
scope=getattr(self.config, "ack_scope", "group-mentions"),
|
||||
)
|
||||
|
||||
# Pairing system
|
||||
@@ -386,7 +387,11 @@ class DiscordBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
handler(bot_message)
|
||||
except Exception as e:
|
||||
logger.error(f"Command handler error: {e}")
|
||||
return
|
||||
return
|
||||
# Unknown/unimplemented slash command: fall through to normal
|
||||
# agent dispatch below rather than a silent no-op, matching the
|
||||
# Slack reference adapter so a misspelled or not-yet-implemented
|
||||
# command reaches the agent instead of vanishing (Issue #4318).
|
||||
|
||||
should_respond = False
|
||||
if message.guild is None:
|
||||
@@ -408,8 +413,15 @@ class DiscordBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
user_id = str(message.author.id)
|
||||
|
||||
# Ack reaction - show processing indicator
|
||||
# (scope-gated: quiet on ambient guild chatter)
|
||||
_is_mention = (
|
||||
message.guild is None
|
||||
or self._client.user.mentioned_in(message)
|
||||
)
|
||||
ack_ctx = None
|
||||
if self._ack.enabled:
|
||||
if self._ack.enabled and self._ack.should_ack_message(
|
||||
bot_message, is_mention=_is_mention
|
||||
):
|
||||
async def _discord_react(emoji, **kw):
|
||||
try:
|
||||
await message.add_reaction(emoji)
|
||||
@@ -553,7 +565,7 @@ class DiscordBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
|
||||
async def _send_long_message(self, channel, text: str, reference=None) -> None:
|
||||
"""Send a long message, splitting with markdown-aware chunking."""
|
||||
from ._chunk import chunk_message
|
||||
from ._chunk import chunk_message, enforce_hard_cap
|
||||
|
||||
max_len = min(self.config.max_message_length, 2000)
|
||||
|
||||
@@ -564,6 +576,10 @@ class DiscordBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
await channel.send(text)
|
||||
else:
|
||||
chunks = chunk_message(text, max_length=max_len, preserve_fences=True)
|
||||
# Guarantee no chunk exceeds the 2000-char cap; an over-cap code
|
||||
# fence would otherwise raise a 400 and drop the entire reply
|
||||
# (Issue #4319).
|
||||
chunks = enforce_hard_cap(chunks, max_len)
|
||||
for i, chunk in enumerate(chunks):
|
||||
if i == 0 and reference:
|
||||
await channel.send(chunk, reference=reference)
|
||||
|
||||
@@ -108,6 +108,7 @@ class LinearBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
self._ack: AckReactor = AckReactor(
|
||||
ack_emoji=self.config.ack_emoji,
|
||||
done_emoji=self.config.done_emoji,
|
||||
scope=getattr(self.config, "ack_scope", "group-mentions"),
|
||||
)
|
||||
|
||||
# ChatCommandMixin setup
|
||||
|
||||
@@ -60,9 +60,14 @@ def split_media_from_output(text: str) -> Dict[str, Any]:
|
||||
|
||||
# Remove MEDIA: lines from text
|
||||
clean = MEDIA_REGEX.sub("", clean)
|
||||
|
||||
# Clean up whitespace
|
||||
clean = "\n".join(line for line in clean.split("\n") if line.strip())
|
||||
|
||||
# Clean up whitespace.
|
||||
# NOTE: only *excess* blank runs left behind by stripped MEDIA lines are
|
||||
# collapsed. Legitimate blank lines are load-bearing on the outbound path —
|
||||
# they are the paragraph separators for chunking and the only visual break in
|
||||
# plain-text replies — so a single blank line between blocks is preserved
|
||||
# (Issue #4319).
|
||||
clean = re.sub(r"\n[ \t]*\n(?:[ \t]*\n)+", "\n\n", clean)
|
||||
clean = clean.strip()
|
||||
|
||||
return {
|
||||
|
||||
@@ -146,6 +146,7 @@ class SignalBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
self._ack: AckReactor = AckReactor(
|
||||
ack_emoji=self.config.ack_emoji,
|
||||
done_emoji=self.config.done_emoji,
|
||||
scope=getattr(self.config, "ack_scope", "group-mentions"),
|
||||
)
|
||||
|
||||
self._command_handlers: Dict[str, Callable] = {}
|
||||
@@ -306,7 +307,14 @@ class SignalBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
|
||||
if not isinstance(envelopes, list):
|
||||
return
|
||||
self._note_inbound()
|
||||
# Only refresh inbound liveness when the bridge actually delivered an
|
||||
# envelope. A successfully-empty poll must NOT count as activity: a
|
||||
# silently half-open transport returns ``[]`` forever without raising,
|
||||
# and noting that as inbound traffic would keep ``last_activity`` fresh
|
||||
# and defeat the health monitor's STALE_SOCKET detection — leaving a
|
||||
# "deaf" channel reported HEALTHY with no operator signal.
|
||||
if envelopes:
|
||||
self._note_inbound()
|
||||
for item in envelopes:
|
||||
try:
|
||||
await self._handle_envelope(item)
|
||||
|
||||
@@ -170,6 +170,7 @@ class SlackBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
self._ack: AckReactor = AckReactor(
|
||||
ack_emoji=self.config.ack_emoji,
|
||||
done_emoji=self.config.done_emoji,
|
||||
scope=getattr(self.config, "ack_scope", "group-mentions"),
|
||||
)
|
||||
|
||||
# Pairing system
|
||||
@@ -562,8 +563,27 @@ class SlackBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
msg_ts = event.get("ts", "")
|
||||
|
||||
# Ack reaction - show processing indicator
|
||||
# (scope-gated: quiet on ambient channel chatter). A reply
|
||||
# inside a thread (thread_ts present) counts as engaging the
|
||||
# bot even without a fresh @mention, so a follow-up in an
|
||||
# ongoing thread is acked under group-mentions instead of being
|
||||
# misclassified as ambient (Greptile #4293).
|
||||
_in_thread = bool(event.get("thread_ts"))
|
||||
_is_mention = channel_type == "im" or _in_thread or (
|
||||
bool(self._bot_user)
|
||||
and f"<@{self._bot_user.user_id}>"
|
||||
in (event.get("text", "") or "")
|
||||
)
|
||||
ack_ctx = None
|
||||
if self._ack.enabled and self._client:
|
||||
if (
|
||||
self._ack.enabled
|
||||
and self._client
|
||||
and self._ack.should_ack(
|
||||
is_direct=channel_type == "im",
|
||||
is_mention=_is_mention,
|
||||
is_ambient=channel_type != "im" and not _is_mention,
|
||||
)
|
||||
):
|
||||
async def _slack_react(emoji, **kw):
|
||||
try:
|
||||
# Slack uses emoji names without colons
|
||||
@@ -944,7 +964,7 @@ class SlackBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
|
||||
async def _send_long_message(self, say, text: str, thread_ts: Optional[str] = None) -> None:
|
||||
"""Send a long message, splitting with markdown-aware chunking."""
|
||||
from ._chunk import chunk_message
|
||||
from ._chunk import chunk_message, enforce_hard_cap
|
||||
|
||||
max_len = min(self.config.max_message_length, 4000)
|
||||
|
||||
@@ -956,6 +976,10 @@ class SlackBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
await say(text=text.strip(), thread_ts=thread_ts)
|
||||
else:
|
||||
chunks = chunk_message(text, max_length=max_len, preserve_fences=True)
|
||||
# Guarantee no chunk exceeds the platform cap; an over-cap code
|
||||
# fence would otherwise raise "msg_too_long" and drop the entire
|
||||
# reply (Issue #4319).
|
||||
chunks = enforce_hard_cap(chunks, max_len)
|
||||
for chunk in chunks:
|
||||
if chunk and chunk.strip():
|
||||
await say(text=chunk.strip(), thread_ts=thread_ts)
|
||||
|
||||
@@ -181,6 +181,7 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
self._ack: AckReactor = AckReactor(
|
||||
ack_emoji=self.config.ack_emoji,
|
||||
done_emoji=self.config.done_emoji,
|
||||
scope=getattr(self.config, "ack_scope", "group-mentions"),
|
||||
)
|
||||
|
||||
# Pairing system
|
||||
@@ -647,9 +648,9 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
logger.error(f"Message handler error: {e}")
|
||||
|
||||
if self._agent and not message.is_command:
|
||||
# Ack reaction
|
||||
# Ack reaction (scope-gated: quiet on ambient group chatter)
|
||||
ack_ctx = None
|
||||
if self._ack.enabled:
|
||||
if self._ack.enabled and self._ack.should_ack_message(message):
|
||||
async def _tg_react(emoji, **kw):
|
||||
try:
|
||||
from telegram import ReactionTypeEmoji
|
||||
@@ -934,12 +935,16 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
|
||||
# File-based custom slash commands (Issue #3729): resolve
|
||||
# ``.praisonai/commands/{command}.md`` and submit the rendered
|
||||
# body as a normal chat turn. Falls through to chat on a miss.
|
||||
# body as a normal chat turn. On a miss (no such custom command)
|
||||
# the raw command text is dispatched to the agent so an unknown
|
||||
# or misspelled slash command is never a silent no-op — matching
|
||||
# the Slack reference adapter's fall-through (Issue #4318).
|
||||
text = update.message.text or ""
|
||||
parts = text.split(maxsplit=1)
|
||||
arguments = parts[1] if len(parts) > 1 else ""
|
||||
rendered = self.render_custom_command(command, arguments)
|
||||
if rendered is not None:
|
||||
prompt = rendered if rendered is not None else text
|
||||
if prompt is not None:
|
||||
user_name = (
|
||||
update.message.from_user.username
|
||||
or update.message.from_user.first_name
|
||||
@@ -947,7 +952,7 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
) if update.message.from_user else ""
|
||||
try:
|
||||
response = await self._session.chat(
|
||||
self._agent, user_id, rendered,
|
||||
self._agent, user_id, prompt,
|
||||
chat_id=str(update.message.chat_id) if update.message.chat_id else "",
|
||||
user_name=user_name,
|
||||
message_id=str(update.message.message_id),
|
||||
@@ -1567,11 +1572,17 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
reply_to: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Send a long message, splitting with markdown-aware chunking."""
|
||||
from ._chunk import chunk_message
|
||||
from ._chunk import chunk_message, enforce_hard_cap, _calculate_length
|
||||
|
||||
max_len = self.config.max_message_length
|
||||
# Telegram measures message length in UTF-16 code units, not Python
|
||||
# codepoints — a single emoji is 2 UTF-16 units. Measuring in
|
||||
# codepoints lets an emoji-heavy reply exceed 4096 UTF-16 units and be
|
||||
# rejected as "message too long", so use the platform's declared unit
|
||||
# (Issue #4319).
|
||||
length_unit = self.platform_capabilities().length_unit
|
||||
|
||||
if len(text) <= max_len:
|
||||
if _calculate_length(text, length_unit) <= max_len:
|
||||
kwargs = {"chat_id": chat_id, "text": text}
|
||||
if reply_to:
|
||||
kwargs["reply_to_message_id"] = reply_to
|
||||
@@ -1591,7 +1602,16 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
}
|
||||
)
|
||||
else:
|
||||
chunks = chunk_message(text, max_length=max_len, preserve_fences=True)
|
||||
chunks = chunk_message(
|
||||
text,
|
||||
max_length=max_len,
|
||||
preserve_fences=True,
|
||||
length_unit=length_unit,
|
||||
)
|
||||
# Guarantee no chunk exceeds the platform cap; an over-cap code
|
||||
# fence would otherwise raise "Message is too long" and drop the
|
||||
# entire reply (Issue #4319).
|
||||
chunks = enforce_hard_cap(chunks, max_len, length_unit=length_unit)
|
||||
for i, chunk in enumerate(chunks):
|
||||
kwargs = {"chat_id": chat_id, "text": chunk}
|
||||
if i == 0 and reply_to:
|
||||
|
||||
@@ -227,6 +227,7 @@ class WhatsAppBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
self._ack: AckReactor = AckReactor(
|
||||
ack_emoji=self.config.ack_emoji,
|
||||
done_emoji=self.config.done_emoji,
|
||||
scope=getattr(self.config, "ack_scope", "group-mentions"),
|
||||
)
|
||||
|
||||
# Web mode adapter (lazy initialized)
|
||||
@@ -902,8 +903,9 @@ class WhatsAppBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
# Route to agent
|
||||
if self._agent and (content or attachments):
|
||||
# Ack reaction - show processing indicator
|
||||
# (scope-gated: quiet on ambient group chatter)
|
||||
ack_ctx = None
|
||||
if self._ack.enabled:
|
||||
if self._ack.enabled and self._ack.should_ack_message(bot_message):
|
||||
async def _wa_react(emoji, **kw):
|
||||
try:
|
||||
await self.send_reaction(sender_id, msg_id, emoji)
|
||||
|
||||
@@ -842,6 +842,24 @@ class GatewayHandler:
|
||||
f"{_time.strftime('%H:%M:%S', _time.localtime(data['last_inbound_at']))} "
|
||||
f"({age:.0f}s ago)"
|
||||
)
|
||||
# Issue #4339: surface degraded owners (channels, providers, and
|
||||
# now durability — a session/idempotency store that fell back to
|
||||
# in-memory) so an operator is told when the bot is running
|
||||
# non-durably instead of it being a silent log line. Always
|
||||
# printed (not gated on ``deep``) since a degraded state is
|
||||
# exactly what an at-a-glance status check should reveal.
|
||||
degraded_owners = data.get("degraded_owners") or []
|
||||
if degraded_owners:
|
||||
print(" Degraded:")
|
||||
for owner in degraded_owners:
|
||||
kind = owner.get("owner_kind", "?")
|
||||
oid = owner.get("owner_id", "?")
|
||||
reason = owner.get("reason", "—")
|
||||
hint = owner.get("retry_hint", "")
|
||||
line = f" ✗ {kind}:{oid} — {reason}"
|
||||
if hint:
|
||||
line += f" fix: {hint}"
|
||||
print(line)
|
||||
channels = data.get("channels") or {}
|
||||
if channels and deep:
|
||||
print(" Channels:")
|
||||
|
||||
@@ -429,6 +429,7 @@ def _generate_bot_yaml(platforms: List[str], agent_name: str = "assistant", agen
|
||||
lines.append(" # Set to false only if you add destructive tools AND have a chat-level approval flow.")
|
||||
lines.append(' ack_emoji: "⏳" # Show processing indicator during long agent operations')
|
||||
lines.append(' done_emoji: "✅" # Replace ack_emoji when operation completes')
|
||||
lines.append(' ack_scope: "group-mentions" # Who gets acked: off | direct | group-mentions | group-all | all')
|
||||
if plat == "whatsapp":
|
||||
lines.append(" phone_number_id: ${WHATSAPP_PHONE_NUMBER_ID}")
|
||||
# Gateway-style routing (ignored by `bot start` which uses top-level routing).
|
||||
|
||||
@@ -15,6 +15,7 @@ if TYPE_CHECKING:
|
||||
from .exec_approval import ExecApprovalManager, get_exec_approval_manager
|
||||
from .gateway_approval import GatewayApprovalBackend
|
||||
from .home_channels import HomeChannelRegistry, DeliveryResolver
|
||||
from .memory_pressure import CgroupMemoryPressure
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy loading of gateway components."""
|
||||
@@ -58,6 +59,9 @@ def __getattr__(name: str):
|
||||
if name == "DeliveryResolver":
|
||||
from .home_channels import DeliveryResolver
|
||||
return DeliveryResolver
|
||||
if name == "CgroupMemoryPressure":
|
||||
from .memory_pressure import CgroupMemoryPressure
|
||||
return CgroupMemoryPressure
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
@@ -74,4 +78,5 @@ __all__ = [
|
||||
"GatewayApprovalBackend",
|
||||
"HomeChannelRegistry",
|
||||
"DeliveryResolver",
|
||||
"CgroupMemoryPressure",
|
||||
]
|
||||
@@ -43,7 +43,7 @@ import threading
|
||||
from contextlib import closing
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING, Union
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, TYPE_CHECKING, Union
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from praisonaiagents.approval import ApprovalStoreProtocol
|
||||
@@ -102,6 +102,10 @@ class PendingRequest:
|
||||
agent_name: str = ""
|
||||
risk_level: str = "medium"
|
||||
created_at: float = field(default_factory=time.time)
|
||||
# Optional per-request custody: reviewer identities authorised to resolve
|
||||
# this request. ``None`` (default) keeps today's behaviour where any holder
|
||||
# of the APPROVALS scope may resolve any request.
|
||||
authorized_reviewers: Optional[frozenset] = None
|
||||
# Resolved via the asyncio Future
|
||||
_future: Optional[asyncio.Future] = field(default=None, repr=False)
|
||||
# Store the event loop at registration time for thread-safe resolution
|
||||
@@ -121,6 +125,11 @@ class Resolution:
|
||||
scope_to_agent: bool = True
|
||||
# If True, also key the grant by the request's argument signature.
|
||||
scope_to_args: bool = False
|
||||
# Identity of the principal resolving this request (operator/device/actor
|
||||
# id). Recorded on the durable audit trail and the allow-always grant so a
|
||||
# decision is attributable to a person. ``None`` (default) keeps today's
|
||||
# anonymous ``"gateway"`` attribution.
|
||||
resolver: Optional[str] = None
|
||||
|
||||
|
||||
# ── Durable, scoped allow-list store ─────────────────────────────────
|
||||
@@ -638,6 +647,7 @@ class ExecApprovalManager:
|
||||
logger.debug(
|
||||
"Could not read stored expiry for %s", approval_id
|
||||
)
|
||||
reviewers = getattr(req, "authorized_reviewers", None)
|
||||
self._pending[approval_id] = PendingRequest(
|
||||
request_id=approval_id,
|
||||
tool_name=req.tool_name,
|
||||
@@ -645,6 +655,7 @@ class ExecApprovalManager:
|
||||
agent_name=req.agent_name or "",
|
||||
risk_level=req.risk_level,
|
||||
created_at=created_at,
|
||||
authorized_reviewers=frozenset(reviewers) if reviewers else None,
|
||||
)
|
||||
count += 1
|
||||
if count:
|
||||
@@ -659,12 +670,17 @@ class ExecApprovalManager:
|
||||
arguments: Dict[str, Any],
|
||||
agent_name: str = "",
|
||||
risk_level: str = "medium",
|
||||
authorized_reviewers: Optional[Iterable[str]] = None,
|
||||
) -> tuple:
|
||||
"""Create a pending request and return ``(request_id, future)``.
|
||||
|
||||
If a matching allow-always grant exists (scoped to this agent, or a
|
||||
legacy global grant), the future is resolved immediately with
|
||||
``approved=True``.
|
||||
|
||||
``authorized_reviewers`` optionally binds the request to a set of
|
||||
reviewer identities; only those principals may resolve it. ``None``
|
||||
(default) leaves resolution open to any holder of the APPROVALS scope.
|
||||
"""
|
||||
# Fast path: already permanently allowed (scoped to this agent, its
|
||||
# argument signature, or a legacy global grant).
|
||||
@@ -682,12 +698,14 @@ class ExecApprovalManager:
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.create_future()
|
||||
|
||||
reviewers = frozenset(authorized_reviewers) if authorized_reviewers else None
|
||||
req = PendingRequest(
|
||||
request_id=request_id,
|
||||
tool_name=tool_name,
|
||||
arguments=arguments,
|
||||
agent_name=agent_name,
|
||||
risk_level=risk_level,
|
||||
authorized_reviewers=reviewers,
|
||||
_future=future,
|
||||
_loop=loop, # capture loop at registration for thread-safe resolve
|
||||
)
|
||||
@@ -709,6 +727,9 @@ class ExecApprovalManager:
|
||||
risk_level=risk_level,
|
||||
agent_name=agent_name or None,
|
||||
approval_id=request_id,
|
||||
authorized_reviewers=(
|
||||
sorted(reviewers) if reviewers else None
|
||||
),
|
||||
),
|
||||
expires_at=time.time() + self._ttl,
|
||||
)
|
||||
@@ -737,6 +758,22 @@ class ExecApprovalManager:
|
||||
if req is None:
|
||||
return False
|
||||
|
||||
# Per-request custody: if the request is bound to a set of authorised
|
||||
# reviewers, only one of them may resolve it. Re-insert the request on
|
||||
# rejection so an authorised reviewer can still resolve it later (the
|
||||
# atomic pop above preserves first-answer-wins between authorised
|
||||
# reviewers).
|
||||
if req.authorized_reviewers and resolution.resolver not in req.authorized_reviewers:
|
||||
with self._lock:
|
||||
self._pending.setdefault(request_id, req)
|
||||
logger.warning(
|
||||
"Rejected approval resolution for %s: resolver %r not authorised",
|
||||
request_id, resolution.resolver,
|
||||
)
|
||||
return False
|
||||
|
||||
approver = resolution.resolver or "gateway"
|
||||
|
||||
if resolution.allow_always:
|
||||
# Scope the grant safely-by-default: to the requesting agent unless
|
||||
# the resolver explicitly widened it, and optionally to its
|
||||
@@ -761,7 +798,7 @@ class ExecApprovalManager:
|
||||
agent_id=agent_id,
|
||||
tool_name=req.tool_name,
|
||||
arg_signature=arg_sig,
|
||||
approver="gateway:human",
|
||||
approver=approver,
|
||||
)
|
||||
logger.info(
|
||||
"Tool '%s' added to allow-always list (agent=%s, args=%s)",
|
||||
@@ -775,7 +812,7 @@ class ExecApprovalManager:
|
||||
)
|
||||
|
||||
# Record the decision to the durable audit trail (best-effort).
|
||||
self._record_resolution(request_id, resolution)
|
||||
self._record_resolution(request_id, resolution, approver=approver)
|
||||
|
||||
logger.info(
|
||||
"Approval resolved: %s -> %s",
|
||||
@@ -784,12 +821,22 @@ class ExecApprovalManager:
|
||||
)
|
||||
return True
|
||||
|
||||
def _record_resolution(self, request_id: str, resolution: Resolution) -> None:
|
||||
def _record_resolution(
|
||||
self,
|
||||
request_id: str,
|
||||
resolution: Resolution,
|
||||
*,
|
||||
approver: str = "gateway",
|
||||
) -> None:
|
||||
"""Persist a decision to the durable store (sync, best-effort).
|
||||
|
||||
Called from :meth:`resolve`, which may run on any thread. The store's
|
||||
``resolve`` is async, so we schedule it on a running loop when one is
|
||||
available and otherwise run it synchronously.
|
||||
|
||||
``approver`` is the resolving principal's identity (defaulting to the
|
||||
anonymous ``"gateway"`` when no resolver was supplied) so the audit
|
||||
trail can attribute the decision to a person.
|
||||
"""
|
||||
if self._store is None:
|
||||
return
|
||||
@@ -799,7 +846,7 @@ class ExecApprovalManager:
|
||||
decision = ApprovalDecision(
|
||||
approved=resolution.approved,
|
||||
reason=resolution.reason,
|
||||
approver="gateway",
|
||||
approver=approver,
|
||||
)
|
||||
coro = self._store.resolve(request_id, decision)
|
||||
except Exception:
|
||||
|
||||
@@ -89,6 +89,7 @@ class GatewayApprovalBackend:
|
||||
arguments=request.arguments,
|
||||
agent_name=request.agent_name or "",
|
||||
risk_level=request.risk_level,
|
||||
authorized_reviewers=request.authorized_reviewers,
|
||||
)
|
||||
|
||||
# Fire optional notification (fire-and-forget)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Concrete cgroup-aware memory-pressure reader for the gateway (Issue #3804).
|
||||
|
||||
The core SDK ships the *decision* — :func:`praisonaiagents.gateway.plan_pressure_evictions`
|
||||
plus the :class:`praisonaiagents.gateway.MemoryPressureProtocol` contract — but the
|
||||
*mechanism* (reading the container's real memory ceiling and the process's own
|
||||
anonymous RSS) belongs in the running gateway. This module is that mechanism: a
|
||||
tiny, stdlib-only implementation of ``MemoryPressureProtocol`` so the eviction
|
||||
budget tracks the actual cgroup limit the process runs under (``memory.high`` /
|
||||
``memory.max`` on cgroup v2, ``memory.limit_in_bytes`` on v1) rather than total
|
||||
host RAM or a hard-coded number.
|
||||
|
||||
Every probe degrades to ``None``/``0.0`` when the platform cannot report it, so
|
||||
``BotSessionManager.sweep_under_pressure`` becomes a safe no-op on hosts without
|
||||
a cgroup limit — preserving today's behaviour exactly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MIB = 1024.0 * 1024.0
|
||||
|
||||
# cgroup v2 reports "no limit" as this literal; v1 uses a very large sentinel.
|
||||
_V2_MAX = "max"
|
||||
# A cgroup v1 limit at/above this many bytes means "unlimited" in practice
|
||||
# (kernels expose PAGE_COUNTER_MAX * PAGE_SIZE, ~8 EiB); treat as no limit.
|
||||
_V1_UNLIMITED_BYTES = 1 << 62
|
||||
|
||||
|
||||
class CgroupMemoryPressure:
|
||||
"""Read the container memory budget and anonymous RSS via cgroup + procfs.
|
||||
|
||||
Implements :class:`praisonaiagents.gateway.MemoryPressureProtocol`. All I/O
|
||||
is best-effort and swallowed: a probe that cannot read its source returns
|
||||
``None`` (budget) or ``0.0`` (RSS) so the pressure sweep never crashes the
|
||||
gateway it is meant to protect.
|
||||
"""
|
||||
|
||||
def cgroup_limit_mb(self) -> Optional[float]:
|
||||
"""Return the container memory limit in MiB, or ``None`` if unknown.
|
||||
|
||||
Prefers cgroup v2 ``memory.high`` (the soft throttle ceiling the kernel
|
||||
reclaims against) then ``memory.max`` (the hard OOM ceiling); falls back
|
||||
to cgroup v1 ``memory.limit_in_bytes``. Returns ``None`` when no finite
|
||||
limit is configured (unconstrained host).
|
||||
|
||||
The process may live in a *nested* cgroup whose limit is lower than the
|
||||
filesystem root's, so each controller file is probed at the process's
|
||||
own cgroup path first (resolved from ``/proc/self/cgroup``) and only
|
||||
then at the root — the root remaining the fallback preserves behaviour
|
||||
on hosts where the nested path is unreadable.
|
||||
"""
|
||||
v2_rel, v1_rel = self._self_cgroup_paths()
|
||||
# cgroup v2: nested dir first, then filesystem root.
|
||||
v2_dirs = []
|
||||
if v2_rel:
|
||||
v2_dirs.append("/sys/fs/cgroup/" + v2_rel.strip("/"))
|
||||
v2_dirs.append("/sys/fs/cgroup")
|
||||
for base in v2_dirs:
|
||||
for leaf in ("memory.high", "memory.max"):
|
||||
mb = self._read_v2_limit(base.rstrip("/") + "/" + leaf)
|
||||
if mb is not None:
|
||||
return mb
|
||||
# cgroup v1 fallback: nested memory-controller dir first, then root.
|
||||
v1_paths = []
|
||||
if v1_rel:
|
||||
v1_paths.append(
|
||||
"/sys/fs/cgroup/memory/" + v1_rel.strip("/") + "/memory.limit_in_bytes"
|
||||
)
|
||||
v1_paths.append("/sys/fs/cgroup/memory/memory.limit_in_bytes")
|
||||
for path in v1_paths:
|
||||
mb = self._read_v1_limit(path)
|
||||
if mb is not None:
|
||||
return mb
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _self_cgroup_paths() -> "tuple[Optional[str], Optional[str]]":
|
||||
"""Resolve this process's (v2, v1-memory) cgroup-relative paths.
|
||||
|
||||
Parses ``/proc/self/cgroup``. A v2 line is ``0::/<path>``; the v1 memory
|
||||
controller is the entry whose second field contains ``memory``. Returns
|
||||
``(None, None)`` when unreadable so the caller falls back to the root.
|
||||
"""
|
||||
v2: Optional[str] = None
|
||||
v1: Optional[str] = None
|
||||
try:
|
||||
with open("/proc/self/cgroup", "r") as fh:
|
||||
for line in fh:
|
||||
parts = line.strip().split(":", 2)
|
||||
if len(parts) != 3:
|
||||
continue
|
||||
_hid, controllers, path = parts
|
||||
if controllers == "" and v2 is None:
|
||||
v2 = path or "/"
|
||||
elif "memory" in controllers.split(",") and v1 is None:
|
||||
v1 = path or "/"
|
||||
except OSError:
|
||||
return None, None
|
||||
return v2, v1
|
||||
|
||||
def anon_rss_mb(self) -> float:
|
||||
"""Return current anonymous (non-reclaimable) RSS in MiB.
|
||||
|
||||
Reads ``RssAnon`` from ``/proc/self/status`` (Linux). Anonymous RSS is
|
||||
the memory that *cannot* be reclaimed by dropping caches — the pages
|
||||
that actually push a container toward its cgroup ceiling. Returns
|
||||
``0.0`` when unavailable (non-Linux / unreadable), which makes the
|
||||
planner a no-op.
|
||||
"""
|
||||
try:
|
||||
with open("/proc/self/status", "r") as fh:
|
||||
for line in fh:
|
||||
if line.startswith("RssAnon:"):
|
||||
# Format: "RssAnon:\t 12345 kB"
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
return float(parts[1]) / 1024.0
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _read_v2_limit(path: str) -> Optional[float]:
|
||||
try:
|
||||
with open(path, "r") as fh:
|
||||
raw = fh.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
if not raw or raw == _V2_MAX:
|
||||
return None
|
||||
try:
|
||||
return float(int(raw)) / _MIB
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _read_v1_limit(path: str) -> Optional[float]:
|
||||
try:
|
||||
with open(path, "r") as fh:
|
||||
raw = fh.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if value <= 0 or value >= _V1_UNLIMITED_BYTES:
|
||||
return None
|
||||
return float(value) / _MIB
|
||||
@@ -1379,6 +1379,35 @@ class WebSocketGateway:
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
def _resolve_operator_identity(request) -> str:
|
||||
"""Derive a stable, non-secret operator identity for the audit trail.
|
||||
|
||||
Resolution order (most to least specific), so multi-operator
|
||||
deployments can attribute decisions to an *individual* rather than
|
||||
to a shared credential:
|
||||
|
||||
1. An explicit ``X-Operator-Id`` header (a non-secret, caller-supplied
|
||||
operator handle). This distinguishes operators even when they
|
||||
share a gateway token — a proxy/SSO layer can stamp it per user.
|
||||
2. A short digest of the operator token. The token is a secret and
|
||||
must never be logged, so we hash it; distinct tokens yield
|
||||
distinct identities. Holders of the *same* token collapse to one
|
||||
identity by design (that is the identity that token represents).
|
||||
3. The client IP, then ``"gateway"`` when nothing else is available
|
||||
(e.g. loopback bypass).
|
||||
"""
|
||||
explicit = request.headers.get("x-operator-id", "").strip()
|
||||
if explicit:
|
||||
# Bound + sanitised so a header value can't bloat or corrupt the
|
||||
# audit row; it is a handle, not a secret.
|
||||
return f"operator:{explicit[:64]}"
|
||||
token = _extract_request_token(request)
|
||||
if token:
|
||||
digest = hashlib.sha256(token.encode("utf-8")).hexdigest()[:12]
|
||||
return f"operator:{digest}"
|
||||
client_ip = request.client.host if request.client else None
|
||||
return f"operator:{client_ip}" if client_ip else "gateway"
|
||||
|
||||
async def info(request):
|
||||
auth_err = _check_auth(request)
|
||||
if auth_err:
|
||||
@@ -1708,12 +1737,14 @@ class WebSocketGateway:
|
||||
allow_always=bool(body.get("allow_always", False)),
|
||||
scope_to_agent=bool(body.get("scope_to_agent", True)),
|
||||
scope_to_args=bool(body.get("scope_to_args", False)),
|
||||
resolver=_resolve_operator_identity(request),
|
||||
)
|
||||
|
||||
found = _approval_mgr.resolve(request_id, resolution)
|
||||
if not found:
|
||||
return JSONResponse(
|
||||
{"error": "Request not found or already resolved"},
|
||||
{"error": "Request not found, already resolved, or "
|
||||
"resolver not authorised"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
@@ -4237,8 +4268,12 @@ class WebSocketGateway:
|
||||
return self._hook_idem
|
||||
# Backend is captured from the parsed ``hooks.idempotency`` config in
|
||||
# ``_apply_hooks_from_config`` (``GatewayConfig`` carries no field for
|
||||
# it). Default to the in-memory store when no config selected one.
|
||||
backend = self._hook_idempotency_backend or "memory"
|
||||
# it). Durable by default (Issue #4339): when no config selected a
|
||||
# backend, ``None`` lets ``build_idempotency_store`` pick the durable
|
||||
# SQLite store so an out-of-box gateway survives a restart rather than
|
||||
# silently re-processing a redelivered webhook. ``"memory"`` stays an
|
||||
# explicit opt-in for ephemeral runs.
|
||||
backend = self._hook_idempotency_backend
|
||||
try:
|
||||
from pathlib import Path
|
||||
from praisonai_bot.bots import build_idempotency_store
|
||||
@@ -5187,6 +5222,21 @@ class WebSocketGateway:
|
||||
if shared is not None:
|
||||
for owner in shared.list_degraded():
|
||||
registry.mark(owner)
|
||||
# Issue #4339: surface durability degradation (a durable session or
|
||||
# idempotency store that could not initialise and fell back to
|
||||
# in-memory) the same way as channel degradation, so an operator is
|
||||
# told their bot is running non-durably instead of it being a silent
|
||||
# log line. Recorded by the boundaries that own that state in
|
||||
# ``bots/_session.py`` / ``bots/_idempotency.py``.
|
||||
try:
|
||||
from praisonai_bot.bots._session import (
|
||||
durability_degraded_owners,
|
||||
)
|
||||
|
||||
for owner in durability_degraded_owners():
|
||||
registry.mark(owner)
|
||||
except Exception:
|
||||
pass
|
||||
degraded_owners = registry.to_list()
|
||||
if degraded_owners:
|
||||
result["degraded_owners"] = degraded_owners
|
||||
@@ -6590,6 +6640,39 @@ class WebSocketGateway:
|
||||
)
|
||||
return None
|
||||
|
||||
def _resolve_profile_for_message(
|
||||
self,
|
||||
channel_name: str,
|
||||
facts: Optional[Any] = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve the per-route tenant profile for an inbound message (#4341).
|
||||
|
||||
Returns the ``profile`` name declared by the matching route binding, or
|
||||
``None`` when no binding matched or the matched route names no profile.
|
||||
The gateway stages this on the session so memory/session state is keyed
|
||||
per tenant for the turn — without it, ``RouteMatch.profile`` resolves but
|
||||
has no runtime effect and every route silently shares one memory store.
|
||||
A ``None`` return fails closed: an unscoped route never borrows another
|
||||
tenant's namespace.
|
||||
"""
|
||||
if facts is None:
|
||||
return None
|
||||
bindings = self._routing_bindings.get(channel_name) or []
|
||||
if not bindings:
|
||||
return None
|
||||
try:
|
||||
from praisonaiagents.gateway import resolve_route
|
||||
|
||||
match = resolve_route(bindings, facts)
|
||||
if getattr(match, "binding", None) is None:
|
||||
return None
|
||||
return getattr(match, "profile", None)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning(
|
||||
f"Profile resolution failed for {channel_name}: {exc}"
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_route_facts(
|
||||
chat_type: str,
|
||||
@@ -7607,6 +7690,19 @@ class WebSocketGateway:
|
||||
channel_name, facts=facts
|
||||
)
|
||||
session.set_pending_tool_policy(agent, tool_policy)
|
||||
# Per-tenant profile scope (Issue #4341): enter the resolved
|
||||
# route's profile namespace so this turn's memory/session state
|
||||
# is keyed per tenant. Staged right before the adapter's own
|
||||
# ``_session.chat()`` in the same synchronous dispatch. ``None``
|
||||
# clears any prior scope so an unscoped route never inherits
|
||||
# another tenant's namespace (fail-closed).
|
||||
if session is not None and hasattr(session, "set_profile_namespace"):
|
||||
session.set_profile_namespace(
|
||||
gateway._resolve_profile_for_message(
|
||||
channel_name, facts=facts
|
||||
),
|
||||
agent,
|
||||
)
|
||||
|
||||
logger.info(f"Injected routing handler for channel '{channel_name}'")
|
||||
|
||||
@@ -7693,10 +7789,25 @@ class WebSocketGateway:
|
||||
tool_policy = gateway._resolve_tool_policy_for_message(
|
||||
channel_name, facts=facts
|
||||
)
|
||||
# Per-tenant profile scope (Issue #4341): enter the resolved route's
|
||||
# profile namespace on the session so this turn's memory/session
|
||||
# state is keyed per tenant. ``None`` clears any prior scope so an
|
||||
# unscoped route never inherits another tenant's namespace.
|
||||
_tg_session = getattr(bot, "_session", None)
|
||||
if _tg_session is not None and hasattr(
|
||||
_tg_session, "set_profile_namespace"
|
||||
):
|
||||
_tg_session.set_profile_namespace(
|
||||
gateway._resolve_profile_for_message(channel_name, facts=facts),
|
||||
agent,
|
||||
)
|
||||
|
||||
# Ack reaction — show processing indicator
|
||||
# Ack reaction — show processing indicator.
|
||||
# Scope-gated like the standalone Telegram adapter so ambient group
|
||||
# chatter the bot won't answer is not acked (Greptile #4293): a
|
||||
# private chat is direct; a reply to the bot counts as a mention.
|
||||
ack_ctx = None
|
||||
if bot._ack.enabled:
|
||||
if bot._ack.enabled and bot._ack.should_ack_message(message):
|
||||
async def _tg_react(emoji, **kw):
|
||||
try:
|
||||
from telegram import ReactionTypeEmoji
|
||||
|
||||
@@ -152,12 +152,33 @@ class TestGatewayConfigWiring:
|
||||
)
|
||||
assert isinstance(srv._get_hook_idem_store(), SqliteIdempotencyStore)
|
||||
|
||||
def test_default_is_memory(self):
|
||||
from praisonaiagents.gateway import InMemoryIdempotencyStore
|
||||
def test_default_is_durable(self, tmp_path, monkeypatch):
|
||||
# Issue #4339: an out-of-box gateway (no ``store_backend`` configured)
|
||||
# must be durable by default so a redelivered webhook after a restart is
|
||||
# suppressed rather than re-processed. ``memory`` is now an explicit
|
||||
# opt-in only.
|
||||
from praisonai_bot.bots import SqliteIdempotencyStore
|
||||
|
||||
monkeypatch.setattr(
|
||||
"pathlib.Path.home", lambda: tmp_path, raising=True
|
||||
)
|
||||
srv = self._bare_server()
|
||||
srv._apply_hooks_from_config({"hooks": []})
|
||||
assert srv._hook_idempotency_backend is None
|
||||
assert isinstance(srv._get_hook_idem_store(), SqliteIdempotencyStore)
|
||||
|
||||
def test_explicit_memory_opt_in(self, tmp_path, monkeypatch):
|
||||
# ``memory`` stays available as an explicit choice for ephemeral runs.
|
||||
from praisonaiagents.gateway import InMemoryIdempotencyStore
|
||||
|
||||
monkeypatch.setattr(
|
||||
"pathlib.Path.home", lambda: tmp_path, raising=True
|
||||
)
|
||||
srv = self._bare_server()
|
||||
srv._apply_hooks_from_config(
|
||||
{"hooks": {"idempotency": {"store_backend": "memory"}, "hooks": []}}
|
||||
)
|
||||
assert srv._hook_idempotency_backend == "memory"
|
||||
assert isinstance(srv._get_hook_idem_store(), InMemoryIdempotencyStore)
|
||||
|
||||
def test_backend_change_rebuilds_store(self, tmp_path, monkeypatch):
|
||||
@@ -168,7 +189,10 @@ class TestGatewayConfigWiring:
|
||||
"pathlib.Path.home", lambda: tmp_path, raising=True
|
||||
)
|
||||
srv = self._bare_server()
|
||||
srv._apply_hooks_from_config({"hooks": []})
|
||||
# Explicit ``memory`` opt-in, then hot-reload to durable sqlite.
|
||||
srv._apply_hooks_from_config(
|
||||
{"hooks": {"idempotency": {"store_backend": "memory"}, "hooks": []}}
|
||||
)
|
||||
assert isinstance(srv._get_hook_idem_store(), InMemoryIdempotencyStore)
|
||||
# Hot-reload flips to sqlite: the cached in-memory store is discarded.
|
||||
srv._apply_hooks_from_config(
|
||||
@@ -195,3 +219,85 @@ class TestBuildIdempotencyStore:
|
||||
from praisonai_bot.bots import build_idempotency_store
|
||||
|
||||
assert isinstance(build_idempotency_store("redis"), InMemoryIdempotencyStore)
|
||||
|
||||
def test_none_backend_is_durable_by_default(self, tmp_path):
|
||||
# Issue #4339: unset backend -> durable SQLite store, not in-memory.
|
||||
from praisonai_bot.bots import SqliteIdempotencyStore, build_idempotency_store
|
||||
|
||||
store = build_idempotency_store(None, path=tmp_path / "idem.sqlite")
|
||||
assert isinstance(store, SqliteIdempotencyStore)
|
||||
|
||||
|
||||
class TestDurabilityDegradation:
|
||||
"""Issue #4339: a durable store that cannot init is a *reported* fact."""
|
||||
|
||||
def test_idempotency_fallback_records_degraded(self, tmp_path):
|
||||
from praisonai_bot.bots import build_idempotency_store
|
||||
from praisonai_bot.bots._session import (
|
||||
clear_durability_degraded,
|
||||
durability_degraded_owners,
|
||||
)
|
||||
from praisonaiagents.gateway import InMemoryIdempotencyStore
|
||||
|
||||
clear_durability_degraded("idempotency")
|
||||
# A path under a file (not a dir) makes the SQLite store fail to open,
|
||||
# exercising the recorded-degradation fallback.
|
||||
bad_parent = tmp_path / "afile"
|
||||
bad_parent.write_text("x")
|
||||
bad_path = bad_parent / "idem.sqlite"
|
||||
store = build_idempotency_store("sqlite", path=bad_path)
|
||||
assert isinstance(store, InMemoryIdempotencyStore)
|
||||
owners = durability_degraded_owners()
|
||||
match = [o for o in owners if o.owner_id == "durability:idempotency"]
|
||||
assert match
|
||||
# #4339: the operator-facing reason must be redacted — the raw store
|
||||
# path (and any backend detail) stays in logs, never in health/status.
|
||||
assert str(bad_path) not in match[0].reason
|
||||
clear_durability_degraded("idempotency")
|
||||
|
||||
def test_successful_build_clears_stale_degradation(self, tmp_path):
|
||||
# #4339: a same-process rebuild (e.g. a config hot-reload) that restores
|
||||
# the durable store must clear a prior degradation so health/status stop
|
||||
# reporting non-durable operation after recovery.
|
||||
from praisonai_bot.bots import (
|
||||
SqliteIdempotencyStore,
|
||||
build_idempotency_store,
|
||||
)
|
||||
from praisonai_bot.bots._session import (
|
||||
clear_durability_degraded,
|
||||
durability_degraded_owners,
|
||||
record_durability_degraded,
|
||||
)
|
||||
|
||||
clear_durability_degraded("idempotency")
|
||||
record_durability_degraded("idempotency", reason="store unavailable")
|
||||
assert any(
|
||||
o.owner_id == "durability:idempotency"
|
||||
for o in durability_degraded_owners()
|
||||
)
|
||||
store = build_idempotency_store("sqlite", path=tmp_path / "idem.sqlite")
|
||||
assert isinstance(store, SqliteIdempotencyStore)
|
||||
assert not any(
|
||||
o.owner_id == "durability:idempotency"
|
||||
for o in durability_degraded_owners()
|
||||
)
|
||||
clear_durability_degraded("idempotency")
|
||||
|
||||
def test_record_and_clear_roundtrip(self):
|
||||
from praisonai_bot.bots._session import (
|
||||
clear_durability_degraded,
|
||||
durability_degraded_owners,
|
||||
record_durability_degraded,
|
||||
)
|
||||
|
||||
clear_durability_degraded("session")
|
||||
record_durability_degraded("session", reason="store unavailable")
|
||||
owners = durability_degraded_owners()
|
||||
match = [o for o in owners if o.owner_id == "durability:session"]
|
||||
assert match and match[0].owner_kind == "gateway"
|
||||
assert match[0].retry_hint == "praisonai gateway doctor --fix"
|
||||
clear_durability_degraded("session")
|
||||
assert not any(
|
||||
o.owner_id == "durability:session"
|
||||
for o in durability_degraded_owners()
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Issue #4341 — Per-tenant profile scope namespaces memory/session state.
|
||||
|
||||
A route (or channel) can name a ``profile``; the gateway stages it on the
|
||||
session via ``set_profile_namespace`` so every ``_storage_key`` for the turn is
|
||||
prefixed with the tenant scope. Two routes multiplexed on one process must never
|
||||
share a transcript, and a route with no profile must stay unscoped (fail-closed)
|
||||
rather than borrow another tenant's namespace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_bot.bots._session import BotSessionManager
|
||||
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self):
|
||||
self.chat_history = []
|
||||
self.calls = []
|
||||
|
||||
def chat(self, prompt):
|
||||
self.calls.append((list(self.chat_history), prompt))
|
||||
self.chat_history.append({"role": "user", "content": prompt})
|
||||
reply = f"reply to {prompt}"
|
||||
self.chat_history.append({"role": "assistant", "content": reply})
|
||||
return reply
|
||||
|
||||
|
||||
class GatedAgent:
|
||||
"""Agent whose sync ``chat`` blocks on a threading gate.
|
||||
|
||||
``chat`` runs on an executor thread, so a ``threading.Event`` lets a test
|
||||
hold one tenant's turn suspended while a second route stages a different
|
||||
profile — proving one turn never persists into the other's namespace.
|
||||
"""
|
||||
|
||||
def __init__(self, gate: "threading.Event"):
|
||||
self.chat_history = []
|
||||
self._gate = gate
|
||||
|
||||
def chat(self, prompt):
|
||||
# Block (on the executor thread) until released so a second turn can
|
||||
# stage its own profile while this turn is in-flight.
|
||||
self._gate.wait(timeout=5)
|
||||
self.chat_history.append({"role": "user", "content": prompt})
|
||||
self.chat_history.append({"role": "assistant", "content": f"re:{prompt}"})
|
||||
return f"re:{prompt}"
|
||||
|
||||
|
||||
class TestProfileStorageKey:
|
||||
def test_unscoped_by_default(self):
|
||||
mgr = BotSessionManager(platform="discord")
|
||||
assert mgr._storage_key("u1") == "u1"
|
||||
|
||||
def test_profile_prefixes_storage_key(self):
|
||||
mgr = BotSessionManager(platform="discord")
|
||||
mgr.set_profile_namespace("acme")
|
||||
assert mgr._storage_key("u1") == "profile:acme:u1"
|
||||
|
||||
def test_distinct_profiles_isolate_same_user(self):
|
||||
mgr = BotSessionManager(platform="discord")
|
||||
mgr.set_profile_namespace("acme")
|
||||
acme_key = mgr._storage_key("u1")
|
||||
mgr.set_profile_namespace("globex")
|
||||
globex_key = mgr._storage_key("u1")
|
||||
assert acme_key != globex_key
|
||||
assert acme_key == "profile:acme:u1"
|
||||
assert globex_key == "profile:globex:u1"
|
||||
|
||||
def test_clear_fails_closed_to_unscoped(self):
|
||||
mgr = BotSessionManager(platform="discord")
|
||||
mgr.set_profile_namespace("acme")
|
||||
mgr.set_profile_namespace(None)
|
||||
assert mgr._storage_key("u1") == "u1"
|
||||
|
||||
def test_blank_profile_is_unscoped(self):
|
||||
mgr = BotSessionManager(platform="discord")
|
||||
mgr.set_profile_namespace(" ")
|
||||
assert mgr._storage_key("u1") == "u1"
|
||||
|
||||
def test_profile_applies_to_per_chat_scope(self):
|
||||
mgr = BotSessionManager(platform="telegram", session_scope="per_chat")
|
||||
mgr.set_profile_namespace("acme")
|
||||
key = mgr._storage_key("alice", chat_id="-100123", chat_type="group")
|
||||
assert key.startswith("profile:acme:")
|
||||
assert ":chat:-100123:" in key
|
||||
|
||||
|
||||
class TestProfileMemoryIsolation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_tenants_do_not_share_history(self):
|
||||
agent = FakeAgent()
|
||||
mgr = BotSessionManager(platform="discord")
|
||||
|
||||
mgr.set_profile_namespace("acme")
|
||||
await mgr.chat(agent, "u1", "acme secret")
|
||||
|
||||
mgr.set_profile_namespace("globex")
|
||||
await mgr.chat(agent, "u1", "globex secret")
|
||||
|
||||
keys = list(mgr._histories)
|
||||
assert "profile:acme:u1" in keys
|
||||
assert "profile:globex:u1" in keys
|
||||
# Same raw user id, but the two tenants never collapse into one key.
|
||||
assert "u1" not in keys
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overlapping_turns_do_not_cross_namespaces(self):
|
||||
"""Two concurrent routed turns must keep their own tenant namespace.
|
||||
|
||||
Regression for the shared-mutable-state race (CodeRabbit/Greptile):
|
||||
turn A stages ``acme`` and suspends inside ``chat()``; turn B then
|
||||
stages ``globex``. The pre-fix code recomputed the storage key from a
|
||||
shared field, so A's save could land in ``globex``. With turn-local
|
||||
(ContextVar) scoping, A persists to ``acme`` and B to ``globex``.
|
||||
"""
|
||||
gate_a = threading.Event()
|
||||
gate_b = threading.Event()
|
||||
agent_a = GatedAgent(gate_a)
|
||||
agent_b = GatedAgent(gate_b)
|
||||
mgr = BotSessionManager(platform="discord")
|
||||
|
||||
async def run(agent, profile):
|
||||
# Passing ``agent`` stages the namespace per-agent, so ``chat()``
|
||||
# re-binds it turn-locally regardless of the parent context.
|
||||
mgr.set_profile_namespace(profile, agent)
|
||||
return await mgr.chat(agent, "u1", f"{profile} msg")
|
||||
|
||||
# Start both turns; each blocks on its gate inside chat() (on an
|
||||
# executor thread), so they are genuinely overlapped and in-flight.
|
||||
task_a = asyncio.create_task(run(agent_a, "acme"))
|
||||
await asyncio.sleep(0.05) # let A stage + enter chat() and block
|
||||
task_b = asyncio.create_task(run(agent_b, "globex"))
|
||||
await asyncio.sleep(0.05) # let B stage + enter chat() and block
|
||||
|
||||
# Release B first, then A — the interleaving that would corrupt keys
|
||||
# under a shared field (A's save would follow B's "globex").
|
||||
gate_b.set()
|
||||
gate_a.set()
|
||||
await asyncio.gather(task_a, task_b)
|
||||
|
||||
keys = set(mgr._histories)
|
||||
assert "profile:acme:u1" in keys
|
||||
assert "profile:globex:u1" in keys
|
||||
# Neither turn leaked into the other tenant's namespace, and no unscoped
|
||||
# key was ever written.
|
||||
assert "u1" not in keys
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Regression test for Signal inbound idle-stall detection (Issue #4289).
|
||||
|
||||
A silently half-open Signal transport keeps returning a successfully-empty
|
||||
``[]`` from the bridge without ever raising. If an empty poll refreshed the
|
||||
inbound-liveness timestamp, ``health.last_activity`` would stay fresh forever
|
||||
and the shared ``evaluate_channel_health`` STALE_SOCKET seam could never flag a
|
||||
"deaf" channel. These tests pin that only a *delivered* envelope advances
|
||||
inbound liveness, so a stalled poll loop remains detectable.
|
||||
"""
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_bot.bots.signal import SignalBot
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload):
|
||||
self.status = 200
|
||||
self._payload = payload
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
async def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
def _bot_with_response(payload):
|
||||
bot = SignalBot(account="+15551234567")
|
||||
session = MagicMock()
|
||||
session.get = MagicMock(return_value=_FakeResponse(payload))
|
||||
bot._http_session = session
|
||||
return bot
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_poll_does_not_refresh_inbound_liveness():
|
||||
bot = _bot_with_response([])
|
||||
bot._last_inbound_activity = None
|
||||
|
||||
await bot._poll_once()
|
||||
|
||||
assert getattr(bot, "_last_inbound_activity", None) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delivered_envelope_refreshes_inbound_liveness():
|
||||
envelope = {"envelope": {"source": "+15550001111", "dataMessage": {"message": "hi"}}}
|
||||
bot = _bot_with_response([envelope])
|
||||
bot._last_inbound_activity = None
|
||||
|
||||
before = time.time()
|
||||
await bot._poll_once()
|
||||
|
||||
assert bot._last_inbound_activity is not None
|
||||
assert bot._last_inbound_activity >= before
|
||||
@@ -194,6 +194,111 @@ def test_prune_marks_store_row_expired(tmp_path):
|
||||
assert row["status"] == "expired"
|
||||
|
||||
|
||||
def test_resolver_identity_recorded_in_audit_trail(tmp_path):
|
||||
"""resolve() records the resolving principal on the durable audit row."""
|
||||
store = ApprovalStore(path=tmp_path / "approvals.sqlite")
|
||||
mgr = ExecApprovalManager(
|
||||
ttl=300, store=store, allowlist_path=tmp_path / "allow.sqlite"
|
||||
)
|
||||
|
||||
async def go():
|
||||
rid, future = await mgr.register(tool_name="shell_exec", arguments={})
|
||||
mgr.resolve(rid, Resolution(approved=True, resolver="operator:alice"))
|
||||
for _ in range(100):
|
||||
if store.pending_count() == 0:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
return rid, await future
|
||||
|
||||
rid, _ = asyncio.run(go())
|
||||
row = store.get(rid)
|
||||
assert row is not None
|
||||
assert row["approver"] == "operator:alice"
|
||||
|
||||
|
||||
def test_resolver_identity_defaults_to_gateway(tmp_path):
|
||||
"""No resolver keeps today's anonymous ``gateway`` attribution."""
|
||||
store = ApprovalStore(path=tmp_path / "approvals.sqlite")
|
||||
mgr = ExecApprovalManager(
|
||||
ttl=300, store=store, allowlist_path=tmp_path / "allow.sqlite"
|
||||
)
|
||||
|
||||
async def go():
|
||||
rid, future = await mgr.register(tool_name="shell_exec", arguments={})
|
||||
mgr.resolve(rid, Resolution(approved=True))
|
||||
for _ in range(100):
|
||||
if store.pending_count() == 0:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
return rid, await future
|
||||
|
||||
rid, _ = asyncio.run(go())
|
||||
assert store.get(rid)["approver"] == "gateway"
|
||||
|
||||
|
||||
def test_authorized_reviewer_binding_rejects_other_operator(tmp_path):
|
||||
"""A bound request cannot be resolved by an unauthorised operator."""
|
||||
mgr = ExecApprovalManager(ttl=300, durable=False)
|
||||
|
||||
async def go():
|
||||
rid, future = await mgr.register(
|
||||
tool_name="deploy",
|
||||
arguments={},
|
||||
authorized_reviewers=["operator:alice"],
|
||||
)
|
||||
# Wrong operator is rejected and the request stays pending.
|
||||
assert mgr.resolve(rid, Resolution(approved=True, resolver="operator:bob")) is False
|
||||
assert mgr.get_pending(rid) is not None
|
||||
# Authorised operator resolves (first-answer-wins preserved).
|
||||
assert mgr.resolve(rid, Resolution(approved=True, resolver="operator:alice")) is True
|
||||
return await future
|
||||
|
||||
res = asyncio.run(go())
|
||||
assert res.approved is True
|
||||
|
||||
|
||||
def test_unbound_request_resolvable_by_anyone(tmp_path):
|
||||
"""Without a reviewer binding any resolver may resolve (legacy behaviour)."""
|
||||
mgr = ExecApprovalManager(ttl=300, durable=False)
|
||||
|
||||
async def go():
|
||||
rid, future = await mgr.register(tool_name="deploy", arguments={})
|
||||
assert mgr.resolve(rid, Resolution(approved=True, resolver="operator:bob")) is True
|
||||
return await future
|
||||
|
||||
assert asyncio.run(go()).approved is True
|
||||
|
||||
|
||||
def test_authorized_reviewers_survive_restart(tmp_path):
|
||||
"""Per-request custody binding is durable across a rehydrate."""
|
||||
store = ApprovalStore(path=tmp_path / "approvals.sqlite")
|
||||
allow_path = tmp_path / "allow.sqlite"
|
||||
|
||||
async def register_only():
|
||||
mgr = ExecApprovalManager(ttl=300, store=store, allowlist_path=allow_path)
|
||||
rid, _ = await mgr.register(
|
||||
tool_name="deploy",
|
||||
arguments={},
|
||||
authorized_reviewers=["operator:alice"],
|
||||
)
|
||||
return rid
|
||||
|
||||
rid = asyncio.run(register_only())
|
||||
|
||||
async def restart_and_check():
|
||||
mgr2 = ExecApprovalManager(ttl=300, store=store, allowlist_path=allow_path)
|
||||
await mgr2.rehydrate()
|
||||
req = mgr2.get_pending(rid)
|
||||
# Wrong operator still rejected after restart.
|
||||
assert mgr2.resolve(rid, Resolution(approved=True, resolver="operator:bob")) is False
|
||||
assert mgr2.resolve(rid, Resolution(approved=True, resolver="operator:alice")) is True
|
||||
return req
|
||||
|
||||
req = asyncio.run(restart_and_check())
|
||||
assert req is not None
|
||||
assert req.authorized_reviewers == frozenset({"operator:alice"})
|
||||
|
||||
|
||||
def test_allow_always_grant_persists_across_manager_restart(tmp_path):
|
||||
"""allow_always via resolve() persists and short-circuits after restart."""
|
||||
store = ApprovalStore(path=tmp_path / "approvals.sqlite")
|
||||
@@ -225,3 +330,51 @@ def test_allow_always_grant_persists_across_manager_restart(tmp_path):
|
||||
rid, res = asyncio.run(restart_fast_path())
|
||||
assert rid == "auto"
|
||||
assert res.approved is True
|
||||
|
||||
|
||||
def test_gateway_backend_forwards_authorized_reviewers(tmp_path):
|
||||
"""The production backend must bind per-request reviewer custody.
|
||||
|
||||
Regression guard: ``GatewayApprovalBackend`` previously dropped
|
||||
``ApprovalRequest.authorized_reviewers`` on the way to ``register()``,
|
||||
leaving every request unbound. This verifies the custody set reaches the
|
||||
manager so an unauthorised operator is rejected end-to-end.
|
||||
"""
|
||||
from praisonaiagents.approval.protocols import ApprovalRequest
|
||||
from praisonai_bot.gateway.gateway_approval import GatewayApprovalBackend
|
||||
|
||||
mgr = ExecApprovalManager(ttl=300, allowlist_path=tmp_path / "allow.sqlite")
|
||||
backend = GatewayApprovalBackend(manager=mgr, timeout=5)
|
||||
|
||||
async def go():
|
||||
request = ApprovalRequest(
|
||||
tool_name="deploy",
|
||||
arguments={"target": "prod"},
|
||||
risk_level="high",
|
||||
agent_name="deployer",
|
||||
authorized_reviewers=["operator:alice"],
|
||||
)
|
||||
task = asyncio.create_task(backend.request_approval(request))
|
||||
# Let the backend register the pending request.
|
||||
await asyncio.sleep(0)
|
||||
pending = mgr.list_pending()
|
||||
assert len(pending) == 1
|
||||
rid = pending[0]["request_id"]
|
||||
|
||||
# The bound pending request carries the custody set.
|
||||
req = mgr.get_pending(rid)
|
||||
assert req is not None
|
||||
assert req.authorized_reviewers == frozenset({"operator:alice"})
|
||||
|
||||
# An unauthorised operator cannot resolve it.
|
||||
assert mgr.resolve(
|
||||
rid, Resolution(approved=True, resolver="operator:bob")
|
||||
) is False
|
||||
# The authorised operator can, completing the awaited request.
|
||||
assert mgr.resolve(
|
||||
rid, Resolution(approved=True, resolver="operator:alice")
|
||||
) is True
|
||||
decision = await task
|
||||
assert decision.approved is True
|
||||
|
||||
asyncio.run(go())
|
||||
@@ -97,3 +97,130 @@ class TestChunkMessage:
|
||||
assert len(chunks) >= 5
|
||||
for chunk in chunks:
|
||||
assert len(chunk) <= 100
|
||||
|
||||
|
||||
class TestEnforceHardCap:
|
||||
"""Tests for enforce_hard_cap — the outbound over-cap guard (Issue #4319)."""
|
||||
|
||||
def _cap(self, chunks, max_length):
|
||||
from praisonai_bot.bots._chunk import enforce_hard_cap
|
||||
return enforce_hard_cap(chunks, max_length)
|
||||
|
||||
def test_under_cap_chunks_unchanged(self):
|
||||
"""Chunks already within the cap pass through untouched."""
|
||||
chunks = ["short one", "short two"]
|
||||
assert self._cap(chunks, 100) == chunks
|
||||
|
||||
def test_over_cap_prose_is_split(self):
|
||||
"""An over-cap plain chunk is split so every piece fits."""
|
||||
big = "word " * 200 # ~1000 chars
|
||||
result = self._cap([big], 100)
|
||||
assert len(result) >= 2
|
||||
for piece in result:
|
||||
assert len(piece) <= 100
|
||||
|
||||
def test_over_cap_fence_is_split_and_rebalanced(self):
|
||||
"""An over-cap code fence is split with each piece a valid fence."""
|
||||
body = "\n".join(f"line {i}" for i in range(400))
|
||||
fence = "```python\n" + body + "\n```"
|
||||
assert len(fence) > 100
|
||||
result = self._cap([fence], 100)
|
||||
assert len(result) >= 2
|
||||
for piece in result:
|
||||
assert len(piece) <= 100
|
||||
# Each emitted piece is a balanced, valid fence.
|
||||
assert piece.count("```") == 2
|
||||
assert piece.startswith("```")
|
||||
assert piece.rstrip().endswith("```")
|
||||
|
||||
def test_over_cap_fence_preserves_language_hint(self):
|
||||
"""The language hint is carried onto every re-opened fence piece."""
|
||||
body = "\n".join(f"row{i}" for i in range(300))
|
||||
fence = "```json\n" + body + "\n```"
|
||||
result = self._cap([fence], 80)
|
||||
assert len(result) >= 2
|
||||
for piece in result:
|
||||
assert piece.startswith("```json")
|
||||
|
||||
def test_no_content_lost_across_split_fence(self):
|
||||
"""The source characters all survive the fence split (order preserved)."""
|
||||
body = "".join(f"payload-{i}\n" for i in range(50)).rstrip()
|
||||
fence = "```\n" + body + "\n```"
|
||||
result = self._cap([fence], 40)
|
||||
# Reassemble the inner bodies (strip the ``` wrappers each piece adds).
|
||||
inner = "".join(
|
||||
piece[3:].split("\n", 1)[1].rsplit("\n```", 1)[0]
|
||||
for piece in result
|
||||
)
|
||||
# Every non-whitespace character of the body is delivered, in order.
|
||||
assert inner.replace("\n", "") == body.replace("\n", "")
|
||||
|
||||
def test_matches_the_reported_pure_fence_failure(self):
|
||||
"""A ~9.8k pure fence is delivered as multiple capped chunks."""
|
||||
body = "\n".join("x" * 60 for _ in range(160))
|
||||
fence = "```\n" + body + "\n```"
|
||||
assert len(fence) > 4096
|
||||
result = self._cap([fence], 4096)
|
||||
assert len(result) >= 2
|
||||
for piece in result:
|
||||
assert len(piece) <= 4096
|
||||
|
||||
def test_mixed_fence_and_prose_not_corrupted(self):
|
||||
"""A closed fence followed by prose must NOT be re-wrapped as code.
|
||||
|
||||
Greptile P1: ``_is_fence_block`` matched anything starting with ``` so a
|
||||
chunk of "closed fence + trailing prose" was wrapped in a fresh fence,
|
||||
rendering the prose as code. The chunk must instead be hard-split as
|
||||
plain text (Issue #4319).
|
||||
"""
|
||||
prose = "This is prose that must stay prose. " * 10
|
||||
chunk = "```python\nprint('hi')\n```\n\n" + prose
|
||||
assert len(chunk) > 100
|
||||
result = self._cap([chunk], 100)
|
||||
combined = "\n".join(result)
|
||||
# The original prose survives verbatim somewhere in the output.
|
||||
assert "This is prose that must stay prose." in combined
|
||||
# No fabricated language-tagged fence wraps the prose.
|
||||
assert combined.count("```python") <= 1
|
||||
for piece in result:
|
||||
assert len(piece) <= 100
|
||||
|
||||
def test_multiple_fences_in_one_chunk_not_rewrapped(self):
|
||||
"""Two fences in one over-cap chunk are hard-split, not merged as code."""
|
||||
chunk = (
|
||||
"```\n" + ("a" * 60) + "\n```\n\n"
|
||||
"prose between fences\n\n"
|
||||
"```\n" + ("b" * 60) + "\n```"
|
||||
)
|
||||
result = self._cap([chunk], 50)
|
||||
combined = "\n".join(result)
|
||||
assert "prose between fences" in combined
|
||||
for piece in result:
|
||||
assert len(piece) <= 50
|
||||
|
||||
|
||||
class TestUtf16LengthUnit:
|
||||
"""Telegram measures length in UTF-16 units, not codepoints (Issue #4319)."""
|
||||
|
||||
def test_emoji_chunks_respect_utf16_cap(self):
|
||||
"""Emoji-heavy text is capped by UTF-16 units so Telegram won't reject it."""
|
||||
from praisonai_bot.bots._chunk import (
|
||||
chunk_message,
|
||||
enforce_hard_cap,
|
||||
_calculate_length,
|
||||
)
|
||||
|
||||
# Each 😀 is 1 codepoint but 2 UTF-16 units. 200 emoji = 400 UTF-16.
|
||||
text = "😀" * 200
|
||||
max_len = 100
|
||||
chunks = chunk_message(text, max_length=max_len, length_unit="utf16")
|
||||
chunks = enforce_hard_cap(chunks, max_len, length_unit="utf16")
|
||||
for chunk in chunks:
|
||||
assert _calculate_length(chunk, "utf16") <= max_len
|
||||
|
||||
def test_calculate_length_utf16_counts_surrogate_pairs(self):
|
||||
"""A supplementary-plane char counts as 2 UTF-16 units, 1 codepoint."""
|
||||
from praisonai_bot.bots._chunk import _calculate_length
|
||||
|
||||
assert _calculate_length("😀", "codepoints") == 1
|
||||
assert _calculate_length("😀", "utf16") == 2
|
||||
@@ -310,6 +310,145 @@ class TestAckReactorIntegration:
|
||||
assert "self._ack.done" in source
|
||||
|
||||
|
||||
class TestAckScopeGating:
|
||||
"""Ack reactions must be scope-gated so busy groups aren't spammed."""
|
||||
|
||||
def test_default_scope_is_group_mentions(self):
|
||||
from praisonai_bot.bots._ack import AckReactor, AckScope
|
||||
|
||||
reactor = AckReactor(ack_emoji="⏳")
|
||||
assert reactor.scope is AckScope.group_mentions
|
||||
|
||||
def test_ambient_group_message_not_acked_by_default(self):
|
||||
from praisonai_bot.bots._ack import AckReactor
|
||||
|
||||
reactor = AckReactor(ack_emoji="⏳")
|
||||
assert not reactor.should_ack(
|
||||
is_direct=False, is_mention=False, is_ambient=True
|
||||
)
|
||||
|
||||
def test_direct_and_mention_acked_by_default(self):
|
||||
from praisonai_bot.bots._ack import AckReactor
|
||||
|
||||
reactor = AckReactor(ack_emoji="⏳")
|
||||
assert reactor.should_ack(is_direct=True, is_mention=False, is_ambient=False)
|
||||
assert reactor.should_ack(is_direct=False, is_mention=True, is_ambient=False)
|
||||
|
||||
def test_scope_off_never_acks_and_disables(self):
|
||||
from praisonai_bot.bots._ack import AckReactor, AckScope
|
||||
|
||||
reactor = AckReactor(ack_emoji="⏳", scope=AckScope.off)
|
||||
assert not reactor.enabled
|
||||
assert not reactor.should_ack(
|
||||
is_direct=True, is_mention=True, is_ambient=False
|
||||
)
|
||||
|
||||
def test_scope_all_acks_ambient(self):
|
||||
from praisonai_bot.bots._ack import AckReactor, AckScope
|
||||
|
||||
reactor = AckReactor(ack_emoji="⏳", scope=AckScope.all)
|
||||
assert reactor.should_ack(
|
||||
is_direct=False, is_mention=False, is_ambient=True
|
||||
)
|
||||
|
||||
def test_scope_direct_only(self):
|
||||
from praisonai_bot.bots._ack import AckReactor, AckScope
|
||||
|
||||
reactor = AckReactor(ack_emoji="⏳", scope=AckScope.direct)
|
||||
assert reactor.should_ack(is_direct=True, is_mention=False, is_ambient=False)
|
||||
assert not reactor.should_ack(
|
||||
is_direct=False, is_mention=True, is_ambient=False
|
||||
)
|
||||
|
||||
def test_scope_group_all_acks_non_ambient(self):
|
||||
from praisonai_bot.bots._ack import AckReactor, AckScope
|
||||
|
||||
reactor = AckReactor(ack_emoji="⏳", scope=AckScope.group_all)
|
||||
assert reactor.should_ack(
|
||||
is_direct=False, is_mention=False, is_ambient=False
|
||||
)
|
||||
assert not reactor.should_ack(
|
||||
is_direct=False, is_mention=False, is_ambient=True
|
||||
)
|
||||
|
||||
def test_empty_emoji_never_acks(self):
|
||||
from praisonai_bot.bots._ack import AckReactor, AckScope
|
||||
|
||||
reactor = AckReactor(ack_emoji="", scope=AckScope.all)
|
||||
assert not reactor.enabled
|
||||
assert not reactor.should_ack(
|
||||
is_direct=True, is_mention=True, is_ambient=False
|
||||
)
|
||||
|
||||
def test_scope_coerce_from_string_and_default(self):
|
||||
from praisonai_bot.bots._ack import AckReactor, AckScope
|
||||
|
||||
assert AckReactor(ack_emoji="⏳", scope="group-all").scope is AckScope.group_all
|
||||
assert AckReactor(ack_emoji="⏳", scope="group_all").scope is AckScope.group_all
|
||||
assert AckReactor(ack_emoji="⏳", scope="bogus").scope is AckScope.group_mentions
|
||||
|
||||
def test_should_ack_message_derives_from_bot_message(self):
|
||||
from praisonai_bot.bots._ack import AckReactor
|
||||
from praisonaiagents.bots.protocols import BotChannel, BotMessage
|
||||
|
||||
reactor = AckReactor(ack_emoji="⏳")
|
||||
|
||||
dm = BotMessage(content="hi", channel=BotChannel(channel_id="c", channel_type="dm"))
|
||||
assert reactor.should_ack_message(dm)
|
||||
|
||||
group = BotMessage(
|
||||
content="ambient", channel=BotChannel(channel_id="g", channel_type="group")
|
||||
)
|
||||
assert not reactor.should_ack_message(group)
|
||||
assert reactor.should_ack_message(group, is_mention=True)
|
||||
|
||||
reply = BotMessage(
|
||||
content="re",
|
||||
channel=BotChannel(channel_id="g", channel_type="group"),
|
||||
reply_to="123",
|
||||
)
|
||||
assert reactor.should_ack_message(reply)
|
||||
|
||||
def test_gateway_telegram_path_scope_gates_ack(self):
|
||||
"""Gateway-hosted Telegram must gate acks by scope, not just ``enabled``.
|
||||
|
||||
Greptile #4293: the gateway's own Telegram message handler previously
|
||||
acked whenever the reactor was ``enabled``, bypassing ``ack_scope`` so
|
||||
ambient group chatter still got a reaction. Assert the handler routes
|
||||
through ``should_ack_message`` like the standalone adapter.
|
||||
"""
|
||||
import inspect
|
||||
from praisonai_bot.gateway import server
|
||||
|
||||
source = inspect.getsource(server)
|
||||
assert "bot._ack.should_ack_message(message)" in source
|
||||
|
||||
def test_slack_thread_reply_treated_as_engaged(self):
|
||||
"""A Slack thread reply is engaged, not ambient, under group-mentions.
|
||||
|
||||
Greptile #4293: when ``mention_required`` is off, a follow-up reply in
|
||||
an ongoing thread (no fresh @mention) is still processed; it must not be
|
||||
classified as ambient or it would silently lose its ack. The adapter
|
||||
treats ``thread_ts`` as a mention; verify the shared contract acks it.
|
||||
"""
|
||||
from praisonai_bot.bots._ack import AckReactor
|
||||
|
||||
reactor = AckReactor(ack_emoji="⏳")
|
||||
# thread reply -> adapter sets is_mention=True, is_ambient=False
|
||||
assert reactor.should_ack(
|
||||
is_direct=False, is_mention=True, is_ambient=False
|
||||
)
|
||||
|
||||
def test_slack_adapter_uses_thread_ts_for_ack(self):
|
||||
"""Slack ack gate must consider ``thread_ts`` when deriving mention."""
|
||||
import inspect
|
||||
from praisonai_bot.bots.slack import SlackBot
|
||||
|
||||
source = inspect.getsource(SlackBot.start)
|
||||
assert 'event.get("thread_ts")' in source
|
||||
assert "_in_thread" in source
|
||||
|
||||
|
||||
class TestWhatsAppRateLimiterIntegration:
|
||||
"""Tests for WhatsApp rate limiter integration."""
|
||||
|
||||
|
||||
@@ -69,6 +69,49 @@ class TestSplitMediaFromOutput:
|
||||
result = split_media_from_output("MEDIA:https://example.com/audio.mp3")
|
||||
assert "https://example.com/audio.mp3" in result["media_urls"]
|
||||
|
||||
def test_blank_line_between_paragraphs_preserved(self):
|
||||
"""A single blank line paragraph separator is preserved (Issue #4319)."""
|
||||
result = split_media_from_output("Paragraph one.\n\nParagraph two.")
|
||||
assert result["text"] == "Paragraph one.\n\nParagraph two."
|
||||
|
||||
def test_load_bearing_blank_line_preserved(self):
|
||||
"""The HTTP header/body separator blank line survives (Issue #4319)."""
|
||||
text = (
|
||||
"POST /v1/messages HTTP/1.1\n"
|
||||
"Host: api.example.com\n"
|
||||
"Content-Type: application/json\n"
|
||||
"\n"
|
||||
'{"model": "x", "messages": []}'
|
||||
)
|
||||
result = split_media_from_output(text)
|
||||
assert result["text"] == text
|
||||
|
||||
def test_multiple_blank_lines_collapsed(self):
|
||||
"""Excess blank runs collapse to one paragraph break (Issue #4319)."""
|
||||
result = split_media_from_output("A\n\n\n\n\nB")
|
||||
assert result["text"] == "A\n\nB"
|
||||
|
||||
def test_no_blank_lines_stripped_without_media(self):
|
||||
"""A reply with no MEDIA: keeps all its blank lines (Issue #4319)."""
|
||||
text = "Line1\n\nLine2\n\nLine3"
|
||||
result = split_media_from_output(text)
|
||||
assert result["text"].count("\n\n") == 2
|
||||
|
||||
def test_blank_lines_preserved_around_media(self):
|
||||
"""Stripping a MEDIA line does not destroy surrounding paragraphs."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||||
temp_path = f.name
|
||||
try:
|
||||
text = f"Intro paragraph.\n\nMEDIA:{temp_path}\n\nOutro paragraph."
|
||||
result = split_media_from_output(text)
|
||||
assert temp_path in result["media_urls"]
|
||||
assert "Intro paragraph." in result["text"]
|
||||
assert "Outro paragraph." in result["text"]
|
||||
# The two real paragraphs remain separated by a blank line.
|
||||
assert "\n\n" in result["text"]
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
|
||||
class TestIsAudioFile:
|
||||
"""Tests for is_audio_file function."""
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Tests for gateway memory-pressure cache eviction wiring (Issue #3804).
|
||||
|
||||
The core planner ``plan_pressure_evictions`` is unit-tested in the core SDK; here
|
||||
we verify the *wrapper enactor* — ``BotSessionManager.sweep_under_pressure`` and
|
||||
``warm_sessions`` — plus the concrete stdlib ``CgroupMemoryPressure`` reader.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_bot.bots._session import BotSessionManager
|
||||
from praisonai_bot.gateway.memory_pressure import CgroupMemoryPressure
|
||||
|
||||
|
||||
class _Store:
|
||||
"""Minimal in-memory SessionStore so sessions count as 'flushed'."""
|
||||
|
||||
def __init__(self):
|
||||
self.saved = {}
|
||||
|
||||
def set_chat_history(self, key, history):
|
||||
self.saved[key] = list(history)
|
||||
|
||||
def get_chat_history(self, key):
|
||||
return list(self.saved.get(key, []))
|
||||
|
||||
def clear_session(self, key):
|
||||
self.saved.pop(key, None)
|
||||
|
||||
|
||||
class _Pressure:
|
||||
"""Scripted MemoryPressureProtocol: fixed budget, decreasing RSS samples."""
|
||||
|
||||
def __init__(self, budget, rss_samples):
|
||||
self._budget = budget
|
||||
self._rss = list(rss_samples)
|
||||
|
||||
def cgroup_limit_mb(self):
|
||||
return self._budget
|
||||
|
||||
def anon_rss_mb(self):
|
||||
# First call returns the initial reading; subsequent calls pop the next
|
||||
# (simulating RSS dropping as caches are evicted).
|
||||
return self._rss.pop(0) if len(self._rss) > 1 else self._rss[0]
|
||||
|
||||
|
||||
def _warm(mgr, key, last_active, flushed=True):
|
||||
"""Seed a warm in-memory cache with a given LRU timestamp.
|
||||
|
||||
``flushed`` mirrors a completed store write; when a store is configured the
|
||||
cache is only rebuildable (and thus evictable) once its last persist
|
||||
succeeded, so tests opt in explicitly.
|
||||
"""
|
||||
mgr._histories[key] = [{"role": "user", "content": "hi"}]
|
||||
mgr._last_active[key] = last_active
|
||||
if flushed and mgr._store is not None:
|
||||
mgr._flushed.add(key)
|
||||
|
||||
|
||||
def test_warm_sessions_reports_flushed_when_store_present():
|
||||
store = _Store()
|
||||
mgr = BotSessionManager(store=store)
|
||||
_warm(mgr, "a", 1.0)
|
||||
warm = mgr.warm_sessions()
|
||||
assert len(warm) == 1
|
||||
assert warm[0].session_id == "a"
|
||||
assert warm[0].flushed is True
|
||||
assert warm[0].in_flight is False
|
||||
|
||||
|
||||
def test_warm_sessions_unflushed_without_store():
|
||||
mgr = BotSessionManager(store=None)
|
||||
_warm(mgr, "a", 1.0)
|
||||
assert mgr.warm_sessions()[0].flushed is False
|
||||
|
||||
|
||||
def test_sweep_evicts_coldest_first_until_within_budget():
|
||||
store = _Store()
|
||||
mgr = BotSessionManager(store=store)
|
||||
_warm(mgr, "cold", 1.0)
|
||||
_warm(mgr, "warm", 100.0)
|
||||
# Budget 1000 MiB, target = 900. Start at 1000 (over), drop to 850 after the
|
||||
# first (coldest) eviction so the sweep stops having shed exactly one cache.
|
||||
mp = _Pressure(1000.0, [1000.0, 850.0])
|
||||
evicted = mgr.sweep_under_pressure(mp, headroom=0.9)
|
||||
assert evicted == 1
|
||||
assert "cold" not in mgr._histories # coldest dropped
|
||||
assert "warm" in mgr._histories # warmest kept
|
||||
|
||||
|
||||
def test_sweep_never_evicts_unflushed_cache():
|
||||
mgr = BotSessionManager(store=None) # no store => nothing rebuildable
|
||||
_warm(mgr, "a", 1.0)
|
||||
mp = _Pressure(100.0, [1000.0])
|
||||
assert mgr.sweep_under_pressure(mp) == 0
|
||||
assert "a" in mgr._histories
|
||||
|
||||
|
||||
def test_sweep_never_evicts_in_flight_cache():
|
||||
store = _Store()
|
||||
mgr = BotSessionManager(store=store)
|
||||
_warm(mgr, "a", 1.0)
|
||||
mgr._in_flight["a"] = 1 # a live turn is executing
|
||||
mp = _Pressure(100.0, [1000.0])
|
||||
assert mgr.sweep_under_pressure(mp) == 0
|
||||
assert "a" in mgr._histories
|
||||
|
||||
|
||||
def test_sweep_noop_when_no_budget():
|
||||
store = _Store()
|
||||
mgr = BotSessionManager(store=store)
|
||||
_warm(mgr, "a", 1.0)
|
||||
mp = _Pressure(None, [1000.0]) # host without a cgroup limit
|
||||
assert mgr.sweep_under_pressure(mp) == 0
|
||||
assert "a" in mgr._histories
|
||||
|
||||
|
||||
def test_sweep_noop_when_within_budget():
|
||||
store = _Store()
|
||||
mgr = BotSessionManager(store=store)
|
||||
_warm(mgr, "a", 1.0)
|
||||
mp = _Pressure(1000.0, [500.0]) # RSS well under 900 target
|
||||
assert mgr.sweep_under_pressure(mp) == 0
|
||||
assert "a" in mgr._histories
|
||||
|
||||
|
||||
def test_soft_evict_leaves_store_intact():
|
||||
store = _Store()
|
||||
store.set_chat_history("a", [{"role": "user", "content": "persisted"}])
|
||||
mgr = BotSessionManager(store=store)
|
||||
_warm(mgr, "a", 1.0)
|
||||
assert mgr._soft_evict("a") is True
|
||||
assert "a" not in mgr._histories # in-memory dropped
|
||||
assert store.get_chat_history("a") # durable copy remains
|
||||
|
||||
|
||||
def test_cgroup_reader_degrades_gracefully(monkeypatch):
|
||||
reader = CgroupMemoryPressure()
|
||||
|
||||
def _boom(*_a, **_k):
|
||||
raise OSError("no cgroup here")
|
||||
|
||||
monkeypatch.setattr("builtins.open", _boom)
|
||||
assert reader.cgroup_limit_mb() is None
|
||||
assert reader.anon_rss_mb() == 0.0
|
||||
|
||||
|
||||
def test_cgroup_reader_parses_v2_max(tmp_path, monkeypatch):
|
||||
reader = CgroupMemoryPressure()
|
||||
limit_file = tmp_path / "memory.max"
|
||||
limit_file.write_text("536870912\n") # 512 MiB
|
||||
|
||||
real_open = open
|
||||
|
||||
def _fake_open(path, *a, **k):
|
||||
if path == "/sys/fs/cgroup/memory.high":
|
||||
raise OSError
|
||||
if path == "/sys/fs/cgroup/memory.max":
|
||||
return real_open(limit_file, *a, **k)
|
||||
raise OSError
|
||||
|
||||
monkeypatch.setattr("builtins.open", _fake_open)
|
||||
assert reader.cgroup_limit_mb() == pytest.approx(512.0)
|
||||
|
||||
|
||||
def test_cgroup_reader_v2_unlimited_is_none(tmp_path, monkeypatch):
|
||||
reader = CgroupMemoryPressure()
|
||||
hi = tmp_path / "memory.high"
|
||||
hi.write_text("max\n")
|
||||
real_open = open
|
||||
|
||||
def _fake_open(path, *a, **k):
|
||||
if path == "/sys/fs/cgroup/memory.high":
|
||||
return real_open(hi, *a, **k)
|
||||
raise OSError
|
||||
|
||||
monkeypatch.setattr("builtins.open", _fake_open)
|
||||
assert reader.cgroup_limit_mb() is None
|
||||
|
||||
|
||||
def test_cgroup_reader_prefers_nested_cgroup_limit(tmp_path, monkeypatch):
|
||||
"""A nested cgroup's lower limit is used instead of the root's (#4296 review)."""
|
||||
reader = CgroupMemoryPressure()
|
||||
proc = tmp_path / "cgroup"
|
||||
proc.write_text("0::/mygroup\n") # v2: process lives in /mygroup
|
||||
nested = tmp_path / "nested_memory.max"
|
||||
nested.write_text("268435456\n") # 256 MiB (the real, lower ceiling)
|
||||
real_open = open
|
||||
|
||||
def _fake_open(path, *a, **k):
|
||||
if path == "/proc/self/cgroup":
|
||||
return real_open(proc, *a, **k)
|
||||
if path == "/sys/fs/cgroup/mygroup/memory.high":
|
||||
raise OSError # no soft limit at the nested level
|
||||
if path == "/sys/fs/cgroup/mygroup/memory.max":
|
||||
return real_open(nested, *a, **k)
|
||||
raise OSError # root files must NOT be consulted once nested resolves
|
||||
|
||||
monkeypatch.setattr("builtins.open", _fake_open)
|
||||
assert reader.cgroup_limit_mb() == pytest.approx(256.0)
|
||||
|
||||
|
||||
def test_sweep_skips_cache_when_last_store_write_failed():
|
||||
"""A caught store-write failure marks the cache unflushed → never evicted.
|
||||
|
||||
Regression for the CodeRabbit data-loss finding: soft-evicting a cache
|
||||
whose only current copy is in memory would restore a stale/empty transcript.
|
||||
"""
|
||||
class _FlakyStore(_Store):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.fail = False
|
||||
|
||||
def set_chat_history(self, key, history):
|
||||
if self.fail:
|
||||
raise RuntimeError("backend down")
|
||||
super().set_chat_history(key, history)
|
||||
|
||||
store = _FlakyStore()
|
||||
mgr = BotSessionManager(store=store)
|
||||
# First successful save marks the session flushed (rebuildable).
|
||||
mgr._save_history("a", [{"role": "user", "content": "one"}])
|
||||
assert mgr.warm_sessions()[0].flushed is True
|
||||
# Next save fails to persist: in-memory diverges, marker must be cleared.
|
||||
store.fail = True
|
||||
mgr._save_history("a", [{"role": "user", "content": "two"}])
|
||||
assert mgr.warm_sessions()[0].flushed is False
|
||||
# Pressure sweep must therefore leave the only current copy intact.
|
||||
mp = _Pressure(100.0, [1000.0])
|
||||
assert mgr.sweep_under_pressure(mp) == 0
|
||||
assert "a" in mgr._histories
|
||||
@@ -604,10 +604,16 @@ class CDPBrowserAgent:
|
||||
return {"success": True}
|
||||
|
||||
elif action_type == "type":
|
||||
# Focus element and type
|
||||
await self._send("Runtime.evaluate", {
|
||||
"expression": f"document.querySelector('{selector}')?.focus()"
|
||||
# Focus element and type; verify the selector matched an element
|
||||
focus_result = await self._send("Runtime.evaluate", {
|
||||
"expression": (
|
||||
f"(function(){{const el=document.querySelector('{selector}');"
|
||||
f"if(!el)return false;el.focus();return true;}})()"
|
||||
),
|
||||
"returnByValue": True
|
||||
})
|
||||
if not focus_result.get("result", {}).get("value"):
|
||||
return {"success": False, "error": f"selector matched no element: {selector}"}
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
# Clear existing text
|
||||
@@ -670,10 +676,16 @@ class CDPBrowserAgent:
|
||||
await asyncio.sleep(1)
|
||||
return {"success": True}
|
||||
else:
|
||||
# Fallback to JS click
|
||||
await self._send("Runtime.evaluate", {
|
||||
"expression": f"document.querySelector('{selector}')?.click()"
|
||||
# Fallback to JS click; verify the selector matched an element
|
||||
click_result = await self._send("Runtime.evaluate", {
|
||||
"expression": (
|
||||
f"(function(){{const el=document.querySelector('{selector}');"
|
||||
f"if(!el)return false;el.click();return true;}})()"
|
||||
),
|
||||
"returnByValue": True
|
||||
})
|
||||
if not click_result.get("result", {}).get("value"):
|
||||
return {"success": False, "error": f"selector matched no element: {selector}"}
|
||||
await asyncio.sleep(1)
|
||||
return {"success": True}
|
||||
|
||||
@@ -689,14 +701,17 @@ class CDPBrowserAgent:
|
||||
return {"success": True}
|
||||
|
||||
elif action_type == "clear_input":
|
||||
# Focus element and clear its value entirely
|
||||
await self._send("Runtime.evaluate", {
|
||||
# Focus element and clear its value entirely; verify it matched
|
||||
clear_result = await self._send("Runtime.evaluate", {
|
||||
"expression": (
|
||||
f"(function(){{const el=document.querySelector('{selector}');"
|
||||
f"if(el){{el.focus();el.value='';"
|
||||
f"el.dispatchEvent(new Event('input',{{bubbles:true}}));}}}})()"
|
||||
)
|
||||
f"if(!el)return false;el.focus();el.value='';"
|
||||
f"el.dispatchEvent(new Event('input',{{bubbles:true}}));return true;}})()"
|
||||
),
|
||||
"returnByValue": True
|
||||
})
|
||||
if not clear_result.get("result", {}).get("value"):
|
||||
return {"success": False, "error": f"selector matched no element: {selector}"}
|
||||
await asyncio.sleep(0.2)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@@ -361,14 +361,36 @@ def _run_alternative_engine(
|
||||
if enable_vision:
|
||||
console.print(f" [cyan]Vision mode: ON[/cyan]")
|
||||
|
||||
# Auto-create screenshot dir when record_video is enabled
|
||||
# Auto-create screenshot dir when record_video is enabled. Only the cdp
|
||||
# engine implements screencast/screenshots, so restrict the auto-dir and
|
||||
# the "Recording to" notice to cdp; playwright/hybrid get a warning below.
|
||||
actual_screenshot_dir = screenshot_dir
|
||||
if record_video and not actual_screenshot_dir:
|
||||
if record_video and not actual_screenshot_dir and engine == "cdp":
|
||||
actual_screenshot_dir = str(Path.home() / ".praisonai" / "browser_screenshots" / datetime.now().strftime("%Y%m%d_%H%M%S"))
|
||||
Path(actual_screenshot_dir).mkdir(parents=True, exist_ok=True)
|
||||
console.print(f" [green]📹 Recording to: {actual_screenshot_dir}[/green]")
|
||||
elif actual_screenshot_dir:
|
||||
elif actual_screenshot_dir and engine == "cdp":
|
||||
console.print(f" [dim]Screenshots: {actual_screenshot_dir}[/dim]")
|
||||
|
||||
# playwright/hybrid honour goal, url, model and max_steps but do not
|
||||
# implement the CDP-only extras. Warn instead of silently discarding them.
|
||||
if engine in ("playwright", "hybrid"):
|
||||
cdp_only = []
|
||||
if enable_vision:
|
||||
cdp_only.append("--vision")
|
||||
if screenshot_dir:
|
||||
cdp_only.append("--screenshots")
|
||||
if record_video:
|
||||
cdp_only.append("--record-video")
|
||||
if max_retries != 3:
|
||||
cdp_only.append("--max-retries")
|
||||
if not record_session:
|
||||
cdp_only.append("--no-record")
|
||||
if cdp_only:
|
||||
console.print(
|
||||
f"[yellow]Ignored on the {engine} engine:[/yellow] {', '.join(cdp_only)}"
|
||||
)
|
||||
console.print("[dim] These options require --engine cdp.[/dim]")
|
||||
console.print()
|
||||
|
||||
async def run():
|
||||
@@ -405,6 +427,7 @@ def _run_alternative_engine(
|
||||
enable_vision=enable_vision or record_video,
|
||||
record_session=record_session,
|
||||
screenshot_dir=actual_screenshot_dir,
|
||||
record_video=record_video,
|
||||
debug=debug,
|
||||
)
|
||||
|
||||
@@ -478,17 +501,19 @@ def run_agent(
|
||||
playwright: Cross-browser automation via Playwright
|
||||
hybrid: Auto-select best available engine
|
||||
|
||||
Features:
|
||||
Features (require --engine cdp; ignored on the default extension engine,
|
||||
which only forwards goal, model and max-steps. Playwright/hybrid honour
|
||||
goal, url, model and max-steps but do not implement these extras):
|
||||
--max-retries: Automatic retry with alternative selectors on failures
|
||||
--vision: Enable vision-based element detection (requires gpt-4o)
|
||||
--screenshots: Save screenshots of each step for debugging
|
||||
--no-record: Disable session recording to database
|
||||
--no-record: Disable session recording (CDP engine only)
|
||||
|
||||
Example:
|
||||
praisonai browser run "Search for PraisonAI on Google"
|
||||
praisonai browser run "task" --engine cdp --headless
|
||||
praisonai browser run "task" --engine hybrid --vision
|
||||
praisonai browser run "task" --screenshots ./screenshots
|
||||
praisonai browser run "task" --engine cdp --screenshots ./screenshots
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
@@ -519,10 +544,39 @@ def run_agent(
|
||||
)
|
||||
return
|
||||
|
||||
# The extension engine forwards only goal, model and max_steps to the bridge;
|
||||
# the extension drives the tab the user already has open. Warn (rather than
|
||||
# silently discard) when engine-only options are set on the default path, so
|
||||
# the user isn't misled into thinking they took effect. Use --engine cdp for
|
||||
# these features.
|
||||
ignored = []
|
||||
if enable_vision:
|
||||
ignored.append("--vision")
|
||||
if screenshot_dir:
|
||||
ignored.append("--screenshots")
|
||||
if record_video:
|
||||
ignored.append("--record-video")
|
||||
if max_retries != 3:
|
||||
ignored.append("--max-retries")
|
||||
if headless:
|
||||
ignored.append("--headless")
|
||||
if extension_path:
|
||||
ignored.append("--extension")
|
||||
if not record:
|
||||
ignored.append("--no-record")
|
||||
if ignored:
|
||||
console.print(
|
||||
f"[yellow]Ignored on the extension engine:[/yellow] {', '.join(ignored)}"
|
||||
)
|
||||
console.print(
|
||||
"[dim] These options require --engine cdp (or playwright/hybrid).[/dim]"
|
||||
)
|
||||
|
||||
console.print("[bold blue]Starting browser agent[/bold blue]")
|
||||
console.print(f" Goal: {goal}")
|
||||
console.print(f" URL: {url}")
|
||||
console.print(f" URL (context only; extension uses the active tab): {url}")
|
||||
console.print(f" Model: {model}")
|
||||
console.print(f" Max steps: {max_steps}")
|
||||
if debug:
|
||||
console.print(f" [dim]Debug mode: ON[/dim]")
|
||||
console.print()
|
||||
@@ -548,7 +602,7 @@ def run_agent(
|
||||
console.print("[dim]Connected to server[/dim]")
|
||||
|
||||
# Send start_session
|
||||
msg = json.dumps({"type": "start_session", "goal": goal, "model": model})
|
||||
msg = json.dumps({"type": "start_session", "goal": goal, "model": model, "max_steps": max_steps})
|
||||
await ws.send(msg)
|
||||
console.print(f"[blue]Sent:[/blue] start_session")
|
||||
|
||||
@@ -647,7 +701,7 @@ def run_agent(
|
||||
try:
|
||||
async with websockets.connect(ws_url, ping_interval=30) as ws:
|
||||
# Start session
|
||||
msg = json.dumps({"type": "start_session", "goal": goal, "model": model})
|
||||
msg = json.dumps({"type": "start_session", "goal": goal, "model": model, "max_steps": max_steps})
|
||||
await ws.send(msg)
|
||||
|
||||
# Wait for session_id
|
||||
|
||||
@@ -536,6 +536,10 @@ class BrowserServer:
|
||||
}
|
||||
|
||||
|
||||
# Per-session step limit (set from the start_session message) so a
|
||||
# per-run --max-steps is honoured, falling back to the server default.
|
||||
session_max_steps = getattr(agent, "max_steps", self.max_steps)
|
||||
|
||||
# Update session
|
||||
if self._sessions:
|
||||
step_number = message.get("step_number", 0)
|
||||
@@ -551,8 +555,8 @@ class BrowserServer:
|
||||
thought=action.get("thought", ""),
|
||||
)
|
||||
|
||||
# Check if done or max steps
|
||||
if action.get("done") or step_number >= self.max_steps:
|
||||
# Check if done or max steps (per-session limit computed above).
|
||||
if action.get("done") or step_number >= session_max_steps:
|
||||
status = "completed" if action.get("done") else "stopped"
|
||||
self._sessions.update_session(session_id, status=status)
|
||||
|
||||
@@ -575,7 +579,7 @@ class BrowserServer:
|
||||
logger.error(f"[SERVER][ERROR] broadcast:server.py → {e}")
|
||||
|
||||
# Also send completion status if done
|
||||
if action.get("done") or message.get("step_number", 0) >= self.max_steps:
|
||||
if action.get("done") or message.get("step_number", 0) >= session_max_steps:
|
||||
status = "completed" if action.get("done") else "stopped"
|
||||
status_msg = {
|
||||
"type": "status",
|
||||
|
||||
@@ -377,13 +377,22 @@ class LazyCommandGroup(TyperGroup):
|
||||
def get_command(self, ctx: click.Context, name: str) -> Optional[click.Command]:
|
||||
"""Lazily import and return the command, tagged with its help category."""
|
||||
command = self._resolve_command(ctx, name)
|
||||
if command is not None and _help_panel_unset(command):
|
||||
# Group the command into a categorised ``--help`` panel. This is the
|
||||
# single hook Typer's rich renderer reads; setting it lazily here keeps
|
||||
# the registry the source of truth without importing every command up
|
||||
# front just to categorise it. Commands that explicitly declare their
|
||||
# own ``rich_help_panel`` keep it.
|
||||
command.rich_help_panel = category_for(name)
|
||||
if command is not None:
|
||||
# ``typer_get_command`` yields ``""`` for a ``typer.Typer(help=…)``
|
||||
# declared without ``name=``, and collapses a single-command app to
|
||||
# that command's own name. Either way the resulting ``command.name``
|
||||
# does not match the registry key the user must actually type, so the
|
||||
# ``--help`` panel renders a blank or wrong name. The registry key is
|
||||
# the source of truth for the typeable name — pin it here.
|
||||
if command.name != name:
|
||||
command.name = name
|
||||
if _help_panel_unset(command):
|
||||
# Group the command into a categorised ``--help`` panel. This is
|
||||
# the single hook Typer's rich renderer reads; setting it lazily
|
||||
# here keeps the registry the source of truth without importing
|
||||
# every command up front just to categorise it. Commands that
|
||||
# explicitly declare their own ``rich_help_panel`` keep it.
|
||||
command.rich_help_panel = category_for(name)
|
||||
return command
|
||||
|
||||
def _resolve_command(self, ctx: click.Context, name: str) -> Optional[click.Command]:
|
||||
@@ -945,7 +954,7 @@ def get_command_names():
|
||||
names.update({"app", "standardise", "standardize"})
|
||||
# Dynamically registered retrieval commands (no static module entry)
|
||||
# NOTE: retrieval_module.register_commands(app) adds these commands dynamically
|
||||
names.update({"index", "query"})
|
||||
names.update({"index", "query", "search"})
|
||||
return names
|
||||
|
||||
def register_commands():
|
||||
|
||||
@@ -1,41 +1,164 @@
|
||||
"""
|
||||
Rules command group for PraisonAI CLI.
|
||||
|
||||
Provides rules management commands.
|
||||
Provides native rules management (list/add/clear) for the standalone
|
||||
``praisonai-code`` terminal product. The underlying storage is core's
|
||||
``praisonaiagents.memory.RulesManager`` (PRAISON.md / CLAUDE.md / AGENTS.md and
|
||||
``.praison/rules/*.md``), so these commands work without the optional
|
||||
``praisonai`` wrapper. The wrapper bridge is retained only as a fallback for
|
||||
environments where core is unexpectedly unavailable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Rules management")
|
||||
|
||||
|
||||
def _rules_manager():
|
||||
"""Return a core ``RulesManager`` for the current workspace, or ``None``.
|
||||
|
||||
Core (``praisonaiagents``) is a hard dependency of ``praisonai-code``, so
|
||||
this normally succeeds on a standalone install. ``None`` is returned only
|
||||
when core is genuinely unavailable, letting the caller fall back to the
|
||||
wrapper bridge with a single clear notice rather than crashing.
|
||||
"""
|
||||
try:
|
||||
from praisonaiagents.memory import RulesManager
|
||||
except ImportError:
|
||||
return None
|
||||
return RulesManager(workspace_path=os.getcwd())
|
||||
|
||||
|
||||
def _fallback(argv: list[str]) -> None:
|
||||
"""Delegate to the wrapper bridge for a rules subcommand (last resort)."""
|
||||
from praisonai_code._wrapper_bridge import run_wrapper_command
|
||||
|
||||
run_wrapper_command(argv, feature="rules")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def rules_list():
|
||||
"""List active rules."""
|
||||
from praisonai_code._wrapper_bridge import run_wrapper_command
|
||||
|
||||
argv = ['rules', 'list']
|
||||
|
||||
run_wrapper_command(argv, feature="rules")
|
||||
manager = _rules_manager()
|
||||
if manager is None:
|
||||
_fallback(['rules', 'list'])
|
||||
return
|
||||
|
||||
try:
|
||||
all_rules = manager.get_all_rules()
|
||||
except Exception as exc: # noqa: BLE001 - never crash the CLI on rules read
|
||||
typer.echo(f"Failed to load rules: {exc}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not all_rules:
|
||||
typer.echo(
|
||||
"No rules found. Create PRAISON.md, CLAUDE.md, AGENTS.md, "
|
||||
"or files in .praison/rules/"
|
||||
)
|
||||
return
|
||||
|
||||
for rule in all_rules:
|
||||
desc = rule.description or ""
|
||||
if len(desc) > 60:
|
||||
desc = desc[:57] + "..."
|
||||
suffix = f" - {desc}" if desc else ""
|
||||
typer.echo(f"{rule.name} [{rule.activation}]{suffix}")
|
||||
|
||||
|
||||
@app.command("add")
|
||||
def rules_add(
|
||||
rule: str = typer.Argument(..., help="Rule to add"),
|
||||
rule: str = typer.Argument(..., help="Rule name"),
|
||||
content: str = typer.Argument(None, help="Rule content"),
|
||||
):
|
||||
"""Add a rule."""
|
||||
from praisonai_code._wrapper_bridge import run_wrapper_command
|
||||
|
||||
argv = ['rules', 'add', rule]
|
||||
|
||||
run_wrapper_command(argv, feature="rules")
|
||||
"""Add a rule (workspace scope)."""
|
||||
manager = _rules_manager()
|
||||
if manager is None:
|
||||
argv = ['rules', 'add', rule]
|
||||
if content:
|
||||
argv.append(content)
|
||||
_fallback(argv)
|
||||
return
|
||||
|
||||
if not content:
|
||||
typer.echo(
|
||||
"Rule content required. Usage: praisonai rules add <name> <content>",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
manager.create_rule(
|
||||
name=rule,
|
||||
content=content,
|
||||
description=f"Rule created via CLI: {rule}",
|
||||
activation="always",
|
||||
scope="workspace",
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - surface a clean error, no traceback
|
||||
typer.echo(f"Failed to add rule '{rule}': {exc}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
typer.echo(f"Rule added: {rule}")
|
||||
|
||||
|
||||
def _workspace_rule_names(manager) -> list[str]:
|
||||
"""Names of rules stored under the workspace ``.praison/rules/`` directory.
|
||||
|
||||
``rules add`` creates ``workspace``-scoped rules; the manager keys every
|
||||
loaded rule as ``"{scope}:{name}"`` and also surfaces discovered project
|
||||
files (``AGENTS.md``/``CLAUDE.md``/``PRAISON.md``), global rules, git-root
|
||||
rules and nested subdir rules. Clearing must therefore be constrained to the
|
||||
``workspace:`` scope so it only removes CLI-created rules and never unlinks
|
||||
the user's hand-authored instruction files or global/nested rules.
|
||||
|
||||
Reads the manager's rule registry directly (public accessors do not expose
|
||||
the scope key) and returns the names under the ``workspace:`` prefix,
|
||||
de-duplicated in insertion order.
|
||||
"""
|
||||
names: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for key in getattr(manager, "_rules", {}):
|
||||
scope, _, name = key.partition(":")
|
||||
if scope == "workspace" and name and name not in seen:
|
||||
seen.add(name)
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
@app.command("clear")
|
||||
def rules_clear():
|
||||
"""Clear all rules."""
|
||||
from praisonai_code._wrapper_bridge import run_wrapper_command
|
||||
|
||||
argv = ['rules', 'clear']
|
||||
|
||||
run_wrapper_command(argv, feature="rules")
|
||||
"""Clear workspace rules created via the CLI.
|
||||
|
||||
Only ``workspace``-scoped rules (those created by ``rules add`` under
|
||||
``.praison/rules/``) are removed. Discovered project files
|
||||
(``AGENTS.md``/``CLAUDE.md``/``PRAISON.md``), global rules, git-root rules
|
||||
and nested subdirectory rules are intentionally left untouched so this
|
||||
command can never destroy hand-authored instructions outside its scope.
|
||||
"""
|
||||
manager = _rules_manager()
|
||||
if manager is None:
|
||||
_fallback(['rules', 'clear'])
|
||||
return
|
||||
|
||||
try:
|
||||
# Ensure the registry reflects on-disk state before enumerating.
|
||||
names = _workspace_rule_names(manager)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
typer.echo(f"Failed to load rules: {exc}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
removed = 0
|
||||
for name in names:
|
||||
try:
|
||||
# Scope the delete to ``workspace`` so a same-named global/subdir
|
||||
# rule is never removed as a side effect.
|
||||
if manager.delete_rule(name, scope="workspace"):
|
||||
removed += 1
|
||||
except Exception: # noqa: BLE001 - skip undeletable rules, keep going
|
||||
continue
|
||||
|
||||
typer.echo(f"Cleared {removed} rule(s).")
|
||||
@@ -2284,8 +2284,10 @@ def _wire_subtree_context_hook(
|
||||
``already_loaded`` so nested files are not duplicated.
|
||||
|
||||
No-op (leaving the default run unchanged) when the up-front rules are
|
||||
disabled via ``--no-rules`` / ``PRAISON_NO_RULES`` or when the optional
|
||||
helper is unavailable. Honours the existing ``PRAISON_CONTEXT_BUDGET``
|
||||
disabled via ``--no-rules`` / ``PRAISON_NO_RULES``. The subtree-context
|
||||
helper is now resident in ``praisonai_code`` so it works on a standalone
|
||||
``pip install praisonai-code`` without the optional ``praisonai_bot`` /
|
||||
``praisonai`` packages. Honours the existing ``PRAISON_CONTEXT_BUDGET``
|
||||
character budget shared with the interactive path.
|
||||
"""
|
||||
import os
|
||||
@@ -2300,15 +2302,18 @@ def _wire_subtree_context_hook(
|
||||
try:
|
||||
import importlib
|
||||
|
||||
# The subtree-context helper is optional. Its canonical home is the
|
||||
# ``praisonai_bot`` support package; the ``praisonai`` wrapper only
|
||||
# re-exports it via a compat shim. Prefer ``praisonai_bot`` so the hook
|
||||
# is available on any install that ships it (matching ``chat``) without
|
||||
# forcing the full wrapper, then fall back to the wrapper shim. Either
|
||||
# way ``run.py`` avoids a module-level ``praisonai`` import (C7 gate).
|
||||
# The subtree-context helper is now resident in ``praisonai_code`` so
|
||||
# the standalone terminal product keeps first-class subtree instruction
|
||||
# loading and ``--instructions`` resolution without the optional
|
||||
# ``praisonai_bot`` support package or the ``praisonai`` wrapper (C7
|
||||
# standalone contract). For backward compatibility we still prefer an
|
||||
# installed ``praisonai_bot``/``praisonai`` copy when present (so a host
|
||||
# that customised it keeps its behaviour), falling back to the resident
|
||||
# implementation otherwise.
|
||||
context_files = None
|
||||
for _mod in ("praisonai_bot.integration.context_files",
|
||||
"praisonai.integration.context_files"):
|
||||
"praisonai.integration.context_files",
|
||||
"praisonai_code.integration.context_files"):
|
||||
try:
|
||||
context_files = importlib.import_module(_mod)
|
||||
break
|
||||
|
||||
@@ -796,9 +796,10 @@ def _render_share_html(session_id: str, transcript_md: str) -> str:
|
||||
def session_share(
|
||||
session_id: str = typer.Argument(..., help="Session ID to share"),
|
||||
redact_level: str = typer.Option(
|
||||
"standard",
|
||||
"strict",
|
||||
"--redact-level",
|
||||
help="Redaction level: 'standard' or 'strict'.",
|
||||
help="Redaction level: 'standard' or 'strict'. Defaults to 'strict' "
|
||||
"because share publishes a transcript for wider distribution.",
|
||||
),
|
||||
):
|
||||
"""Publish a redacted, read-only transcript and return a shareable link.
|
||||
|
||||
@@ -144,6 +144,7 @@ COMMAND_CATEGORIES: dict[str, str] = {
|
||||
"plugins": CATEGORY_ADVANCED,
|
||||
"sandbox": CATEGORY_ADVANCED,
|
||||
"registry": CATEGORY_ADVANCED,
|
||||
"backends": CATEGORY_ADVANCED,
|
||||
"package": CATEGORY_ADVANCED,
|
||||
"command": CATEGORY_ADVANCED,
|
||||
"standardise": CATEGORY_ADVANCED,
|
||||
|
||||
@@ -453,6 +453,12 @@ class PraisonAI:
|
||||
elif args.command.startswith("tests.test") or args.command.startswith("tests/test"): # Argument used for testing purposes
|
||||
print("test")
|
||||
return "test"
|
||||
elif args.command == 'train':
|
||||
# 'train' is a real command handled by the dedicated block below
|
||||
# (see ``if args.command == 'train':``). Do not fall through to the
|
||||
# bare-prompt handler, which would forward the literal word "train"
|
||||
# to an LLM as a chat prompt and never launch the trainer.
|
||||
pass
|
||||
else:
|
||||
# Handle --compare flag for CLI mode comparison
|
||||
if hasattr(args, 'compare') and args.compare:
|
||||
|
||||
@@ -286,13 +286,21 @@ def apply_cli_session_continuity(agent, session_id: str, project_path: Optional[
|
||||
|
||||
history = store.get_chat_history(session_id) or []
|
||||
if history:
|
||||
existing = {(m.get("role"), m.get("content")) for m in agent.chat_history}
|
||||
for msg in history:
|
||||
entry = {"role": msg["role"], "content": msg["content"]}
|
||||
key = (entry["role"], entry["content"])
|
||||
if key not in existing:
|
||||
agent.chat_history.append(entry)
|
||||
existing.add(key)
|
||||
# Restore by position, not by content. Two identical user turns (e.g. a
|
||||
# bare "yes" confirmation repeated across steps) are two distinct turns;
|
||||
# a content-based filter silently deletes legitimate repeats and hands
|
||||
# the model a conversation that never happened (Issue #4243). Only skip
|
||||
# restoration when the stored history is already present verbatim as a
|
||||
# prefix of chat_history, so a double-injection does not duplicate it.
|
||||
stored = [
|
||||
{"role": msg["role"], "content": msg["content"]} for msg in history
|
||||
]
|
||||
current = [
|
||||
{"role": m.get("role"), "content": m.get("content")}
|
||||
for m in agent.chat_history[: len(stored)]
|
||||
]
|
||||
if current != stored:
|
||||
agent.chat_history.extend(stored)
|
||||
agent._auto_save_last_index = len(agent.chat_history)
|
||||
|
||||
# Persist model/agent so a later resume is deterministic regardless of the
|
||||
|
||||
@@ -17,11 +17,14 @@ Design (deliberately lightweight — stdlib only, no new dependencies):
|
||||
* Order matters: process-registered secrets first (most sensitive), then any
|
||||
detected key/token patterns, then absolute paths and the cwd, so a path that
|
||||
is part of a secret is not half-masked.
|
||||
* ``level="standard"`` redacts secrets and absolute paths in every string.
|
||||
* ``level="standard"`` redacts secrets and absolute paths in every string. This
|
||||
includes any ``key: value`` / ``key = value`` pair whose key *contains* a
|
||||
secret word (``api_key``, ``secret``, ``token``, ``password``, ``access_key``),
|
||||
so shapes like ``AWS_SECRET_ACCESS_KEY=…`` and ``STRIPE_SECRET_KEY=…`` are
|
||||
caught, not only keys that end in one.
|
||||
``level="strict"`` additionally masks values that look like credentials in a
|
||||
broader set of shapes (bearer tokens, PEM private-key blocks) and treats any
|
||||
``key: value`` / ``key = value`` pair whose key names a secret as sensitive —
|
||||
trading a little readability for a stronger guarantee when sharing widely.
|
||||
broader set of shapes (bearer tokens, PEM private-key blocks) — trading a
|
||||
little readability for a stronger guarantee when sharing widely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -38,13 +41,21 @@ REDACT_LEVELS: Tuple[str, ...] = ("standard", "strict")
|
||||
# Token/secret shapes worth masking even when never registered as a resolved
|
||||
# secret. Kept intentionally small and high-signal to avoid over-redaction.
|
||||
_SECRET_PATTERNS: Tuple[re.Pattern, ...] = (
|
||||
# Stripe live/test keys (sk_live_/sk_test_) and OpenAI-style sk- keys. The
|
||||
# underscore form must precede the hyphen form so it is matched whole.
|
||||
re.compile(r"sk_(?:live|test)_[A-Za-z0-9]{16,}"), # Stripe secret keys
|
||||
re.compile(r"sk-[A-Za-z0-9_-]{16,}"), # OpenAI-style keys
|
||||
re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), # Slack tokens
|
||||
re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), # GitHub tokens
|
||||
re.compile(r"AKIA[0-9A-Z]{16}"), # AWS access key id
|
||||
re.compile(r"AIza[0-9A-Za-z_-]{30,}"), # Google API key
|
||||
re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"), # JWT
|
||||
re.compile(r"(?i)(?:api[_-]?key|secret|token|password)\s*[:=]\s*['\"]?([A-Za-z0-9_\-]{12,})"),
|
||||
# key=value / key: value where the key *contains* a secret word anywhere
|
||||
# (e.g. AWS_SECRET_ACCESS_KEY, STRIPE_SECRET_KEY), not only ends in one.
|
||||
re.compile(
|
||||
r"(?i)[A-Za-z0-9_.\-]*(?:api[_-]?key|secret|token|password|access[_-]?key)"
|
||||
r"[A-Za-z0-9_.\-]*\s*[:=]\s*['\"]?([^\s'\"]{8,})"
|
||||
),
|
||||
)
|
||||
|
||||
# Extra high-signal shapes only masked under ``strict`` — broader by design, so
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Resident integration helpers for the standalone praisonai-code CLI."""
|
||||
@@ -0,0 +1,707 @@
|
||||
"""AGENTS.md-style context file injection, resident in ``praisonai-code``.
|
||||
|
||||
This is the dependency-free home of the subtree-instruction helper so the
|
||||
terminal-native CLI (``pip install praisonai-code``) can load project
|
||||
instructions and resolve ``--instructions`` sources without importing the
|
||||
optional ``praisonai_bot`` support package or the umbrella ``praisonai``
|
||||
wrapper (C7 standalone contract).
|
||||
|
||||
It relies only on the standard library plus ``praisonaiagents.hooks`` (already a
|
||||
core dependency of ``praisonai-code``). The ``praisonai_bot`` copy remains a
|
||||
backward-compatible mirror for host apps that import it directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob as _glob
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CANDIDATES = ["AGENTS.md", "agents.md", ".agents/AGENTS.md", "CLAUDE.md"]
|
||||
|
||||
# Upper bound on the bytes read from a single remote instruction source. Keeps a
|
||||
# stray large URL from ballooning the system context; oversized bodies are
|
||||
# truncated with a marker rather than failing the run.
|
||||
_REMOTE_MAX_BYTES = 256 * 1024
|
||||
|
||||
# Timeout (seconds) for a single remote instruction fetch. Best-effort: a slow
|
||||
# or unreachable URL is skipped with a warning, never blocking the run.
|
||||
_REMOTE_TIMEOUT = 5.0
|
||||
|
||||
# Opt-in to allow fetching instruction URLs that resolve to private, loopback,
|
||||
# or link-local addresses. Off by default so an auto-loaded project config from
|
||||
# an untrusted checkout cannot make the host contact internal services (SSRF).
|
||||
_ALLOW_LOCAL_URL_ENV = "PRAISONAI_INSTRUCTIONS_ALLOW_LOCAL_URLS"
|
||||
|
||||
# Tool-argument keys that carry a file path across the various file tools
|
||||
# (praisonaiagents ``read_file`` uses ``filepath``; praisonai-code tools use
|
||||
# ``path``/``file_path``; ACP edit tools use ``file_path``). The first present,
|
||||
# string-valued key wins.
|
||||
_PATH_ARG_KEYS = ("filepath", "file_path", "path", "filename", "target_file")
|
||||
|
||||
|
||||
def _get_git_root(start: Path) -> Optional[Path]:
|
||||
"""Resolve the git/project root using the resident CLI resolver.
|
||||
|
||||
Falls back to None when the helper or git is unavailable so discovery
|
||||
simply walks up to the filesystem root.
|
||||
"""
|
||||
try:
|
||||
from praisonai_code.cli.utils import project
|
||||
|
||||
return project.get_git_root(str(start))
|
||||
except (ImportError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
# When no git root is found, cap the walk-up to avoid scanning arbitrary
|
||||
# system directories up to the filesystem root.
|
||||
_MAX_WALK_UP_DEPTH = 10
|
||||
|
||||
|
||||
def _discover_search_dirs(base: Path, walk_up: bool) -> List[Path]:
|
||||
"""Build directories to search, ordered root -> cwd (nearest last).
|
||||
|
||||
Walks up from ``base`` to the git root so nearer, more specific
|
||||
instruction files take precedence by appearing last. When no git root
|
||||
is found, the walk-up is capped at ``_MAX_WALK_UP_DEPTH`` levels to avoid
|
||||
scanning arbitrary system directories.
|
||||
"""
|
||||
base = base.resolve()
|
||||
if not walk_up:
|
||||
return [base]
|
||||
|
||||
git_root = _get_git_root(base)
|
||||
if git_root is not None:
|
||||
git_root = git_root.resolve()
|
||||
|
||||
dirs: List[Path] = []
|
||||
current = base
|
||||
depth = 0
|
||||
while True:
|
||||
dirs.append(current)
|
||||
if git_root and current == git_root:
|
||||
break
|
||||
if current == current.parent:
|
||||
break
|
||||
if git_root is None and depth >= _MAX_WALK_UP_DEPTH:
|
||||
break
|
||||
current = current.parent
|
||||
depth += 1
|
||||
|
||||
# Reverse so root is first and cwd (nearest) is last.
|
||||
dirs.reverse()
|
||||
return dirs
|
||||
|
||||
|
||||
def load_context_files(
|
||||
paths: Optional[List[str]] = None,
|
||||
*,
|
||||
cwd: Optional[Path] = None,
|
||||
walk_up: bool = True,
|
||||
) -> str:
|
||||
"""Load context from AGENTS.md-style files and return combined text.
|
||||
|
||||
Discovery mirrors the configuration resolver: it walks up from ``cwd``
|
||||
to the project boundary (git root) collecting instruction files at each
|
||||
level, layers a user-global file (``~/.praisonai/AGENTS.md``) as the
|
||||
lowest-precedence source, and concatenates root -> cwd so nearer files
|
||||
take precedence (appear last).
|
||||
|
||||
Args:
|
||||
paths: Explicit relative file names to load from ``cwd`` only. When
|
||||
provided, discovery/walk-up is skipped (backward compatible).
|
||||
cwd: Directory to start discovery from (defaults to ``Path.cwd()``).
|
||||
walk_up: When True (default), walk up to the git/project root and
|
||||
layer files. Ignored when ``paths`` is given.
|
||||
|
||||
Returns:
|
||||
Combined instruction text, blank-line separated.
|
||||
"""
|
||||
base = cwd or Path.cwd()
|
||||
|
||||
seen: set = set()
|
||||
chunks: List[str] = []
|
||||
|
||||
def _add(path: Path) -> None:
|
||||
if not path.is_file():
|
||||
return
|
||||
# De-duplicate by filesystem identity so the same physical file is
|
||||
# read once even via different paths or on case-insensitive volumes.
|
||||
try:
|
||||
stat = path.stat()
|
||||
key = (stat.st_dev, stat.st_ino)
|
||||
except OSError:
|
||||
key = path.resolve()
|
||||
if key in seen:
|
||||
return
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return
|
||||
seen.add(key)
|
||||
chunks.append(text)
|
||||
|
||||
# Explicit paths override discovery and remain cwd-only for compatibility.
|
||||
if paths is not None:
|
||||
for name in paths:
|
||||
_add(base / name)
|
||||
return "\n\n".join(chunks)
|
||||
|
||||
# Lowest-precedence layer: user-global instructions
|
||||
# (skipped when walk_up is False to honour cwd-only semantics).
|
||||
if walk_up:
|
||||
_add(Path.home() / ".praisonai" / "AGENTS.md")
|
||||
|
||||
# Walk-up layers: root -> cwd so nearer files take precedence (last).
|
||||
for search_dir in _discover_search_dirs(base, walk_up):
|
||||
for name in DEFAULT_CANDIDATES:
|
||||
_add(search_dir / name)
|
||||
|
||||
return "\n\n".join(chunks)
|
||||
|
||||
|
||||
def _is_remote_source(entry: str) -> bool:
|
||||
"""Whether an instruction entry is an ``http(s)://`` URL."""
|
||||
return entry.startswith(("http://", "https://"))
|
||||
|
||||
|
||||
def _allow_local_urls() -> bool:
|
||||
"""Whether fetching URLs that resolve to internal addresses is opted in."""
|
||||
return os.environ.get(_ALLOW_LOCAL_URL_ENV, "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
|
||||
|
||||
def _is_blocked_ip(addr: str) -> bool:
|
||||
"""Whether a resolved address string is in a non-global (blocked) range."""
|
||||
import ipaddress
|
||||
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr.split("%", 1)[0])
|
||||
except ValueError:
|
||||
return True
|
||||
return (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_reserved
|
||||
or ip.is_multicast
|
||||
or ip.is_unspecified
|
||||
)
|
||||
|
||||
|
||||
def _resolve_public_addrs(host: str) -> Optional[List[str]]:
|
||||
"""Resolve ``host`` and return its addresses only if *all* are global.
|
||||
|
||||
Returns the list of resolved address strings when every resolution is a
|
||||
public/global address, ``None`` when the host is unresolvable (let urlopen
|
||||
surface the failure downstream), or an empty list when any resolved address
|
||||
is in a blocked (private/loopback/link-local/reserved) range so the caller
|
||||
fails closed. Returning the concrete addresses lets the fetch *pin* the
|
||||
connection to a validated IP, closing the DNS-rebinding (TOCTOU) window
|
||||
where a re-resolution at connect time could return an internal address.
|
||||
"""
|
||||
import socket
|
||||
|
||||
if not host:
|
||||
return []
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
except OSError:
|
||||
return None
|
||||
addrs: List[str] = []
|
||||
for info in infos:
|
||||
addr = info[4][0]
|
||||
if _is_blocked_ip(addr):
|
||||
return []
|
||||
addrs.append(addr)
|
||||
return addrs
|
||||
|
||||
|
||||
def _is_blocked_host(host: str) -> bool:
|
||||
"""Whether ``host`` resolves to a private/loopback/link-local/reserved IP.
|
||||
|
||||
SSRF guard: an instruction URL can arrive from an auto-loaded project config
|
||||
in an untrusted checkout, so a URL that resolves to an internal service must
|
||||
not be fetched. Any resolved address in a non-global range blocks the fetch
|
||||
(fail-closed on resolution errors). Bypassable via ``_ALLOW_LOCAL_URL_ENV``
|
||||
for trusted internal setups.
|
||||
"""
|
||||
addrs = _resolve_public_addrs(host)
|
||||
if addrs is None:
|
||||
# Unresolvable — let urlopen surface the failure/warning downstream.
|
||||
return False
|
||||
return len(addrs) == 0
|
||||
|
||||
|
||||
def _url_is_fetchable(url: str) -> bool:
|
||||
"""Validate scheme and destination of a remote instruction URL (SSRF guard)."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
if _allow_local_urls():
|
||||
return True
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
if _is_blocked_host(parsed.hostname or ""):
|
||||
logger.warning(
|
||||
"Skipping remote instruction source %s: resolves to a "
|
||||
"private/loopback/link-local address (set %s=1 to allow)",
|
||||
url,
|
||||
_ALLOW_LOCAL_URL_ENV,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class _PinnedSSRFError(Exception):
|
||||
"""Raised at connect time when a host re-resolves to a blocked address."""
|
||||
|
||||
|
||||
def _build_pinned_opener():
|
||||
"""Build a urllib opener that pins each connection to a validated IP.
|
||||
|
||||
Closes the DNS-rebinding (TOCTOU) window: the up-front
|
||||
:func:`_url_is_fetchable` check resolves the host once, but a plain
|
||||
``urlopen`` re-resolves at connect time, so an attacker-controlled hostname
|
||||
can rebind to an internal address between the two resolutions. Here each
|
||||
``HTTP(S)Connection`` re-resolves *and re-validates* the host at connect
|
||||
time and connects to that exact validated IP, so the address checked is the
|
||||
address contacted. The original hostname is preserved for the ``Host``
|
||||
header and TLS SNI so virtual-hosted and HTTPS endpoints still work.
|
||||
|
||||
Redirects are additionally re-validated by a guarded redirect handler, and
|
||||
the ``_allow_local_urls`` opt-in bypasses pinning entirely for trusted
|
||||
internal setups.
|
||||
"""
|
||||
import http.client
|
||||
import socket
|
||||
import urllib.request
|
||||
|
||||
def _pinned_connect(conn):
|
||||
# Re-validate at the moment of connection so a rebind since the
|
||||
# up-front check is caught, then connect to the validated address.
|
||||
addrs = _resolve_public_addrs(conn.host)
|
||||
if not addrs: # empty (blocked) or None (unresolvable) -> refuse
|
||||
raise _PinnedSSRFError(
|
||||
f"host {conn.host!r} did not resolve to a public address"
|
||||
)
|
||||
last_err: Optional[Exception] = None
|
||||
for addr in addrs:
|
||||
try:
|
||||
conn.sock = socket.create_connection(
|
||||
(addr, conn.port), timeout=conn.timeout
|
||||
)
|
||||
if getattr(conn, "_tunnel_host", None):
|
||||
conn._tunnel()
|
||||
return
|
||||
except OSError as exc: # try the next validated address
|
||||
last_err = exc
|
||||
raise last_err or OSError(f"could not connect to {conn.host!r}")
|
||||
|
||||
class _PinnedHTTPConnection(http.client.HTTPConnection):
|
||||
def connect(self):
|
||||
_pinned_connect(self)
|
||||
|
||||
class _PinnedHTTPSConnection(http.client.HTTPSConnection):
|
||||
def connect(self):
|
||||
_pinned_connect(self)
|
||||
# Wrap the raw socket with TLS using the original hostname for SNI
|
||||
# and certificate verification (not the pinned IP).
|
||||
self.sock = self._context.wrap_socket(
|
||||
self.sock, server_hostname=self.host
|
||||
)
|
||||
|
||||
class _PinnedHTTPHandler(urllib.request.HTTPHandler):
|
||||
def http_open(self, req):
|
||||
return self.do_open(_PinnedHTTPConnection, req)
|
||||
|
||||
class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
|
||||
def https_open(self, req):
|
||||
return self.do_open(_PinnedHTTPSConnection, req)
|
||||
|
||||
class _GuardedRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
if not _url_is_fetchable(newurl):
|
||||
return None
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
return urllib.request.build_opener(
|
||||
_PinnedHTTPHandler(),
|
||||
_PinnedHTTPSHandler(),
|
||||
_GuardedRedirectHandler(),
|
||||
)
|
||||
|
||||
|
||||
def _fetch_remote_source(url: str) -> Optional[str]:
|
||||
"""Fetch a remote instruction source, best-effort and size-bounded.
|
||||
|
||||
Uses the stdlib ``urllib`` (lazily imported) so no heavy dependency is
|
||||
added. Destinations are validated against an SSRF guard (see
|
||||
:func:`_url_is_fetchable`) that rejects private/loopback/link-local hosts,
|
||||
the connection is *pinned* to the validated IP (see
|
||||
:func:`_build_pinned_opener`) so a DNS rebind cannot redirect it to an
|
||||
internal service, and redirects are re-validated so a public URL cannot
|
||||
bounce to an internal one. A slow, unreachable, or oversized response is
|
||||
handled gracefully: failures return ``None`` (with a warning) so the run
|
||||
continues, and bodies larger than ``_REMOTE_MAX_BYTES`` are truncated with a
|
||||
marker.
|
||||
"""
|
||||
if not _url_is_fetchable(url):
|
||||
return None
|
||||
try:
|
||||
if _allow_local_urls():
|
||||
# Trusted internal setup: skip IP pinning so a legitimately private
|
||||
# instruction host is reachable.
|
||||
import urllib.request
|
||||
|
||||
opener = urllib.request.build_opener()
|
||||
else:
|
||||
opener = _build_pinned_opener()
|
||||
with opener.open(url, timeout=_REMOTE_TIMEOUT) as resp: # nosec B310
|
||||
raw = resp.read(_REMOTE_MAX_BYTES + 1)
|
||||
except Exception as exc: # noqa: BLE001 - best-effort; never block the run
|
||||
logger.warning("Skipping remote instruction source %s: %s", url, exc)
|
||||
return None
|
||||
|
||||
truncated = len(raw) > _REMOTE_MAX_BYTES
|
||||
if truncated:
|
||||
raw = raw[:_REMOTE_MAX_BYTES]
|
||||
try:
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
except Exception: # noqa: BLE001 - decode is defensive
|
||||
return None
|
||||
if truncated:
|
||||
text += "\n... [remote instruction source truncated]"
|
||||
return text
|
||||
|
||||
|
||||
def resolve_instruction_sources(
|
||||
entries: Optional[List[str]] = None,
|
||||
*,
|
||||
cwd: Optional[Path] = None,
|
||||
) -> str:
|
||||
"""Resolve config-declared instruction sources into combined text.
|
||||
|
||||
Each entry may be:
|
||||
|
||||
* a plain file path (``docs/rules.md``),
|
||||
* a glob (``docs/standards/*.md``),
|
||||
* a ``~``-prefixed path (``~/company/ai-rules.md``), or
|
||||
* a remote ``http(s)://`` URL.
|
||||
|
||||
Entries are resolved in order and their contents concatenated (blank-line
|
||||
separated), so callers can layer org-wide sources before project-specific
|
||||
ones. Local globs expand to their matches sorted for determinism; a
|
||||
``~``/env-var path is expanded; remote URLs are fetched lazily, best-effort
|
||||
and size-bounded (see :func:`_fetch_remote_source`). Missing local paths and
|
||||
failed fetches are skipped with a warning rather than aborting the run.
|
||||
|
||||
Args:
|
||||
entries: Ordered list of source specifiers. ``None``/empty returns "".
|
||||
cwd: Base directory for resolving relative paths (defaults to CWD).
|
||||
|
||||
Returns:
|
||||
Combined instruction text, blank-line separated (possibly empty).
|
||||
"""
|
||||
if not entries:
|
||||
return ""
|
||||
|
||||
base = Path(cwd) if cwd else Path.cwd()
|
||||
|
||||
seen: Set = set()
|
||||
chunks: List[str] = []
|
||||
|
||||
def _add_file(path: Path) -> None:
|
||||
if not path.is_file():
|
||||
return
|
||||
try:
|
||||
stat = path.stat()
|
||||
key = (stat.st_dev, stat.st_ino)
|
||||
except OSError:
|
||||
key = path.resolve()
|
||||
if key in seen:
|
||||
return
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
logger.warning("Skipping instruction source %s: %s", path, exc)
|
||||
return
|
||||
seen.add(key)
|
||||
chunks.append(text)
|
||||
|
||||
for entry in entries:
|
||||
if not isinstance(entry, str) or not entry.strip():
|
||||
continue
|
||||
entry = entry.strip()
|
||||
|
||||
if _is_remote_source(entry):
|
||||
if entry in seen:
|
||||
continue
|
||||
text = _fetch_remote_source(entry)
|
||||
if text is not None:
|
||||
seen.add(entry)
|
||||
chunks.append(text)
|
||||
continue
|
||||
|
||||
expanded = os.path.expanduser(os.path.expandvars(entry))
|
||||
candidate = Path(expanded)
|
||||
if not candidate.is_absolute():
|
||||
candidate = base / candidate
|
||||
|
||||
# Expand globs (including brace-free ``*``/``?``/``[]`` patterns). When
|
||||
# the entry contains no glob metacharacter this yields the single path.
|
||||
pattern = str(candidate)
|
||||
if any(ch in pattern for ch in "*?["):
|
||||
for match in sorted(_glob.glob(pattern, recursive=True)):
|
||||
_add_file(Path(match))
|
||||
else:
|
||||
if candidate.is_file():
|
||||
_add_file(candidate)
|
||||
else:
|
||||
logger.warning("Instruction source not found: %s", entry)
|
||||
|
||||
return "\n\n".join(chunks)
|
||||
|
||||
|
||||
def _identity_key(path: Path):
|
||||
"""Filesystem identity for dedup (device+inode), falling back to resolved path."""
|
||||
try:
|
||||
stat = path.stat()
|
||||
return (stat.st_dev, stat.st_ino)
|
||||
except OSError:
|
||||
return path.resolve()
|
||||
|
||||
|
||||
class PathContextAttacher:
|
||||
"""Attach the nearest governing instruction files as files are touched.
|
||||
|
||||
Up-front ``load_context_files`` discovery walks ``cwd -> project root`` once
|
||||
at session start. In a monorepo, an agent that later reads/edits a file in a
|
||||
*sibling or deeper* subtree (e.g. ``packages/foo/AGENTS.md``) never sees that
|
||||
subtree's conventions. This attacher augments the up-front load: on the first
|
||||
touch of a directory it walks up to the project root collecting
|
||||
``DEFAULT_CANDIDATES``, deduplicates against files already loaded (both the
|
||||
up-front rules and earlier touches), bounds the result by a character budget,
|
||||
and caches per directory so repeated touches never re-walk disk.
|
||||
|
||||
Session-scoped: create one instance per session/agent run so cache and dedup
|
||||
state stay isolated (multi-agent safe).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
already_loaded: Optional[str] = None,
|
||||
*,
|
||||
max_chars: int = 8000,
|
||||
) -> None:
|
||||
"""Initialise the attacher.
|
||||
|
||||
Args:
|
||||
already_loaded: Text already injected up front (used only to seed
|
||||
dedup so its files are not re-attached). Its identity is tracked
|
||||
by content so duplicate physical files are skipped.
|
||||
max_chars: Character budget for the total text this attacher emits
|
||||
across the session. ``0`` disables the budget.
|
||||
"""
|
||||
self._seen: Set = set()
|
||||
self._seen_texts: Set[str] = set()
|
||||
self._dir_cache: Dict[Path, str] = {}
|
||||
self._max_chars = max_chars
|
||||
self._emitted_chars = 0
|
||||
# Seed dedup with the identities of already-loaded files so the same
|
||||
# physical instruction file discovered up front is not re-attached.
|
||||
if already_loaded:
|
||||
self._seed_from_text(already_loaded)
|
||||
|
||||
def _seed_from_text(self, text: str) -> None:
|
||||
# We cannot recover file identities from text alone, so we record the
|
||||
# text content to skip re-emitting identical bodies. Physical-identity
|
||||
# dedup below still handles the same file reached via different paths.
|
||||
#
|
||||
# ``load_context_files`` joins multiple instruction files with "\n\n",
|
||||
# so seed the whole blob *and* each chunk. This lets an individual file
|
||||
# discovered later match even when it was originally injected inside a
|
||||
# larger concatenated up-front load.
|
||||
self._seen_texts.add(text)
|
||||
for chunk in text.split("\n\n"):
|
||||
if chunk:
|
||||
self._seen_texts.add(chunk)
|
||||
|
||||
def attach_for_path(self, file_path) -> str:
|
||||
"""Return nearest instruction text for ``file_path``'s directory.
|
||||
|
||||
Walks up from the file's directory to the project root collecting
|
||||
instruction files (root first, nearest last), deduplicated against
|
||||
everything already emitted/loaded and bounded by the char budget. The
|
||||
first touch of a directory does the disk walk; later touches of the same
|
||||
directory return the cached (already-deduped) result cheaply.
|
||||
|
||||
Returns an empty string when nothing new is found or the budget is
|
||||
exhausted.
|
||||
"""
|
||||
p = Path(file_path)
|
||||
directory = (p if p.is_dir() else p.parent).resolve()
|
||||
|
||||
if directory in self._dir_cache:
|
||||
return self._dir_cache[directory]
|
||||
|
||||
# Collect newly-discovered files without mutating dedup state yet: if
|
||||
# the char budget discards this result we must not permanently lock the
|
||||
# files out of future touches (they were never actually emitted).
|
||||
chunks: List[str] = []
|
||||
new_keys: List = []
|
||||
local_seen_texts: Set[str] = set()
|
||||
for search_dir in _discover_search_dirs(directory, walk_up=True):
|
||||
for name in DEFAULT_CANDIDATES:
|
||||
candidate = search_dir / name
|
||||
if not candidate.is_file():
|
||||
continue
|
||||
key = _identity_key(candidate)
|
||||
if key in self._seen:
|
||||
continue
|
||||
try:
|
||||
text = candidate.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
if text in self._seen_texts or text in local_seen_texts:
|
||||
self._seen.add(key)
|
||||
continue
|
||||
local_seen_texts.add(text)
|
||||
new_keys.append(key)
|
||||
chunks.append(text)
|
||||
|
||||
result = "\n\n".join(chunks)
|
||||
|
||||
# Enforce the character budget across the whole session, keeping the
|
||||
# truncation marker inside the remaining budget so max_chars holds.
|
||||
if self._max_chars and result:
|
||||
remaining = self._max_chars - self._emitted_chars
|
||||
if remaining <= 0:
|
||||
result = ""
|
||||
elif len(result) > remaining:
|
||||
suffix = "\n... [subtree context truncated]"
|
||||
if remaining > len(suffix):
|
||||
result = result[: remaining - len(suffix)] + suffix
|
||||
else:
|
||||
result = result[:remaining]
|
||||
|
||||
# Only commit dedup state when something was actually emitted, so a
|
||||
# budget-suppressed discovery can still surface if budget frees up.
|
||||
if result:
|
||||
for key in new_keys:
|
||||
self._seen.add(key)
|
||||
self._seen_texts |= local_seen_texts
|
||||
|
||||
self._emitted_chars += len(result)
|
||||
self._dir_cache[directory] = result
|
||||
return result
|
||||
|
||||
|
||||
def load_context_files_for_path(
|
||||
file_path,
|
||||
*,
|
||||
already_loaded: Optional[str] = None,
|
||||
max_chars: int = 8000,
|
||||
) -> str:
|
||||
"""Stateless one-shot discovery of nearest instruction files for a path.
|
||||
|
||||
Convenience wrapper around :class:`PathContextAttacher` for callers that do
|
||||
not maintain session state. Prefer the class when touching many files so
|
||||
dedup and per-directory caching persist across touches.
|
||||
"""
|
||||
return PathContextAttacher(
|
||||
already_loaded, max_chars=max_chars
|
||||
).attach_for_path(file_path)
|
||||
|
||||
|
||||
# Tool names that operate on a file and should trigger subtree-rule discovery.
|
||||
# Covers the real interactive tool surface (praisonaiagents ``read_file``/
|
||||
# ``write_file`` and the ACP ``acp_create_file``/``acp_edit_file``/
|
||||
# ``acp_delete_file`` tools) plus common generic aliases so custom file tools
|
||||
# are matched too.
|
||||
_FILE_TOOL_MATCHER = (
|
||||
r"^(read_file|write_file|edit_file|list_files"
|
||||
r"|acp_create_file|acp_edit_file|acp_delete_file"
|
||||
r"|read|edit|write|apply_patch|str_replace|multi_edit|search_replace)$"
|
||||
)
|
||||
|
||||
|
||||
def _extract_tool_path(tool_input: Any) -> Optional[str]:
|
||||
"""Pull the target file path out of a tool's input arguments.
|
||||
|
||||
Returns the first present, non-empty string value among ``_PATH_ARG_KEYS``
|
||||
so it works across the differing file-tool argument conventions.
|
||||
"""
|
||||
if not isinstance(tool_input, dict):
|
||||
return None
|
||||
for key in _PATH_ARG_KEYS:
|
||||
value = tool_input.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def build_subtree_context_hook(
|
||||
already_loaded: Optional[str] = None,
|
||||
*,
|
||||
max_chars: int = 8000,
|
||||
) -> Callable[[Any], Any]:
|
||||
"""Build an ``AFTER_TOOL`` hook that lazily attaches subtree instructions.
|
||||
|
||||
Wires the :class:`PathContextAttacher` into the neutral core hook
|
||||
substrate: on each file-touching tool call (``read_file``/``edit_file``/
|
||||
``write_file`` and friends) it resolves the nearest instruction file for the
|
||||
touched directory and, if not already loaded/emitted this session, returns it
|
||||
as ``additional_context`` so the agent sees locally-relevant rules the first
|
||||
time it works in that subtree.
|
||||
|
||||
The returned callable is session-scoped (its attacher owns the dedup and
|
||||
per-directory cache), so create one hook per session/agent run. Register it
|
||||
on an agent's hook registry for ``HookEvent.AFTER_TOOL`` (optionally with
|
||||
``matcher=file_tool_matcher()`` to skip non-file tools cheaply).
|
||||
|
||||
Args:
|
||||
already_loaded: Up-front instruction text (used only to seed dedup so
|
||||
files already injected at session start are not re-attached).
|
||||
max_chars: Character budget for the total text emitted across the
|
||||
session. ``0`` disables the budget.
|
||||
|
||||
Returns:
|
||||
A function ``hook(hook_input) -> HookResult | None`` suitable for
|
||||
``HookRegistry.register_function``. Returns ``None`` (no-op) when the
|
||||
tool is not a file tool or nothing new is discovered.
|
||||
"""
|
||||
attacher = PathContextAttacher(already_loaded, max_chars=max_chars)
|
||||
|
||||
def _hook(hook_input: Any):
|
||||
tool_input = getattr(hook_input, "tool_input", None)
|
||||
path = _extract_tool_path(tool_input)
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
context = attacher.attach_for_path(path)
|
||||
except OSError:
|
||||
return None
|
||||
if not context:
|
||||
return None
|
||||
try:
|
||||
from praisonaiagents.hooks import HookResult
|
||||
except ImportError:
|
||||
return None
|
||||
return HookResult(decision="allow", additional_context=context)
|
||||
|
||||
_hook.__name__ = "subtree_instruction_injection"
|
||||
return _hook
|
||||
|
||||
|
||||
def file_tool_matcher() -> str:
|
||||
"""Regex matching file-touching tool names for the subtree-context hook."""
|
||||
return _FILE_TOOL_MATCHER
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Routing parity guard for the CLI command registry.
|
||||
|
||||
Regression test for the ``search`` drift (issue #4326) and the broader #2191
|
||||
guarantee that routing can never drift from the advertised command set: every
|
||||
command surfaced in ``--help`` (``LazyCommandGroup.list_commands``) must also be
|
||||
recognised by the routing oracle (``get_command_names``), otherwise a real,
|
||||
registered command is misclassified as a bare prompt and billed as an LLM call.
|
||||
"""
|
||||
|
||||
from praisonai_code.cli import app as app_mod
|
||||
|
||||
|
||||
def test_advertised_commands_are_all_routed():
|
||||
group = app_mod.LazyCommandGroup()
|
||||
advertised = set(group.list_commands(None))
|
||||
routed = app_mod.get_command_names()
|
||||
missing = advertised - routed
|
||||
assert not missing, f"advertised but not routed: {sorted(missing)}"
|
||||
|
||||
|
||||
def test_search_command_is_routed():
|
||||
group = app_mod.LazyCommandGroup()
|
||||
assert "search" in group.list_commands(None)
|
||||
assert "search" in app_mod.get_command_names()
|
||||
@@ -83,6 +83,26 @@ def test_get_command_tags_help_panel():
|
||||
assert cmd.rich_help_panel == expected
|
||||
|
||||
|
||||
def test_resolved_commands_keep_their_typeable_name():
|
||||
"""Every resolvable command must render under the name a user can type.
|
||||
|
||||
Regression for issue #4323: ``typer_get_command`` yields ``""`` for a
|
||||
``typer.Typer(help=…)`` declared without ``name=`` and collapses a
|
||||
single-command app to that command's own name, so the ``--help`` panel
|
||||
printed blank or wrong names. ``get_command`` must pin ``command.name`` to
|
||||
the registry key.
|
||||
"""
|
||||
ctx = _root_context()
|
||||
root = ctx.command
|
||||
for name in root.list_commands(ctx):
|
||||
cmd = root.get_command(ctx, name)
|
||||
if cmd is None:
|
||||
continue
|
||||
assert cmd.name == name, (
|
||||
f"command {name!r} resolved with mismatched name {cmd.name!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_help_output_is_grouped_not_flat():
|
||||
"""The rendered help groups commands into categorised panels."""
|
||||
result = _invoke_help()
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Regression tests for `run --continue` history restoration (Issue #4243).
|
||||
|
||||
`apply_cli_session_continuity` restored history using a content-based filter, so
|
||||
repeated turns (the common case being a bare "yes" confirmation) were silently
|
||||
dropped and the model resumed onto a conversation that never happened. History
|
||||
must be restored by position: two identical turns are two turns.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(tmp_path, monkeypatch):
|
||||
"""Isolated project dir + sessions home under the test sandbox."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / ".praison_home"))
|
||||
import praisonaiagents.paths as _paths
|
||||
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
|
||||
def _fake_get_sessions_dir():
|
||||
sessions_dir.mkdir(parents=True, exist_ok=True)
|
||||
return sessions_dir
|
||||
|
||||
monkeypatch.setattr(_paths, "get_sessions_dir", _fake_get_sessions_dir)
|
||||
monkeypatch.setattr(
|
||||
"praisonaiagents.session.store.get_sessions_dir", _fake_get_sessions_dir
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
def __init__(self):
|
||||
self.chat_history = []
|
||||
self.name = "tester"
|
||||
self.llm = "gpt-4o-mini"
|
||||
|
||||
|
||||
def _store(session_id, contents):
|
||||
from praisonai_code.cli.state.project_sessions import get_project_session_store
|
||||
|
||||
store = get_project_session_store()
|
||||
for i, content in enumerate(contents):
|
||||
role = "user" if i % 2 == 0 else "assistant"
|
||||
store.add_message(session_id, role, content)
|
||||
return store
|
||||
|
||||
|
||||
def test_repeated_confirmations_survive_a_continue(project):
|
||||
"""'yes' three times is three turns; collapsing them corrupts the history."""
|
||||
from praisonai_code.cli.state.project_sessions import apply_cli_session_continuity
|
||||
|
||||
_store(
|
||||
"sess-yes",
|
||||
["run the tests", "ok", "yes", "done 1", "yes", "done 2", "yes", "done 3"],
|
||||
)
|
||||
agent = _FakeAgent()
|
||||
apply_cli_session_continuity(agent, "sess-yes")
|
||||
|
||||
assert len(agent.chat_history) == 8
|
||||
assert [m["content"] for m in agent.chat_history].count("yes") == 3
|
||||
|
||||
|
||||
def test_auto_save_index_matches_full_restored_length(project):
|
||||
"""The auto-save offset must equal the full restored length, not a deduped one."""
|
||||
from praisonai_code.cli.state.project_sessions import apply_cli_session_continuity
|
||||
|
||||
_store("sess-idx", ["a", "b", "a", "b"])
|
||||
agent = _FakeAgent()
|
||||
apply_cli_session_continuity(agent, "sess-idx")
|
||||
|
||||
assert agent._auto_save_last_index == len(agent.chat_history) == 4
|
||||
|
||||
|
||||
def test_no_duplicate_when_history_already_present(project):
|
||||
"""If chat_history already holds the stored history verbatim, do not re-add it."""
|
||||
from praisonai_code.cli.state.project_sessions import apply_cli_session_continuity
|
||||
|
||||
_store("sess-dup", ["x", "y"])
|
||||
agent = _FakeAgent()
|
||||
agent.chat_history = [
|
||||
{"role": "user", "content": "x"},
|
||||
{"role": "assistant", "content": "y"},
|
||||
]
|
||||
apply_cli_session_continuity(agent, "sess-dup")
|
||||
|
||||
assert len(agent.chat_history) == 2
|
||||
@@ -120,6 +120,56 @@ def test_strict_masks_bearer_and_pem_but_standard_does_not():
|
||||
assert "[redacted:secret:" in strict
|
||||
|
||||
|
||||
def test_stripe_live_and_test_keys_are_redacted():
|
||||
"""sk_live_/sk_test_ shapes must be masked, not only sk- (Issue #4243)."""
|
||||
# Build the credential shapes at runtime so no scannable secret literal
|
||||
# lives in source (keeps secret scanners quiet) while still exercising the
|
||||
# redaction regex with a matching string.
|
||||
live_key = "sk_" + "live_" + "51H8xQ2KZvB9nLmPqRsTuVwXy"
|
||||
test_key = "sk_" + "test_" + "ABCDEF0123456789ABCDEF"
|
||||
payload = {
|
||||
"chat_history": [
|
||||
{"role": "user", "content": f"key {live_key} here"},
|
||||
{"role": "user", "content": f"and {test_key} too"},
|
||||
]
|
||||
}
|
||||
dumped = json.dumps(redact_transcript(payload))
|
||||
assert live_key not in dumped
|
||||
assert test_key not in dumped
|
||||
assert "[redacted:secret:" in dumped
|
||||
|
||||
|
||||
def test_secret_named_keys_are_redacted_when_key_contains_secret_word():
|
||||
"""key=value where the key *contains* a secret word (not only ends in one)
|
||||
must be masked — AWS_SECRET_ACCESS_KEY / STRIPE_SECRET_KEY (Issue #4243)."""
|
||||
# Assemble fixture values at runtime so no literal secret is committed.
|
||||
aws_value = "wJalrXUtnFEMI" + "/" + "K7MDENGbPxRfiCY"
|
||||
stripe_value = "sk_" + "live_" + "51H8xQ2KZvB9nLmPqRsTuVwXy"
|
||||
payload = {
|
||||
"chat_history": [
|
||||
{"role": "user", "content": f"AWS_SECRET_ACCESS_KEY={aws_value}"},
|
||||
{"role": "user", "content": f"STRIPE_SECRET_KEY={stripe_value}"},
|
||||
]
|
||||
}
|
||||
dumped = json.dumps(redact_transcript(payload))
|
||||
assert "wJalrXUtnFEMI" not in dumped
|
||||
assert stripe_value not in dumped
|
||||
assert "[redacted:secret:" in dumped
|
||||
|
||||
|
||||
def test_aws_secret_value_is_not_half_masked_by_path():
|
||||
"""A secret value containing '/' must be masked whole, not leave a visible
|
||||
prefix with the tail eaten by the path regex (Issue #4243)."""
|
||||
aws_value = "wJalrXUtnFEMI" + "/" + "K7MDENGbPxRfiCY"
|
||||
payload = {
|
||||
"chat_history": [
|
||||
{"role": "user", "content": f"AWS_SECRET_ACCESS_KEY={aws_value}"}
|
||||
]
|
||||
}
|
||||
dumped = json.dumps(redact_transcript(payload, level="strict"))
|
||||
assert "wJalrXUtnFEMI" not in dumped
|
||||
|
||||
|
||||
def test_invalid_level_raises():
|
||||
with pytest.raises(ValueError):
|
||||
redact_transcript(_fixture_payload(), level="bogus")
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Standalone regression tests for project instructions & rules (issue #4337).
|
||||
|
||||
On a lean ``pip install praisonai-code`` (no ``praisonai`` wrapper, no
|
||||
``praisonai_bot`` support package) the terminal product must still:
|
||||
|
||||
* attach subtree ``AGENTS.md`` on cross-directory file reads,
|
||||
* resolve ``--instructions`` globs/URLs up front, and
|
||||
* run ``praisonai rules list/add/clear`` natively.
|
||||
|
||||
These tests simulate the absence of the optional packages by blocking their
|
||||
import so the resident ``praisonai_code`` implementation is exercised.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
# Import the command modules up front (with the optional packages available) so
|
||||
# the ``no_optional_packages`` fixture only affects the *runtime* helper lookup
|
||||
# inside ``_wire_subtree_context_hook`` / the rules handler, not their import.
|
||||
from praisonai_code.cli.commands.run import _wire_subtree_context_hook
|
||||
from praisonai_code.cli.commands import rules as rules_cmd
|
||||
from praisonai_code.integration import context_files as _resident_ctx # noqa: F401
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_optional_packages(monkeypatch):
|
||||
"""Make ``praisonai_bot`` and the ``praisonai`` wrapper un-importable.
|
||||
|
||||
Simulates a standalone ``praisonai-code`` install so the resident helper
|
||||
(``praisonai_code.integration.context_files``) is the only one available at
|
||||
runtime. ``praisonai_code`` itself is a distinct top-level package and is
|
||||
unaffected.
|
||||
"""
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
for name in list(sys.modules):
|
||||
if name == "praisonai_bot" or name.startswith("praisonai_bot."):
|
||||
monkeypatch.delitem(sys.modules, name, raising=False)
|
||||
if name == "praisonai" or name.startswith("praisonai."):
|
||||
monkeypatch.delitem(sys.modules, name, raising=False)
|
||||
|
||||
real_import_module = importlib.import_module
|
||||
|
||||
def _blocked_import_module(name, *args, **kwargs):
|
||||
if name == "praisonai_bot" or name.startswith("praisonai_bot."):
|
||||
raise ImportError(f"blocked in standalone test: {name}")
|
||||
if name == "praisonai" or name.startswith("praisonai."):
|
||||
raise ImportError(f"blocked in standalone test: {name}")
|
||||
return real_import_module(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(importlib, "import_module", _blocked_import_module)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def monorepo(tmp_path, monkeypatch):
|
||||
"""A monorepo with a root and a nested package, with a fake git root."""
|
||||
root = tmp_path / "repo"
|
||||
foo = root / "packages" / "foo"
|
||||
foo.mkdir(parents=True)
|
||||
(root / "AGENTS.md").write_text("ROOT RULES")
|
||||
(foo / "AGENTS.md").write_text("FOO RULES")
|
||||
# Force deterministic git-root detection on the *resident* helper.
|
||||
monkeypatch.setattr(
|
||||
"praisonai_code.integration.context_files._get_git_root",
|
||||
lambda start: root,
|
||||
)
|
||||
return root, foo
|
||||
|
||||
|
||||
def _fire_after_tool(registry, tool_input):
|
||||
from praisonaiagents.hooks import HookEvent
|
||||
|
||||
event = SimpleNamespace(tool_input=tool_input)
|
||||
contexts = []
|
||||
for hook in registry.get_hooks(HookEvent.AFTER_TOOL):
|
||||
func = getattr(hook, "func", hook)
|
||||
result = func(event)
|
||||
if result is not None and getattr(result, "additional_context", None):
|
||||
contexts.append(result.additional_context)
|
||||
return "\n".join(contexts)
|
||||
|
||||
|
||||
def test_subtree_hook_wired_without_optional_packages(
|
||||
monorepo, no_optional_packages
|
||||
):
|
||||
"""Subtree AGENTS.md attaches on cross-dir reads with no wrapper/bot."""
|
||||
_root, foo = monorepo
|
||||
agent_config = {"name": "RunAgent"}
|
||||
|
||||
_wire_subtree_context_hook(agent_config)
|
||||
|
||||
registry = agent_config.get("hooks")
|
||||
assert registry is not None, "resident subtree hook must register standalone"
|
||||
|
||||
context = _fire_after_tool(registry, {"file_path": str(foo / "bar.py")})
|
||||
assert "FOO RULES" in context
|
||||
|
||||
|
||||
def test_instruction_sources_resolved_without_optional_packages(
|
||||
tmp_path, monkeypatch, no_optional_packages
|
||||
):
|
||||
"""``--instructions`` globs resolve natively on a standalone install."""
|
||||
monkeypatch.setattr(
|
||||
"praisonai_code.integration.context_files._get_git_root",
|
||||
lambda start: tmp_path,
|
||||
)
|
||||
(tmp_path / "a.md").write_text("ORG STANDARD A")
|
||||
(tmp_path / "b.md").write_text("ORG STANDARD B")
|
||||
|
||||
agent_config = {"name": "RunAgent"}
|
||||
_wire_subtree_context_hook(
|
||||
agent_config, instructions=[str(tmp_path / "*.md")]
|
||||
)
|
||||
|
||||
backstory = agent_config.get("backstory") or ""
|
||||
assert "ORG STANDARD A" in backstory
|
||||
assert "ORG STANDARD B" in backstory
|
||||
assert "# Project Instructions" in backstory
|
||||
|
||||
|
||||
def test_resident_context_files_is_dependency_free(no_optional_packages):
|
||||
"""The resident helper imports and exposes the expected API standalone."""
|
||||
from praisonai_code.integration import context_files
|
||||
|
||||
assert callable(context_files.resolve_instruction_sources)
|
||||
assert callable(context_files.build_subtree_context_hook)
|
||||
assert callable(context_files.file_tool_matcher)
|
||||
|
||||
|
||||
# --- Native rules command --------------------------------------------------
|
||||
|
||||
|
||||
def test_rules_list_native_without_wrapper(tmp_path, monkeypatch, capsys):
|
||||
"""``rules list`` uses core RulesManager and never touches the wrapper."""
|
||||
(tmp_path / "AGENTS.md").write_text("be concise")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
def _boom(*_a, **_k):
|
||||
raise AssertionError("wrapper fallback must not be used standalone")
|
||||
|
||||
monkeypatch.setattr(rules_cmd, "_fallback", _boom)
|
||||
|
||||
rules_cmd.rules_list()
|
||||
out = capsys.readouterr().out
|
||||
# AGENTS.md is discovered as a root instruction rule.
|
||||
assert "AGENTS" in out or "agents" in out.lower()
|
||||
|
||||
|
||||
def test_rules_add_then_list_native(tmp_path, monkeypatch, capsys):
|
||||
"""``rules add`` creates a workspace rule surfaced by ``rules list``."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
rules_cmd,
|
||||
"_fallback",
|
||||
lambda *_a, **_k: (_ for _ in ()).throw(AssertionError("no fallback")),
|
||||
)
|
||||
|
||||
rules_cmd.rules_add("style", "use tabs")
|
||||
add_out = capsys.readouterr().out
|
||||
assert "style" in add_out
|
||||
|
||||
rules_cmd.rules_list()
|
||||
list_out = capsys.readouterr().out
|
||||
assert "style" in list_out
|
||||
|
||||
|
||||
def test_rules_clear_native(tmp_path, monkeypatch, capsys):
|
||||
"""``rules clear`` removes CLI-created rules."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
rules_cmd,
|
||||
"_fallback",
|
||||
lambda *_a, **_k: (_ for _ in ()).throw(AssertionError("no fallback")),
|
||||
)
|
||||
|
||||
rules_cmd.rules_add("style", "use tabs")
|
||||
capsys.readouterr()
|
||||
|
||||
rules_cmd.rules_clear()
|
||||
clear_out = capsys.readouterr().out
|
||||
assert "Cleared" in clear_out
|
||||
|
||||
|
||||
def test_rules_clear_preserves_project_instruction_files(
|
||||
tmp_path, monkeypatch, capsys
|
||||
):
|
||||
"""``rules clear`` must NOT delete hand-authored AGENTS.md/CLAUDE.md.
|
||||
|
||||
Only ``workspace``-scoped rules created via ``rules add`` should be removed;
|
||||
discovered project instruction files and global/nested rules are left
|
||||
untouched (regression for the destructive over-clear bug).
|
||||
"""
|
||||
agents = tmp_path / "AGENTS.md"
|
||||
claude = tmp_path / "CLAUDE.md"
|
||||
agents.write_text("be concise")
|
||||
claude.write_text("prefer stdlib")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
rules_cmd,
|
||||
"_fallback",
|
||||
lambda *_a, **_k: (_ for _ in ()).throw(AssertionError("no fallback")),
|
||||
)
|
||||
|
||||
rules_cmd.rules_add("style", "use tabs")
|
||||
capsys.readouterr()
|
||||
|
||||
rules_cmd.rules_clear()
|
||||
|
||||
# The CLI-created workspace rule is gone, but the user's instruction files
|
||||
# must survive.
|
||||
assert agents.exists(), "rules clear deleted a hand-authored AGENTS.md"
|
||||
assert claude.exists(), "rules clear deleted a hand-authored CLAUDE.md"
|
||||
|
||||
|
||||
def test_remote_fetch_pins_to_validated_ip(monkeypatch):
|
||||
"""The pinned opener re-validates at connect time (DNS-rebinding guard).
|
||||
|
||||
Simulate a rebind: the up-front check sees a public address, but the
|
||||
connect-time resolution returns a loopback address. The pinned connection
|
||||
must refuse rather than contact the internal address.
|
||||
"""
|
||||
from praisonai_code.integration import context_files as ctx
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def _rebinding_resolver(host):
|
||||
# First resolution (up-front validation) is public; a later resolution
|
||||
# (connect time) rebinds to loopback -> must be refused.
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return ["93.184.216.34"]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(ctx, "_resolve_public_addrs", _rebinding_resolver)
|
||||
monkeypatch.setattr(ctx, "_allow_local_urls", lambda: False)
|
||||
|
||||
# Up-front guard passes on the first (public) resolution.
|
||||
assert ctx._url_is_fetchable("http://rebind.example/instructions.md")
|
||||
|
||||
# But the pinned connect re-resolves and refuses the rebound address.
|
||||
result = ctx._fetch_remote_source(
|
||||
"http://rebind.example/instructions.md"
|
||||
)
|
||||
assert result is None
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Regression test for issue #4314: ``train`` must reach the trainer, not chat.
|
||||
|
||||
The legacy dispatcher's ``if args.command:`` chain had no ``train`` case, so
|
||||
``args.command == 'train'`` fell through to the bare-prompt handler and the
|
||||
literal word "train" was forwarded to an LLM as a chat prompt — the real
|
||||
training block was never reached and no trainer subprocess was launched.
|
||||
|
||||
This drives the *real* router (unlike the wholesale-``PraisonAI`` stub in
|
||||
``praisonai-train``) and asserts the dataset config is generated and the trainer
|
||||
invocation is dispatched, with ``handle_direct_prompt`` never called.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_module():
|
||||
try:
|
||||
import praisonai_code.cli.legacy.praison_ai as pa
|
||||
except ImportError as exc: # pragma: no cover - depends on optional wrapper
|
||||
pytest.skip(f"legacy dispatcher unavailable: {exc}")
|
||||
return pa
|
||||
|
||||
|
||||
def test_train_command_reaches_trainer_not_direct_prompt(monkeypatch, tmp_path):
|
||||
pa = _load_module()
|
||||
|
||||
# parse_args() needs the wrapper's argparse builder; skip if absent.
|
||||
try:
|
||||
from praisonai_code._wrapper_bridge import import_wrapper_module
|
||||
import_wrapper_module("praisonai.cli.legacy.dispatch.argparse_builder")
|
||||
except ImportError as exc: # pragma: no cover - depends on optional wrapper
|
||||
pytest.skip(f"wrapper argparse builder unavailable: {exc}")
|
||||
|
||||
calls = {"hdp": [], "train_argv": None, "config": None}
|
||||
|
||||
def fake_hdp(self, *a, **k):
|
||||
calls["hdp"].append(a)
|
||||
return "PROMPTED"
|
||||
|
||||
def fake_stream(cmd, env=None):
|
||||
calls["train_argv"] = list(cmd)
|
||||
|
||||
def fake_gen_config(**kw):
|
||||
calls["config"] = kw
|
||||
return {"model_name": kw.get("model_name") or ""}
|
||||
|
||||
monkeypatch.setattr(pa, "TRAIN_AVAILABLE", True)
|
||||
monkeypatch.setattr(pa.PraisonAI, "handle_direct_prompt", fake_hdp)
|
||||
monkeypatch.setattr(pa, "stream_subprocess", fake_stream)
|
||||
monkeypatch.setattr(pa, "_get_generate_config", lambda: fake_gen_config)
|
||||
# No conda: force the direct-python execution branch deterministically.
|
||||
import subprocess as _sp
|
||||
monkeypatch.setattr(_sp, "check_output", lambda *a, **k: (_ for _ in ()).throw(FileNotFoundError()))
|
||||
|
||||
dataset = tmp_path / "my_sft.jsonl"
|
||||
dataset.write_text('{"instruction": "x"}\n')
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
# parse_args() short-circuits to empty argv under a detected test env
|
||||
# (argparse_builder parses ``[]`` when in_test_env). Present a genuine
|
||||
# ``praisonai train ...`` argv so the real router parses ``command='train'``.
|
||||
import sys as _sys
|
||||
monkeypatch.setattr(
|
||||
_sys, "argv",
|
||||
["praisonai", "train", "--dataset", str(dataset), "--model", "llama-3.1"],
|
||||
)
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
|
||||
pa.PraisonAI().main()
|
||||
|
||||
# The literal word "train" was never sent to an LLM as a chat prompt.
|
||||
assert calls["hdp"] == []
|
||||
# The user's dataset drove config generation (not yahma/alpaca-cleaned).
|
||||
assert calls["config"] is not None
|
||||
assert calls["config"]["dataset"] == [{"name": str(dataset)}]
|
||||
assert calls["config"]["model_name"] == "llama-3.1"
|
||||
# The trainer subprocess was actually dispatched.
|
||||
assert calls["train_argv"] is not None
|
||||
assert "praisonai_train.train.llm.trainer" in calls["train_argv"]
|
||||
assert "train" in calls["train_argv"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -303,6 +303,8 @@ class DeployHandler:
|
||||
console.print(f" Provider: {provider_name}")
|
||||
console.print(f" Region: {config.cloud.region}")
|
||||
console.print(f" Service: {config.cloud.service_name}")
|
||||
if config.cloud.cluster_name:
|
||||
console.print(f" Cluster: {config.cloud.cluster_name}")
|
||||
|
||||
except ValueError as e:
|
||||
if args.json:
|
||||
|
||||
Loaded 100 of 247 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user