mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-10 17:29:39 -10:00
Compare commits
No files matched your search
@@ -1,86 +1,41 @@
|
||||
---
|
||||
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.
|
||||
Explains TypeScript/JavaScript issue and fix routing in the PraisonAI monorepo:
|
||||
TS issues filed on MervinPraison/PraisonAI, fixes implemented in src/praisonai-ts/.
|
||||
Use when routing npm/TS issues, merge-gate TS paths, or when the user mentions
|
||||
praisonai-ts, javascript label, or TypeScript SDK in the monorepo.
|
||||
---
|
||||
|
||||
# praisonai-js hub intake (Option B)
|
||||
# TypeScript SDK in the PraisonAI monorepo
|
||||
|
||||
## Model in one sentence
|
||||
## Model
|
||||
|
||||
**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.**
|
||||
**Issues and fixes both live in `MervinPraison/PraisonAI`.** TypeScript code path: `src/praisonai-ts/`.
|
||||
|
||||
Do **not** ask users to re-file on praisonai-js unless they opened a duplicate on the wrong repo.
|
||||
Do **not** route TS fixes to external repos in automation prompts. The separate [praisonai-js](https://github.com/MervinPraison/praisonai-js) repo is an npm mirror updated **from** the monorepo (`Sync to praisonai-js` workflow).
|
||||
|
||||
## 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/...
|
||||
```
|
||||
| Topic | Report | Fix path |
|
||||
|-------|--------|----------|
|
||||
| Python SDK / CLI | PraisonAI | `src/praisonai-agents/`, `src/praisonai/` |
|
||||
| TypeScript / npm | PraisonAI | **`src/praisonai-ts/`** |
|
||||
| Agent-callable tools | PraisonAI | PraisonAI-Tools (external) |
|
||||
| Lifecycle plugins | PraisonAI | PraisonAI-Plugins (external) |
|
||||
|
||||
## 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.
|
||||
1. TS fixes → edit **`src/praisonai-ts/`** in the monorepo.
|
||||
2. Read **`src/praisonai-ts/AGENTS.md`** for TS architecture rules.
|
||||
3. Tests: `cd src/praisonai-ts && npm install && npm run build && npm test`.
|
||||
4. Merge gate FINAL reviewer: Python + `src/praisonai-ts/` in scope; **no praisonai-js routing line**.
|
||||
5. External STEP 3-ALT only for Tools, Docs, Plugins — not TS.
|
||||
|
||||
## TS issue detection (PraisonAI triage)
|
||||
## Issue triage
|
||||
|
||||
Labels `javascript` + `praisonai-js` when issue mentions: `praisonai-js`, `praisonai-ts`, `src/praisonai-ts`, `typescript`+`praisonai`, `npm`+`praisonai` (not pypi).
|
||||
`javascript` label when issue mentions typescript, javascript, or npm.
|
||||
|
||||
## Key files
|
||||
## Key automation 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/` |
|
||||
See [reference.md](reference.md).
|
||||
@@ -1,72 +1,46 @@
|
||||
# praisonai-js hub intake — file reference
|
||||
# TypeScript SDK — monorepo reference
|
||||
|
||||
## PraisonAI monorepo (`MervinPraison/PraisonAI`)
|
||||
## Fix path
|
||||
|
||||
| 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) |
|
||||
`src/praisonai-ts/` — canonical in monorepo. Read `src/praisonai-ts/AGENTS.md`.
|
||||
|
||||
### Commits (Option B rollout)
|
||||
## Automation (no external TS routing)
|
||||
|
||||
- `4ab1ed280` — Implement Option B hub intake for praisonai-js TypeScript issues
|
||||
| File | TS behaviour |
|
||||
|------|----------------|
|
||||
| `.github/workflows/claude.yml` | Fix in `src/praisonai-ts/`; STEP 3-ALT = Tools/Docs/Plugins only |
|
||||
| `.github/scripts/merge-gate.js` | FINAL scope includes `src/praisonai-ts/`; no `tsMirrorPathReasons` block |
|
||||
| `.github/workflows/claude-merge-gate.yml` | No BLOCK for src/praisonai-ts/ |
|
||||
| `CONTRIBUTING.md` | TS fixes in monorepo |
|
||||
| `.github/workflows/sync-to-praisonai-js.yml` | Mirror sync: monorepo → praisonai-js |
|
||||
|
||||
## 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
|
||||
## Mirror sync (after TS merge)
|
||||
|
||||
```bash
|
||||
gh workflow run "Sync to praisonai-js" --repo MervinPraison/PraisonAI
|
||||
```
|
||||
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)
|
||||
|
||||
Direction: **`src/praisonai-ts/` → praisonai-js** (not the reverse).
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd src/praisonai-ts && npm install && npm run build && npm test
|
||||
```
|
||||
|
||||
## External repos (not TS)
|
||||
|
||||
- PraisonAI-Tools — agent-callable tools
|
||||
- PraisonAI-Plugins — lifecycle plugins
|
||||
- PraisonAIDocs — documentation
|
||||
|
||||
## 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 |
|
||||
| Route TS fix to praisonai-js in merge gate / Claude | Fix in `src/praisonai-ts/` |
|
||||
| Sync praisonai-js → monorepo | Sync **monorepo → praisonai-js** after merge |
|
||||
|
||||
## praisonai-js mirror
|
||||
|
||||
npm repo; updated from monorepo via `Sync to praisonai-js`. See praisonai-js `SYNC.md`.
|
||||
@@ -8,7 +8,7 @@ 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
|
||||
- [ ] TypeScript / JavaScript (`npm install praisonai`) — fixes in `src/praisonai-ts/`
|
||||
- [ ] Cross-language / parity (file here on PraisonAI)
|
||||
|
||||
## Environment
|
||||
|
||||
@@ -225,10 +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. 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).
|
||||
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).
|
||||
6. TypeScript SDK → src/praisonai-ts/ in this monorepo (read src/praisonai-ts/AGENTS.md for TS rules).
|
||||
- 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,7 +35,7 @@ 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.
|
||||
- TypeScript/JavaScript SDK → src/praisonai-ts/ in this monorepo (read src/praisonai-ts/AGENTS.md).
|
||||
- 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.
|
||||
@@ -45,7 +45,7 @@ steps:
|
||||
- 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 / TS-external / Tools-external / Plugins-external / No-change), validation result, and exact files/lines to change.
|
||||
Output: routing decision (Core / Wrapper / TS-monorepo / 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,7 +157,6 @@ 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 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';
|
||||
'@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: Review changes in this PR. Python SDK: praisonaiagents, praisonai. TypeScript SDK: src/praisonai-ts/. Do NOT modify src/praisonai-rust. 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';
|
||||
|
||||
async function getMergeState(github, owner, repo, prNumber) {
|
||||
const query = `
|
||||
@@ -600,24 +600,6 @@ 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 [];
|
||||
@@ -841,7 +823,6 @@ 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));
|
||||
|
||||
@@ -1008,7 +989,6 @@ 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: 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)'
|
||||
body: '🤖 Auto-triaging with Claude. Analyzing issue and implementing a fix...\n\nClaude will:\n1. Route correctly: TypeScript → src/praisonai-ts/; 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'
|
||||
});
|
||||
@@ -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 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.
|
||||
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.
|
||||
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,21 +70,9 @@ jobs:
|
||||
if (content.includes('documentation') || content.includes('docs')) {
|
||||
labels.push('documentation');
|
||||
}
|
||||
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')) {
|
||||
if (content.includes('typescript') || content.includes('javascript') || content.includes('npm')) {
|
||||
labels.push('javascript');
|
||||
}
|
||||
if (isPraisonaiJsFocus) {
|
||||
labels.push('praisonai-js');
|
||||
}
|
||||
|
||||
// Apply labels
|
||||
if (labels.length > 0) {
|
||||
@@ -112,7 +100,7 @@ jobs:
|
||||
'',
|
||||
'_Bug and enhancement reports are auto-triaged with Claude. Questions and other issues: a maintainer can comment `@claude` to trigger analysis._',
|
||||
'',
|
||||
'**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).'
|
||||
'**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). TypeScript/JavaScript SDK fixes land in `src/praisonai-ts/` in this repo.'
|
||||
].join('\n')
|
||||
});
|
||||
}
|
||||
@@ -305,7 +293,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 praisonai-rust or src/praisonai-ts in this repo — TypeScript/JavaScript SDK work belongs in MervinPraison/praisonai-js (STEP 3-ALT).
|
||||
SCOPE: Python SDK (praisonaiagents, praisonai) and TypeScript SDK (src/praisonai-ts/) live in this monorepo. Do NOT modify src/praisonai-rust. For TS issues read src/praisonai-ts/AGENTS.md.
|
||||
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):
|
||||
@@ -317,8 +305,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.
|
||||
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.
|
||||
7. TypeScript/JavaScript SDK → src/praisonai-ts/ in this monorepo (not praisonaiagents/).
|
||||
For items routed to an EXTERNAL repository (Tools, Docs, Plugins), 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.
|
||||
@@ -331,7 +319,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, praisonai-js):
|
||||
STEP 3-ALT — IMPLEMENT IN EXTERNAL REPO (for PraisonAI-Tools, PraisonAIDocs, PraisonAI-Plugins):
|
||||
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>
|
||||
@@ -349,11 +337,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/.
|
||||
h) Comment on THIS PraisonAI issue with the external PR URL.
|
||||
|
||||
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 Python SDK changes: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
|
||||
- For TypeScript SDK changes: cd src/praisonai-ts && npm install && npm run build && npm test
|
||||
- For docs changes: verify files exist and are valid markdown/MDX
|
||||
- Ensure no regressions
|
||||
|
||||
|
||||
@@ -126,6 +126,39 @@ jobs:
|
||||
working-directory: src/praisonai-desktop/src-tauri
|
||||
run: cargo tauri build --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
|
||||
|
||||
- name: The macOS bundle must carry a valid signature
|
||||
if: runner.os == 'macOS'
|
||||
working-directory: src/praisonai-desktop/src-tauri
|
||||
run: |
|
||||
set -euo pipefail
|
||||
app="$(find "target/${{ matrix.target }}/release/bundle" -name '*.app' -print -quit)"
|
||||
[ -n "$app" ] || { echo "no .app was produced"; exit 1; }
|
||||
|
||||
# v4.7.2 shipped a DMG that macOS called "damaged and can't be
|
||||
# opened" on every Mac. The cause was not the missing Developer ID:
|
||||
# the only signature present was the one rustc's linker puts on the
|
||||
# bare executable (adhoc, linker-signed), and its CodeDirectory
|
||||
# declares a resource seal that an unsigned bundle does not have --
|
||||
#
|
||||
# Sealed Resources=none
|
||||
# code has no resources but signature indicates they must be present
|
||||
#
|
||||
# That is an *invalid* signature, not an unsigned one, and macOS
|
||||
# reports invalid as "damaged". Right-click -> Open cannot bypass it,
|
||||
# so the instructions shipped alongside it could not work either.
|
||||
#
|
||||
# `codesign --verify` is therefore a release gate. An ad-hoc identity
|
||||
# is still not a Developer ID and Gatekeeper will still refuse the
|
||||
# app on a double-click -- but it refuses it as "unidentified
|
||||
# developer", which right-click -> Open does bypass.
|
||||
echo "verifying $app"
|
||||
codesign --verify --deep --strict --verbose=2 "$app"
|
||||
# Printed, not asserted: --verify already fails on the exact bundle
|
||||
# that shipped ("code has no resources but signature indicates they
|
||||
# must be present"), so a second check for the same thing would only
|
||||
# be a second way to say it. This is here to make the log readable.
|
||||
codesign -dv --verbose=2 "$app" 2>&1 | grep -E 'Sealed Resources|flags=' || true
|
||||
|
||||
- name: Name the artifact for the platform it runs on
|
||||
id: artifact
|
||||
shell: bash
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
name: Sync to praisonai-js
|
||||
|
||||
# Canonical TS source: src/praisonai-ts/ in this monorepo.
|
||||
# This workflow pushes the mirror to MervinPraison/praisonai-js (npm repo).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_branch:
|
||||
description: 'Target branch on MervinPraison/praisonai-js'
|
||||
required: false
|
||||
default: main
|
||||
type: string
|
||||
mode:
|
||||
description: 'pr = open pull request (default); commit = push directly to target branch'
|
||||
required: false
|
||||
default: pr
|
||||
type: choice
|
||||
options:
|
||||
- pr
|
||||
- commit
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
TARGET_REPO: MervinPraison/praisonai-js
|
||||
SOURCE_PATH: src/praisonai-ts
|
||||
TARGET_BRANCH: ${{ inputs.target_branch }}
|
||||
steps:
|
||||
- name: Validate token
|
||||
run: |
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "GH_TOKEN secret is required (PAT with repo write access to praisonai-js)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout PraisonAI monorepo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Checkout praisonai-js
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ env.TARGET_REPO }}
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
path: praisonai-js-target
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
|
||||
- name: Sync monorepo path into praisonai-js
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rsync -a --delete \
|
||||
--exclude '.git/' \
|
||||
--exclude 'node_modules/' \
|
||||
--exclude 'dist/' \
|
||||
--exclude 'coverage/' \
|
||||
--exclude '.DS_Store' \
|
||||
--exclude '*.tsbuildinfo' \
|
||||
"${SOURCE_PATH}/" praisonai-js-target/
|
||||
|
||||
- name: Push changes
|
||||
working-directory: praisonai-js-target
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Preserve praisonai-js automation (rsync source has no .github/)
|
||||
if git diff --quiet; then
|
||||
echo "No changes — praisonai-js already matches ${SOURCE_PATH} on monorepo main."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git add -A
|
||||
msg="sync: update from monorepo ${SOURCE_PATH}"
|
||||
|
||||
if [ "${{ inputs.mode }}" = "commit" ]; then
|
||||
git commit -m "$msg"
|
||||
git push origin "HEAD:${TARGET_BRANCH}"
|
||||
echo "Committed and pushed directly to ${TARGET_BRANCH} on praisonai-js."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
branch="sync/monorepo-praisonai-ts-$(date -u +%Y%m%d-%H%M%S)"
|
||||
git checkout -b "$branch"
|
||||
git commit -m "$msg"
|
||||
git push origin "$branch"
|
||||
|
||||
gh pr create \
|
||||
--repo "$TARGET_REPO" \
|
||||
--base "$TARGET_BRANCH" \
|
||||
--head "$branch" \
|
||||
--title "$msg" \
|
||||
--body "$(cat <<EOF
|
||||
Automated sync from [\`MervinPraison/PraisonAI\`](https://github.com/MervinPraison/PraisonAI) \`${SOURCE_PATH}/\` into the npm mirror repo.
|
||||
|
||||
Canonical development is in the monorepo; this PR updates the public \`praisonai-js\` checkout.
|
||||
|
||||
Excludes from rsync source: \`node_modules/\`, \`dist/\`, \`coverage/\`. \`.github/\` on praisonai-js is unchanged.
|
||||
EOF
|
||||
)"
|
||||
echo "Opened PR on ${TARGET_REPO} (${branch} → ${TARGET_BRANCH})."
|
||||
@@ -8,6 +8,7 @@ You are working on the PraisonAI project.
|
||||
- Test implementation thoroughly
|
||||
- Ensure backward compatibility with existing APIs
|
||||
- Follow protocol-driven design across the nine packages: core protocols in `praisonaiagents/`, agentic terminal CLI in `praisonai-code/`, bots/gateway in `praisonai-bot/`, LLM fine-tuning + agent training in `praisonai-train/`, browser automation in `praisonai-browser/`, MCP server host in `praisonai-mcp/`, sandbox backends in `praisonai-sandbox/`, deployment in `praisonai-deploy/`, integrations/serve/dashboard in the `praisonai/` wrapper
|
||||
- The TypeScript/JavaScript SDK is canonical in `src/praisonai-ts/` here — TS fixes land in this monorepo, not in `MervinPraison/praisonai-js`, which is only the npm mirror written to by `src/praisonai-ts/` (direction: monorepo → praisonai-js, via `gh workflow run "Sync to praisonai-js" --repo MervinPraison/PraisonAI`). See `src/praisonai-ts/AGENTS.md` §2.1.1.
|
||||
- Preserve old `praisonai.*` import paths via shims when moving code between packages (see §2.3 in `src/praisonai-agents/AGENTS.md`; shim helpers in `src/praisonai/praisonai/cli/_shim.py`)
|
||||
- Package boundaries and dependency rules: `ARCHITECTURE.md` §2 (Tier 2 packages must never PyPI-depend on the wrapper; cross-tier access goes through lazy `_*_bridge` modules)
|
||||
- Boundary manifests: `src/praisonai/tests/PRAISONAI_BOT_MANIFEST.md` (C9), `src/praisonai/tests/PRAISONAI_TRAIN_MANIFEST.md` (C10), `src/praisonai/tests/PRAISONAI_BROWSER_MANIFEST.md` (C11), `src/praisonai/tests/PRAISONAI_MCP_MANIFEST.md` (C12), `src/praisonai/tests/PRAISONAI_SANDBOX_MANIFEST.md` (C13), `src/praisonai/tests/PRAISONAI_DEPLOY_MANIFEST.md` (C14)
|
||||
|
||||
+6
-7
@@ -7,21 +7,20 @@ 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)
|
||||
## Where to report issues
|
||||
|
||||
| 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 |
|
||||
| Python SDK, CLI, wrapper | **This repo** | `src/praisonai-agents/`, `src/praisonai/` |
|
||||
| TypeScript / JavaScript (`npm install praisonai`) | **This repo** | `src/praisonai-ts/` |
|
||||
| Cross-language parity | **This repo** | Often both Python and TypeScript paths |
|
||||
| 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/`
|
||||
- TypeScript changes: implement in `src/praisonai-ts/` and run `npm test` there
|
||||
- After merge, sync the npm mirror: `gh workflow run "Sync to praisonai-js" --repo MervinPraison/PraisonAI`
|
||||
+1
-3
@@ -8,9 +8,7 @@ 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) | [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.
|
||||
| `praisonai` | [npm](https://www.npmjs.com/package/praisonai) | `src/praisonai-ts/` |
|
||||
|
||||
Report issues against the **specific package** where the vulnerability exists. A platform-only issue does not necessarily affect the core SDK, and vice versa.
|
||||
|
||||
|
||||
@@ -923,7 +923,7 @@ Methods:
|
||||
Types/Exports:
|
||||
```ts
|
||||
export { Agent, AgentTeam, Agents, PraisonAIAgents, Router } from "./agent";
|
||||
export type { AgentTeamConfig, PraisonAIAgentsConfig, SimpleAgentConfig, SimpleRouteConfig, SimpleRouterConfig } from "./agent";
|
||||
export type { AgentEvent, AgentStreamOptions, AgentTeamConfig, PraisonAIAgentsConfig, SimpleAgentConfig, SimpleRouteConfig, SimpleRouterConfig } from "./agent";
|
||||
export { AudioAgent, createAudioAgent } from "./agent/audio";
|
||||
export type { AudioAgentConfig, AudioProvider, AudioSpeakOptions, AudioSpeakResult, AudioTranscribeOptions, AudioTranscribeResult } from "./agent/audio";
|
||||
export { ContextAgent, createContextAgent } from "./agent/context";
|
||||
|
||||
@@ -17,17 +17,17 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 22 | 53 | 17107 |
|
||||
| ✅ AGUI | 1 | 2 | 511 |
|
||||
| ✅ Agent | 22 | 54 | 17255 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
|
||||
| ✅ Approval | 1 | 5 | 2764 |
|
||||
| ✅ Approval | 1 | 6 | 2915 |
|
||||
| ✅ Audio | 2 | 12 | 1000 |
|
||||
| ✅ Auto Generation | 5 | 11 | 3542 |
|
||||
| ✅ Autonomy | 3 | 7 | 2790 |
|
||||
| ✅ Bots | 7 | 32 | 13616 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 122 | 36991 |
|
||||
| ✅ CLI | 5 | 122 | 36971 |
|
||||
| ✅ Chunking | 2 | 2 | 420 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 2 | 14 | 4855 |
|
||||
@@ -49,9 +49,9 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| ✅ Hooks | 2 | 9 | 4509 |
|
||||
| ✅ Image | 1 | 11 | 1255 |
|
||||
| ✅ Knowledge | 4 | 16 | 4967 |
|
||||
| ✅ LLM | 3 | 15 | 5372 |
|
||||
| ✅ LLM | 3 | 15 | 5376 |
|
||||
| ✅ Loops | 4 | 5 | 1475 |
|
||||
| ✅ MCP | 1 | 61 | 14160 |
|
||||
| ✅ MCP | 1 | 62 | 14278 |
|
||||
| ✅ Memory | 6 | 18 | 7193 |
|
||||
| ✅ OCR | 2 | 1 | 237 |
|
||||
| ✅ Observability | 2 | 23 | 2811 |
|
||||
@@ -75,7 +75,7 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| ✅ Tasks | 2 | 6 | 2897 |
|
||||
| ✅ Telemetry | 1 | 2 | 648 |
|
||||
| ✅ Templates | 1 | 8 | 1663 |
|
||||
| ✅ Tools | 12 | 144 | 43251 |
|
||||
| ✅ Tools | 12 | 144 | 43272 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Vector Store | 1 | 12 | 1215 |
|
||||
| ✅ Video | 2 | 6 | 649 |
|
||||
|
||||
@@ -1928,7 +1928,16 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
self.self_reflect = True if self_reflect is None else self_reflect
|
||||
|
||||
self.instructions = instructions
|
||||
|
||||
|
||||
# Unique, per-instance scope id for skill auto-approval grants. The
|
||||
# display ``name`` defaults to the literal "Agent" for every unnamed
|
||||
# agent, so keying auto-approval by name alone would let one agent's
|
||||
# skill grant leak onto every other unnamed agent in the same process
|
||||
# (a cross-tenant privilege escalation). This id can never collide
|
||||
# across unrelated instances.
|
||||
import uuid as _uuid
|
||||
self._approval_scope_id = f"{self.name}:{_uuid.uuid4().hex}"
|
||||
|
||||
# Resolve tool_config for tool execution settings
|
||||
_tool_config = Agent._resolve_tool_config(
|
||||
tool_config=tool_config
|
||||
|
||||
@@ -2947,7 +2947,14 @@ Your Goal: {self.goal}"""
|
||||
from ..approval import get_approval_registry
|
||||
|
||||
registry = get_approval_registry()
|
||||
agent_name = getattr(self, "display_name", getattr(self, "name", None))
|
||||
# Key the grant by the unique per-instance scope id (not the
|
||||
# display name, which defaults to "Agent" for every unnamed
|
||||
# agent and would leak this grant onto other agents in the
|
||||
# same process). Falls back to name/display_name for objects
|
||||
# without a scope id.
|
||||
agent_name = getattr(self, "_approval_scope_id", None) or getattr(
|
||||
self, "display_name", getattr(self, "name", None)
|
||||
)
|
||||
if agent_name: # Only approve if we have a stable agent identifier
|
||||
for _tn in tool_names:
|
||||
try:
|
||||
@@ -3927,7 +3934,12 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self._ensure_knowledge_processed()
|
||||
|
||||
if not skip_retrieval and self.knowledge:
|
||||
search_results = self.knowledge.search(prompt, agent_id=self.agent_id)
|
||||
# Knowledge.search is synchronous (Mem0 embedding + vector-store
|
||||
# I/O). Offload to a thread so it never blocks the event loop for
|
||||
# other coroutines sharing the loop.
|
||||
search_results = await asyncio.to_thread(
|
||||
self.knowledge.search, prompt, agent_id=self.agent_id
|
||||
)
|
||||
if search_results:
|
||||
if isinstance(search_results, dict) and 'results' in search_results:
|
||||
knowledge_content = "\n".join([result['memory'] for result in search_results['results']])
|
||||
|
||||
@@ -1626,6 +1626,12 @@ class ToolExecutionMixin:
|
||||
or (trust_level == "external") # External tools need approval
|
||||
)
|
||||
if needs_approval:
|
||||
# Attach a rendered unified diff for file-mutating tools so the
|
||||
# reviewer approves the actual change rather than a truncated
|
||||
# argument dump. Non-edit tools get no diff (``None``) and keep
|
||||
# the existing argument summary.
|
||||
from ..approval.utils import build_diff_preview
|
||||
diff_preview = build_diff_preview(tool_name, tool_args)
|
||||
request = ApprovalRequest(
|
||||
tool_name=tool_name,
|
||||
arguments=tool_args,
|
||||
@@ -1634,6 +1640,7 @@ class ToolExecutionMixin:
|
||||
or DEFAULT_DANGEROUS_TOOLS.get(tool_name, "medium")
|
||||
),
|
||||
agent_name=getattr(self, 'name', None),
|
||||
context={"diff": diff_preview} if diff_preview else {},
|
||||
)
|
||||
|
||||
if is_async:
|
||||
@@ -1699,15 +1706,22 @@ class ToolExecutionMixin:
|
||||
# registry state (which leaked onto other agents when this agent had
|
||||
# no stable name), pass the intent per-call via ``force`` so the
|
||||
# registry prompts for *this* call only without side effects.
|
||||
# Skill auto-approval grants are keyed by this agent's unique
|
||||
# per-instance scope id (not the display name, which defaults to
|
||||
# "Agent" for every unnamed agent), so a skill grant on one agent
|
||||
# can never unlock the tool for an unrelated agent in the process.
|
||||
auto_approve_scope = getattr(self, '_approval_scope_id', None)
|
||||
if is_async:
|
||||
return get_approval_registry().approve_async(
|
||||
getattr(self, 'name', None), tool_name, tool_args,
|
||||
force=manager_forces_approval,
|
||||
auto_approve_scope=auto_approve_scope,
|
||||
)
|
||||
else:
|
||||
return get_approval_registry().approve_sync(
|
||||
getattr(self, 'name', None), tool_name, tool_args,
|
||||
force=manager_forces_approval,
|
||||
auto_approve_scope=auto_approve_scope,
|
||||
)
|
||||
|
||||
def _doom_loop_approved(self, function_name, arguments, verdict) -> bool:
|
||||
@@ -2852,12 +2866,15 @@ class ToolExecutionMixin:
|
||||
|
||||
from ..approval.protocols import ApprovalRequest
|
||||
from ..approval.registry import DEFAULT_DANGEROUS_TOOLS
|
||||
from ..approval.utils import build_diff_preview
|
||||
|
||||
diff_preview = build_diff_preview(function_name, arguments)
|
||||
request = ApprovalRequest(
|
||||
tool_name=function_name,
|
||||
arguments=arguments,
|
||||
risk_level=DEFAULT_DANGEROUS_TOOLS.get(function_name, "medium"),
|
||||
agent_name=getattr(self, 'name', None),
|
||||
context={"diff": diff_preview} if diff_preview else {},
|
||||
)
|
||||
|
||||
tracking_id = str(uuid.uuid4())[:8]
|
||||
|
||||
@@ -80,6 +80,35 @@ class AutoApproveBackend:
|
||||
class ConsoleBackend:
|
||||
"""Interactive Rich terminal prompt. Default for CLI usage."""
|
||||
|
||||
@staticmethod
|
||||
def _render_diff_markup(diff: str, max_lines: int = 40) -> str:
|
||||
"""Return Rich-markup for a unified *diff* with coloured +/- hunks.
|
||||
|
||||
Escapes any literal ``[`` in the diff so raw file content can never be
|
||||
interpreted as Rich markup. Added lines are green, removed lines red,
|
||||
hunk headers cyan and context lines dimmed. Bounded to ``max_lines``.
|
||||
"""
|
||||
from rich.markup import escape
|
||||
|
||||
lines = diff.splitlines()
|
||||
rendered = []
|
||||
for i, line in enumerate(lines):
|
||||
if i >= max_lines:
|
||||
rendered.append("[dim]... (diff truncated)[/dim]")
|
||||
break
|
||||
safe = escape(line)
|
||||
if line.startswith("+++") or line.startswith("---"):
|
||||
rendered.append(f"[bold]{safe}[/bold]")
|
||||
elif line.startswith("@@"):
|
||||
rendered.append(f"[cyan]{safe}[/cyan]")
|
||||
elif line.startswith("+"):
|
||||
rendered.append(f"[green]{safe}[/green]")
|
||||
elif line.startswith("-"):
|
||||
rendered.append(f"[red]{safe}[/red]")
|
||||
else:
|
||||
rendered.append(f"[dim]{safe}[/dim]")
|
||||
return "\n".join(rendered) + "\n"
|
||||
|
||||
def _prompt_user(self, request: ApprovalRequest):
|
||||
"""Show Rich panel and ask once/session/always/no/deny-with-guidance.
|
||||
|
||||
@@ -108,12 +137,22 @@ class ConsoleBackend:
|
||||
tool_info += f"[bold]Risk Level:[/] [{risk_color}]{request.risk_level.upper()}[/{risk_color}]\n"
|
||||
if request.agent_name:
|
||||
tool_info += f"[bold]Agent:[/] {request.agent_name}\n"
|
||||
tool_info += "[bold]Arguments:[/]\n"
|
||||
for key, value in request.arguments.items():
|
||||
str_value = str(value)
|
||||
if len(str_value) > 100:
|
||||
str_value = str_value[:97] + "..."
|
||||
tool_info += f" {key}: {str_value}\n"
|
||||
|
||||
# For file-mutating tools a rendered unified diff is attached to the
|
||||
# request context so the reviewer sees the actual change (path plus
|
||||
# +/- hunks) instead of a truncated argument dump. Fall back to the
|
||||
# argument summary when no diff is present (non-edit tools).
|
||||
diff = (request.context or {}).get("diff")
|
||||
if diff:
|
||||
tool_info += "[bold]Diff:[/]\n"
|
||||
tool_info += self._render_diff_markup(diff)
|
||||
else:
|
||||
tool_info += "[bold]Arguments:[/]\n"
|
||||
for key, value in request.arguments.items():
|
||||
str_value = str(value)
|
||||
if len(str_value) > 100:
|
||||
str_value = str_value[:97] + "..."
|
||||
tool_info += f" {key}: {str_value}\n"
|
||||
|
||||
console.print(Panel(
|
||||
tool_info.strip(),
|
||||
|
||||
@@ -422,6 +422,7 @@ class ApprovalRegistry:
|
||||
tool_name: str,
|
||||
arguments: Dict,
|
||||
force: bool = False,
|
||||
auto_approve_scope: Optional[str] = None,
|
||||
) -> ApprovalDecision:
|
||||
"""Synchronous approval — used by ``Agent._execute_tool_impl``.
|
||||
|
||||
@@ -429,7 +430,14 @@ class ApprovalRegistry:
|
||||
registered as requiring approval (e.g. a per-agent ``PermissionManager``
|
||||
``ask`` rule). It applies only to this call and never mutates shared
|
||||
registry state, so it cannot leak an approval gate onto other agents.
|
||||
|
||||
``auto_approve_scope`` is the unique per-instance key used *only* for
|
||||
the skill auto-approval check. It defaults to ``agent_name`` so callers
|
||||
that don't pass it keep the old behaviour; passing a per-instance id
|
||||
(e.g. ``Agent._approval_scope_id``) prevents skill grants from leaking
|
||||
across unrelated agents that share the same display name.
|
||||
"""
|
||||
auto_scope = auto_approve_scope or agent_name
|
||||
# Fast-path: not required (checks both global and this agent's scope)
|
||||
if not force and not self.is_required(tool_name, agent_name):
|
||||
return ApprovalDecision(approved=True, reason="No approval required")
|
||||
@@ -444,7 +452,7 @@ class ApprovalRegistry:
|
||||
return ApprovalDecision(approved=True, reason="Approved (session)", approver="session")
|
||||
|
||||
# Check per-tool auto-approval (G-A fix)
|
||||
if self.is_auto_approved(tool_name, agent_name):
|
||||
if self.is_auto_approved(tool_name, auto_scope):
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (skill)", approver="skill")
|
||||
|
||||
@@ -489,12 +497,14 @@ class ApprovalRegistry:
|
||||
tool_name: str,
|
||||
arguments: Dict,
|
||||
force: bool = False,
|
||||
auto_approve_scope: Optional[str] = None,
|
||||
) -> ApprovalDecision:
|
||||
"""Asynchronous approval — used by async tool execution path.
|
||||
|
||||
See :meth:`approve_sync` for the ``force`` semantics (per-call gate,
|
||||
no shared-state mutation).
|
||||
See :meth:`approve_sync` for the ``force`` and ``auto_approve_scope``
|
||||
semantics (per-call gate, no shared-state mutation).
|
||||
"""
|
||||
auto_scope = auto_approve_scope or agent_name
|
||||
# Fast-path: not required (checks both global and this agent's scope)
|
||||
if not force and not self.is_required(tool_name, agent_name):
|
||||
return ApprovalDecision(approved=True, reason="No approval required")
|
||||
@@ -508,7 +518,7 @@ class ApprovalRegistry:
|
||||
return ApprovalDecision(approved=True, reason="Approved (session)", approver="session")
|
||||
|
||||
# Check per-tool auto-approval (G-A fix)
|
||||
if self.is_auto_approved(tool_name, agent_name):
|
||||
if self.is_auto_approved(tool_name, auto_scope):
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (skill)", approver="skill")
|
||||
|
||||
|
||||
@@ -114,6 +114,108 @@ def build_permission_target(
|
||||
return f"tool:{tool_name}"
|
||||
|
||||
|
||||
def build_diff_preview(
|
||||
tool_name: str, arguments: Optional[Dict[str, Any]] = None
|
||||
) -> Optional[str]:
|
||||
"""Build a readable unified diff for a file-mutating tool call.
|
||||
|
||||
Lets an approval prompt show the *actual* pending change (path plus
|
||||
``+``/``-`` hunks) instead of a truncated argument dump. Uses only the
|
||||
stdlib :mod:`difflib`; no new dependencies. Returns ``None`` for tools that
|
||||
are not file mutations (or when the diff cannot be computed) so callers can
|
||||
fall back to the existing argument summary.
|
||||
|
||||
Supported tools:
|
||||
|
||||
* ``edit_file`` — targeted ``old_string`` -> ``new_string`` replacement at
|
||||
``filepath``/``path``. Honours ``replace_all`` (default ``False`` =
|
||||
first occurrence only) so the preview matches what ``edit_file`` applies.
|
||||
* ``acp_edit_file`` — whole-file replace: ``new_content`` at ``filepath``
|
||||
diffed against the current on-disk file.
|
||||
* ``write_file`` / ``acp_create_file`` — new ``content`` against the
|
||||
current on-disk file (empty when the file does not yet exist).
|
||||
* ``apply_patch`` — the ``patch`` text is already a unified diff, returned
|
||||
verbatim.
|
||||
"""
|
||||
import difflib
|
||||
import os
|
||||
|
||||
args = arguments or {}
|
||||
|
||||
def _path() -> str:
|
||||
for key in _PATH_KEYS:
|
||||
value = args.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return tool_name
|
||||
|
||||
def _read_existing(path: str) -> str:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
def _unified(old: str, new: str, path: str) -> Optional[str]:
|
||||
diff = "".join(
|
||||
difflib.unified_diff(
|
||||
old.splitlines(keepends=True),
|
||||
new.splitlines(keepends=True),
|
||||
fromfile=f"{path} (before)",
|
||||
tofile=f"{path} (after)",
|
||||
n=3,
|
||||
)
|
||||
)
|
||||
return diff or None
|
||||
|
||||
try:
|
||||
if tool_name == "edit_file":
|
||||
old_string = args.get("old_string")
|
||||
new_string = args.get("new_string")
|
||||
if not isinstance(old_string, str) or not isinstance(new_string, str):
|
||||
return None
|
||||
path = _path()
|
||||
existing = _read_existing(path)
|
||||
if existing and old_string and old_string in existing:
|
||||
# Mirror ``edit_file`` semantics: ``replace_all`` defaults to
|
||||
# False (first occurrence only), so the preview must not show
|
||||
# every occurrence changing when only the first will be edited.
|
||||
if args.get("replace_all"):
|
||||
new_content = existing.replace(old_string, new_string)
|
||||
else:
|
||||
new_content = existing.replace(old_string, new_string, 1)
|
||||
return _unified(existing, new_content, path)
|
||||
return _unified(old_string, new_string, path)
|
||||
|
||||
if tool_name == "acp_edit_file":
|
||||
# ACP edits are whole-file replacements: ``new_content`` at
|
||||
# ``filepath`` (no old_string/new_string contract).
|
||||
new_content = args.get("new_content")
|
||||
if not isinstance(new_content, str):
|
||||
return None
|
||||
path = _path()
|
||||
existing = _read_existing(path) if os.path.exists(path) else ""
|
||||
return _unified(existing, new_content, path)
|
||||
|
||||
if tool_name in ("write_file", "acp_create_file"):
|
||||
content = args.get("content")
|
||||
if not isinstance(content, str):
|
||||
return None
|
||||
path = _path()
|
||||
existing = _read_existing(path) if os.path.exists(path) else ""
|
||||
return _unified(existing, content, path)
|
||||
|
||||
if tool_name == "apply_patch":
|
||||
patch = args.get("patch")
|
||||
if isinstance(patch, str) and patch.strip():
|
||||
return patch
|
||||
return None
|
||||
except Exception: # noqa: BLE001 — preview is advisory; never break approval
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def run_coroutine_safely(
|
||||
coro: Awaitable[T],
|
||||
timeout: Optional[float] = None
|
||||
|
||||
@@ -245,6 +245,83 @@ def _find_config_file() -> Optional[Path]:
|
||||
return None
|
||||
|
||||
|
||||
def _discover_config_files() -> List[Path]:
|
||||
"""Discover all config files ordered lowest→highest precedence.
|
||||
|
||||
Returns an ordered list ``[global, …ancestors…, project]`` so callers can
|
||||
deep-merge left-to-right: the user-global ``~/.praisonai/config.*`` provides
|
||||
defaults, project configs discovered by walking up from the current
|
||||
directory override them, and the nearest (deepest) project config wins.
|
||||
|
||||
A project config that sets one key therefore keeps every global default for
|
||||
the rest, fixing the previous first-file-wins behaviour where any project
|
||||
config silently shadowed the global one.
|
||||
|
||||
For each directory only the first matching filename (in ``config.toml``,
|
||||
``config.yaml``, ``config.yml``, ``praisonai.{toml,yaml,yml}`` order) is
|
||||
used, preserving today's per-location precedence.
|
||||
|
||||
Returns:
|
||||
Ordered list of existing config paths (lowest precedence first). Empty
|
||||
when no config files exist anywhere.
|
||||
"""
|
||||
files: List[Path] = []
|
||||
|
||||
# User global location (lowest precedence).
|
||||
from ..paths import get_data_dir
|
||||
data_dir = get_data_dir()
|
||||
for global_path in (
|
||||
data_dir / "config.toml",
|
||||
data_dir / "config.yaml",
|
||||
data_dir / "config.yml",
|
||||
):
|
||||
if global_path.exists():
|
||||
files.append(global_path)
|
||||
break
|
||||
|
||||
# Project configs discovered by walking up from cwd to the filesystem root.
|
||||
# Collected root→cwd so the nearest (deepest) config is folded last and wins.
|
||||
ancestor_files: List[Path] = []
|
||||
cwd = Path.cwd()
|
||||
for directory in [cwd, *cwd.parents]:
|
||||
project_data = directory / ".praisonai"
|
||||
candidates = [
|
||||
project_data / "config.toml",
|
||||
project_data / "config.yaml",
|
||||
project_data / "config.yml",
|
||||
directory / "praisonai.toml",
|
||||
directory / "praisonai.yaml",
|
||||
directory / "praisonai.yml",
|
||||
]
|
||||
for path in candidates:
|
||||
if path.exists():
|
||||
ancestor_files.append(path)
|
||||
break
|
||||
ancestor_files.reverse()
|
||||
files.extend(ancestor_files)
|
||||
|
||||
return files
|
||||
|
||||
|
||||
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Recursively merge ``override`` onto ``base`` (dicts recurse, else replace).
|
||||
|
||||
Nested mappings are merged key-by-key so a higher-precedence layer can set a
|
||||
single nested key without discarding sibling keys from lower layers. Any
|
||||
non-dict value (including lists) at a given key replaces the base value.
|
||||
|
||||
Returns a new dict; inputs are not mutated.
|
||||
"""
|
||||
result = dict(base)
|
||||
for key, value in override.items():
|
||||
existing = result.get(key)
|
||||
if isinstance(existing, dict) and isinstance(value, dict):
|
||||
result[key] = _deep_merge(existing, value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _parse_toml(path: Path) -> Dict[str, Any]:
|
||||
"""Parse TOML file with fallback for Python < 3.11.
|
||||
|
||||
@@ -295,16 +372,8 @@ def _parse_yaml(path: Path) -> Dict[str, Any]:
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _load_config() -> Dict[str, Any]:
|
||||
"""Load configuration from file (TOML or the unified YAML surface).
|
||||
|
||||
Returns:
|
||||
Config dict (empty if no config file found)
|
||||
"""
|
||||
config_path = _find_config_file()
|
||||
if config_path is None:
|
||||
return {}
|
||||
|
||||
def _parse_config_file(config_path: Path) -> Dict[str, Any]:
|
||||
"""Parse a single config file (TOML or YAML), returning {} on failure."""
|
||||
try:
|
||||
if config_path.suffix.lower() in (".yaml", ".yml"):
|
||||
return _parse_yaml(config_path)
|
||||
@@ -315,6 +384,31 @@ def _load_config() -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def _load_config() -> Dict[str, Any]:
|
||||
"""Load configuration by deep-merging all discovered config files.
|
||||
|
||||
Files are folded lowest→highest precedence (global first, nearest project
|
||||
last) so a project config selectively overrides global defaults instead of
|
||||
shadowing them entirely. When exactly one file exists the merged result
|
||||
equals parsing that file alone, preserving backward compatibility.
|
||||
|
||||
Returns:
|
||||
Merged config dict (empty if no config file found)
|
||||
"""
|
||||
config_paths = _discover_config_files()
|
||||
if not config_paths:
|
||||
return {}
|
||||
|
||||
# Fast path: a single file needs no merge and matches prior behaviour.
|
||||
if len(config_paths) == 1:
|
||||
return _parse_config_file(config_paths[0])
|
||||
|
||||
merged: Dict[str, Any] = {}
|
||||
for config_path in config_paths:
|
||||
merged = _deep_merge(merged, _parse_config_file(config_path))
|
||||
return merged
|
||||
|
||||
|
||||
def _dict_to_plugins_config(data: Dict[str, Any]) -> PluginsConfig:
|
||||
"""Convert dict to PluginsConfig.
|
||||
|
||||
@@ -596,15 +690,19 @@ def get_plugin_options() -> Dict[str, Dict[str, Any]]:
|
||||
def _config_write_target() -> Path:
|
||||
"""Resolve the config file the CLI should write plugin enable/disable to.
|
||||
|
||||
Returns the existing config file if one is already present (so the CLI
|
||||
mutates the *same* file the runtime reads), otherwise the project-local
|
||||
``.praisonai/config.yaml`` — the unified surface — as the default target.
|
||||
This closes the config split-brain where the CLI wrote a JSON file the
|
||||
runtime never read.
|
||||
Returns the *highest-precedence* existing config file (the one that wins
|
||||
after the deep merge) so the CLI mutates the same file the runtime honours.
|
||||
Using ``_discover_config_files()[-1]`` — rather than ``_find_config_file()``
|
||||
which only sees cwd + global — ensures that when a nearer ancestor config
|
||||
overrides the global, the CLI writes that ancestor file instead of a
|
||||
lower-precedence global whose change would be silently overridden on reload.
|
||||
Falls back to the project-local ``.praisonai/config.yaml`` — the unified
|
||||
surface — when no config exists yet. This closes the config split-brain
|
||||
where the CLI wrote a file the runtime never read.
|
||||
"""
|
||||
existing = _find_config_file()
|
||||
if existing is not None:
|
||||
return existing
|
||||
config_paths = _discover_config_files()
|
||||
if config_paths:
|
||||
return config_paths[-1]
|
||||
return get_project_data_dir() / "config.yaml"
|
||||
|
||||
|
||||
@@ -649,7 +747,10 @@ def set_plugin_enabled(name: str, enabled: bool) -> Path:
|
||||
The path of the config file that was written.
|
||||
"""
|
||||
target = _config_write_target()
|
||||
raw = _load_config() if _find_config_file() is not None else {}
|
||||
# Seed from the *target* file only (not the merged view) so we round-trip the
|
||||
# exact file we write back to; merging in lower-precedence globals here would
|
||||
# persist inherited keys into the target and defeat the fall-through design.
|
||||
raw = _parse_config_file(target) if target.exists() else {}
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
|
||||
|
||||
@@ -646,6 +646,16 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
if any(url and ("ollama" in url.lower() or ":11434" in url) for url in base_urls):
|
||||
return "ollama"
|
||||
|
||||
# Substring fallback for routed models whose provider is not the first
|
||||
# path segment (e.g. bedrock/anthropic.claude-*, vertex_ai/claude-*,
|
||||
# openrouter/anthropic/*, vertex_ai/gemini-*). Mirrors the substring
|
||||
# fallback in get_provider_adapter so these get the correct adapter
|
||||
# (and its streaming guard) instead of falling through to "openai".
|
||||
if "claude" in model_lower or "anthropic" in model_lower:
|
||||
return "anthropic"
|
||||
if "gemini" in model_lower:
|
||||
return "gemini"
|
||||
|
||||
# Default fallback
|
||||
return "openai"
|
||||
|
||||
|
||||
@@ -23,6 +23,19 @@ from ..protocols import MemoryProtocol
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sanitize_chroma_metadata(metadata: Dict) -> Dict:
|
||||
"""Sanitize metadata for ChromaDB - convert to acceptable types."""
|
||||
sanitized = {}
|
||||
for k, v in metadata.items():
|
||||
if v is None:
|
||||
continue
|
||||
if isinstance(v, (str, int, float, bool)):
|
||||
sanitized[k] = v
|
||||
else:
|
||||
sanitized[k] = str(v)
|
||||
return sanitized
|
||||
|
||||
|
||||
def safe_mem0_search(mem0_client, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Defensive wrapper for mem0.search() to handle MongoDB vector store compatibility.
|
||||
@@ -359,17 +372,7 @@ class ChromaMemoryAdapter:
|
||||
|
||||
def _sanitize_metadata(self, metadata: Dict) -> Dict:
|
||||
"""Sanitize metadata for ChromaDB - convert to acceptable types."""
|
||||
sanitized = {}
|
||||
for k, v in metadata.items():
|
||||
if v is None:
|
||||
continue
|
||||
if isinstance(v, (str, int, float, bool)):
|
||||
sanitized[k] = v
|
||||
elif isinstance(v, dict):
|
||||
sanitized[k] = str(v)
|
||||
else:
|
||||
sanitized[k] = str(v)
|
||||
return sanitized
|
||||
return sanitize_chroma_metadata(metadata)
|
||||
|
||||
def _reset_collection(self) -> None:
|
||||
"""Delete and recreate this adapter's collection, wiping stored memory.
|
||||
|
||||
@@ -5,6 +5,7 @@ Lightweight in-memory adapter for testing and development.
|
||||
No dependencies required - stores data as plain Python lists.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@@ -25,6 +26,13 @@ class InMemoryAdapter:
|
||||
self._data: List[Dict[str, Any]] = []
|
||||
self._max_size: int = kwargs.get("max_size", 10_000) # Default limit
|
||||
self._next_id: int = 0 # Monotonic counter to prevent ID collisions after eviction
|
||||
# Guards the non-atomic id-increment + append + eviction sequence. Async
|
||||
# task/knowledge paths offload writes via ``asyncio.to_thread``, so
|
||||
# parallel callbacks can hit this shared adapter on different worker
|
||||
# threads concurrently. A re-entrant lock keeps every mutation atomic
|
||||
# (no duplicate ids / lost entries) at negligible cost for the common
|
||||
# single-threaded case.
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def _evict_if_needed(self):
|
||||
"""Evict old entries if we exceed max size (FIFO eviction)."""
|
||||
@@ -35,50 +43,54 @@ class InMemoryAdapter:
|
||||
def store_short_term(
|
||||
self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs
|
||||
) -> str:
|
||||
entry: Dict[str, Any] = {
|
||||
"id": str(self._next_id),
|
||||
"text": text,
|
||||
"type": "short",
|
||||
"metadata": metadata,
|
||||
}
|
||||
self._next_id += 1
|
||||
self._data.append(entry)
|
||||
self._evict_if_needed()
|
||||
return entry["id"]
|
||||
with self._lock:
|
||||
entry: Dict[str, Any] = {
|
||||
"id": str(self._next_id),
|
||||
"text": text,
|
||||
"type": "short",
|
||||
"metadata": metadata,
|
||||
}
|
||||
self._next_id += 1
|
||||
self._data.append(entry)
|
||||
self._evict_if_needed()
|
||||
return entry["id"]
|
||||
|
||||
def search_short_term(
|
||||
self, query: str, limit: int = 5, **kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
results = [
|
||||
e
|
||||
for e in self._data
|
||||
if e["type"] == "short" and query.lower() in e["text"].lower()
|
||||
]
|
||||
return results[:limit]
|
||||
with self._lock:
|
||||
results = [
|
||||
e
|
||||
for e in self._data
|
||||
if e["type"] == "short" and query.lower() in e["text"].lower()
|
||||
]
|
||||
return results[:limit]
|
||||
|
||||
def store_long_term(
|
||||
self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs
|
||||
) -> str:
|
||||
entry: Dict[str, Any] = {
|
||||
"id": str(self._next_id),
|
||||
"text": text,
|
||||
"type": "long",
|
||||
"metadata": metadata,
|
||||
}
|
||||
self._next_id += 1
|
||||
self._data.append(entry)
|
||||
self._evict_if_needed()
|
||||
return entry["id"]
|
||||
with self._lock:
|
||||
entry: Dict[str, Any] = {
|
||||
"id": str(self._next_id),
|
||||
"text": text,
|
||||
"type": "long",
|
||||
"metadata": metadata,
|
||||
}
|
||||
self._next_id += 1
|
||||
self._data.append(entry)
|
||||
self._evict_if_needed()
|
||||
return entry["id"]
|
||||
|
||||
def search_long_term(
|
||||
self, query: str, limit: int = 5, **kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
results = [
|
||||
e
|
||||
for e in self._data
|
||||
if e["type"] == "long" and query.lower() in e["text"].lower()
|
||||
]
|
||||
return results[:limit]
|
||||
with self._lock:
|
||||
results = [
|
||||
e
|
||||
for e in self._data
|
||||
if e["type"] == "long" and query.lower() in e["text"].lower()
|
||||
]
|
||||
return results[:limit]
|
||||
|
||||
def delete_memory(self, memory_id: str, tier: Optional[str] = None, **kwargs) -> bool:
|
||||
"""Delete a memory by ID. Returns True if an entry was removed.
|
||||
@@ -87,25 +99,29 @@ class InMemoryAdapter:
|
||||
tier. IDs here are globally unique (shared monotonic counter), so tier
|
||||
is only used to preserve the caller's scope, never for disambiguation.
|
||||
"""
|
||||
before = len(self._data)
|
||||
self._data = [
|
||||
e
|
||||
for e in self._data
|
||||
if not (
|
||||
e.get("id") == str(memory_id)
|
||||
and (tier is None or e.get("type") == tier)
|
||||
)
|
||||
]
|
||||
return len(self._data) < before
|
||||
with self._lock:
|
||||
before = len(self._data)
|
||||
self._data = [
|
||||
e
|
||||
for e in self._data
|
||||
if not (
|
||||
e.get("id") == str(memory_id)
|
||||
and (tier is None or e.get("type") == tier)
|
||||
)
|
||||
]
|
||||
return len(self._data) < before
|
||||
|
||||
def reset_short_term(self) -> None:
|
||||
"""Clear all short-term memories."""
|
||||
self._data = [e for e in self._data if e.get("type") != "short"]
|
||||
with self._lock:
|
||||
self._data = [e for e in self._data if e.get("type") != "short"]
|
||||
|
||||
def reset_long_term(self) -> None:
|
||||
"""Clear all long-term memories."""
|
||||
self._data = [e for e in self._data if e.get("type") != "long"]
|
||||
with self._lock:
|
||||
self._data = [e for e in self._data if e.get("type") != "long"]
|
||||
|
||||
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
|
||||
# Return defensive copy to prevent external mutation of internal state
|
||||
return [dict(entry) for entry in self._data]
|
||||
with self._lock:
|
||||
# Return defensive copy to prevent external mutation of internal state
|
||||
return [dict(entry) for entry in self._data]
|
||||
@@ -18,6 +18,7 @@ from .core import MemoryCoreMixin
|
||||
from .protocols import MemoryProtocol
|
||||
from .adapters import get_memory_adapter, get_first_available_memory_adapter
|
||||
from .adapters.sqlite_adapter import SqliteMemoryAdapter
|
||||
from .adapters.factories import sanitize_chroma_metadata
|
||||
|
||||
# Disable litellm telemetry before any imports
|
||||
os.environ["LITELLM_TELEMETRY"] = "False"
|
||||
@@ -979,19 +980,8 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
# -------------------------------------------------------------------------
|
||||
def _sanitize_metadata(self, metadata: Dict) -> Dict:
|
||||
"""Sanitize metadata for ChromaDB - convert to acceptable types"""
|
||||
sanitized = {}
|
||||
for k, v in metadata.items():
|
||||
if v is None:
|
||||
continue
|
||||
if isinstance(v, (str, int, float, bool)):
|
||||
sanitized[k] = v
|
||||
elif isinstance(v, dict):
|
||||
# Convert dict to string representation
|
||||
sanitized[k] = str(v)
|
||||
else:
|
||||
# Convert other types to string
|
||||
sanitized[k] = str(v)
|
||||
return sanitized
|
||||
from .adapters.factories import sanitize_chroma_metadata
|
||||
return sanitize_chroma_metadata(metadata)
|
||||
|
||||
def store_long_term(
|
||||
self,
|
||||
|
||||
@@ -694,10 +694,15 @@ class Task:
|
||||
# Store task output in memory
|
||||
try:
|
||||
logger.info(f"Task {self.id}: Storing task output in memory...")
|
||||
self.store_in_memory(
|
||||
content=task_output.raw,
|
||||
agent_name=self.agent.name if self.agent else "Agent",
|
||||
task_id=self.id
|
||||
# store_in_memory -> Memory.store_long_term is synchronous
|
||||
# (embedding-API call + DB write). Offload to a thread so it
|
||||
# never blocks the event loop for tasks batched into the same
|
||||
# asyncio.gather(...) parallel fan-out.
|
||||
await asyncio.to_thread(
|
||||
self.store_in_memory,
|
||||
task_output.raw,
|
||||
self.agent.name if self.agent else "Agent",
|
||||
self.id
|
||||
)
|
||||
logger.info(f"Task {self.id}: Task output stored in memory")
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Unit tests for deep-merged config resolution across global + project files.
|
||||
|
||||
Covers the fix for first-file-wins: a project config must selectively override
|
||||
the user-global config instead of shadowing it entirely.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.config import loader
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_cache():
|
||||
loader.clear_config_cache()
|
||||
yield
|
||||
loader.clear_config_cache()
|
||||
|
||||
|
||||
def _write_yaml(path: Path, text: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def test_deep_merge_recurses_dicts_and_replaces_scalars():
|
||||
base = {"defaults": {"model": "global", "memory": {"backend": "sqlite"}}}
|
||||
override = {"defaults": {"model": "project", "memory": {"provider": "x"}}}
|
||||
merged = loader._deep_merge(base, override)
|
||||
assert merged == {
|
||||
"defaults": {
|
||||
"model": "project",
|
||||
"memory": {"backend": "sqlite", "provider": "x"},
|
||||
}
|
||||
}
|
||||
# Inputs untouched.
|
||||
assert base["defaults"]["model"] == "global"
|
||||
|
||||
|
||||
def test_deep_merge_lists_replace():
|
||||
base = {"plugins": {"enabled": ["a", "b"]}}
|
||||
override = {"plugins": {"enabled": ["c"]}}
|
||||
assert loader._deep_merge(base, override) == {"plugins": {"enabled": ["c"]}}
|
||||
|
||||
|
||||
def _run_with_dirs(tmp_path, monkeypatch, home_dir, cwd_dir):
|
||||
monkeypatch.setenv("PRAISONAI_HOME", str(home_dir))
|
||||
monkeypatch.chdir(cwd_dir)
|
||||
loader.clear_config_cache()
|
||||
from praisonaiagents import paths
|
||||
paths._clear_cache()
|
||||
|
||||
|
||||
def test_global_only(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
_write_yaml(home / "config.yaml", "defaults:\n model: global-model\n")
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_run_with_dirs(tmp_path, monkeypatch, home, project)
|
||||
|
||||
raw = loader._load_config()
|
||||
assert raw["defaults"]["model"] == "global-model"
|
||||
|
||||
|
||||
def test_project_only(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
project = tmp_path / "project"
|
||||
_write_yaml(project / ".praisonai" / "config.yaml", "defaults:\n model: proj-model\n")
|
||||
_run_with_dirs(tmp_path, monkeypatch, home, project)
|
||||
|
||||
raw = loader._load_config()
|
||||
assert raw["defaults"]["model"] == "proj-model"
|
||||
|
||||
|
||||
def test_both_present_project_overrides_with_global_fallthrough(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
_write_yaml(
|
||||
home / "config.yaml",
|
||||
"defaults:\n model: global-model\n base_url: https://global\n",
|
||||
)
|
||||
project = tmp_path / "project"
|
||||
_write_yaml(
|
||||
project / ".praisonai" / "config.yaml",
|
||||
"defaults:\n model: project-model\n",
|
||||
)
|
||||
_run_with_dirs(tmp_path, monkeypatch, home, project)
|
||||
|
||||
raw = loader._load_config()
|
||||
# Project overrides model; global base_url falls through.
|
||||
assert raw["defaults"]["model"] == "project-model"
|
||||
assert raw["defaults"]["base_url"] == "https://global"
|
||||
|
||||
|
||||
def test_ancestor_directory_discovery_nearest_wins(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
root = tmp_path / "workspace"
|
||||
_write_yaml(
|
||||
root / ".praisonai" / "config.yaml",
|
||||
"defaults:\n model: root-model\n base_url: https://root\n",
|
||||
)
|
||||
nested = root / "sub" / "deep"
|
||||
_write_yaml(
|
||||
nested / ".praisonai" / "config.yaml",
|
||||
"defaults:\n model: nested-model\n",
|
||||
)
|
||||
_run_with_dirs(tmp_path, monkeypatch, home, nested)
|
||||
|
||||
raw = loader._load_config()
|
||||
# Nearest (nested) wins for model; ancestor base_url falls through.
|
||||
assert raw["defaults"]["model"] == "nested-model"
|
||||
assert raw["defaults"]["base_url"] == "https://root"
|
||||
|
||||
|
||||
def test_write_target_prefers_highest_precedence_file(tmp_path, monkeypatch):
|
||||
# Global config exists AND a nearer ancestor config exists. The CLI must
|
||||
# write to the ancestor (which wins the merge), not the global — otherwise
|
||||
# the ancestor overrides the write on reload and the CLI has no effect.
|
||||
home = tmp_path / "home"
|
||||
_write_yaml(home / "config.yaml", "plugins:\n enabled: []\n")
|
||||
root = tmp_path / "workspace"
|
||||
ancestor_cfg = root / ".praisonai" / "config.yaml"
|
||||
_write_yaml(ancestor_cfg, "plugins:\n enabled: []\n")
|
||||
nested = root / "sub" / "deep"
|
||||
nested.mkdir(parents=True)
|
||||
_run_with_dirs(tmp_path, monkeypatch, home, nested)
|
||||
|
||||
target = loader._config_write_target()
|
||||
assert target == ancestor_cfg
|
||||
|
||||
|
||||
def test_set_plugin_enabled_writes_ancestor_and_takes_effect(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
_write_yaml(home / "config.yaml", "plugins:\n enabled: []\n")
|
||||
root = tmp_path / "workspace"
|
||||
ancestor_cfg = root / ".praisonai" / "config.yaml"
|
||||
_write_yaml(ancestor_cfg, "plugins:\n enabled: []\n")
|
||||
nested = root / "sub" / "deep"
|
||||
nested.mkdir(parents=True)
|
||||
_run_with_dirs(tmp_path, monkeypatch, home, nested)
|
||||
|
||||
written = loader.set_plugin_enabled("pii_guardrail", True)
|
||||
assert written == ancestor_cfg
|
||||
# The runtime (merged) view reflects the change, not overridden by global.
|
||||
loader.clear_config_cache()
|
||||
assert loader.get_enabled_plugins() == ["pii_guardrail"]
|
||||
|
||||
|
||||
def test_single_file_fast_path_matches_direct_parse(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
project = tmp_path / "project"
|
||||
cfg = project / ".praisonai" / "config.yaml"
|
||||
_write_yaml(cfg, "defaults:\n model: only-model\n")
|
||||
_run_with_dirs(tmp_path, monkeypatch, home, project)
|
||||
|
||||
files = loader._discover_config_files()
|
||||
assert files == [cfg]
|
||||
assert loader._load_config() == loader._parse_config_file(cfg)
|
||||
@@ -174,7 +174,7 @@ class TestYamlConfigDiscovery:
|
||||
" pii_guardrail:\n"
|
||||
" redact: [email]\n"
|
||||
)
|
||||
monkeypatch.setattr(loader, "_find_config_file", lambda: cfg)
|
||||
monkeypatch.setattr(loader, "_discover_config_files", lambda: [cfg])
|
||||
data = loader._load_config()
|
||||
assert data["plugins"]["enabled"] is True
|
||||
assert data["plugins"]["pii_guardrail"] == {"redact": ["email"]}
|
||||
|
||||
@@ -25,9 +25,13 @@ def test_skill_allowed_tools_do_not_leak_across_agents():
|
||||
registry._agent_tool_auto_approve.clear()
|
||||
|
||||
# Mock the skill_manager to avoid constructing a real Agent.
|
||||
# ``_approval_scope_id`` is the unique per-instance key the grant is keyed
|
||||
# by (a real Agent sets it in __init__); set it explicitly here so the Mock
|
||||
# doesn't auto-generate an opaque attribute.
|
||||
agent_x = Mock()
|
||||
agent_x.name = "agent_x"
|
||||
agent_x.display_name = "agent_x"
|
||||
agent_x._approval_scope_id = "agent_x_scope"
|
||||
agent_x.skill_manager = Mock()
|
||||
agent_x.skill_manager.get_allowed_tools.return_value = ["read_file"]
|
||||
agent_x.skill_manager.invoke.return_value = "Skill activated"
|
||||
@@ -38,10 +42,13 @@ def test_skill_allowed_tools_do_not_leak_across_agents():
|
||||
result = agent_x._resolve_skill_invocation("/demo")
|
||||
assert result == "Skill activated"
|
||||
|
||||
# Correct per-agent, per-tool scoping:
|
||||
assert registry.is_auto_approved("read_file", agent_name="agent_x")
|
||||
# Correct per-agent-instance, per-tool scoping (keyed by the unique scope id):
|
||||
assert registry.is_auto_approved("read_file", agent_name="agent_x_scope")
|
||||
# The grant must NOT be queryable by the display name alone, so another
|
||||
# unnamed agent defaulting to the same display name cannot inherit it.
|
||||
assert not registry.is_auto_approved("read_file", agent_name="agent_x")
|
||||
assert not registry.is_auto_approved("read_file", agent_name="agent_y")
|
||||
assert not registry.is_auto_approved("write_file", agent_name="agent_x")
|
||||
assert not registry.is_auto_approved("write_file", agent_name="agent_x_scope")
|
||||
|
||||
# Pre-fix bug signature: agent-wide backend swap must NOT happen anymore.
|
||||
assert registry._global_backend is None
|
||||
|
||||
@@ -144,6 +144,131 @@ class TestConsoleBackend:
|
||||
assert decision.approved is True
|
||||
|
||||
|
||||
class TestDiffPreview:
|
||||
def test_edit_file_builds_diff(self):
|
||||
from praisonaiagents.approval.utils import build_diff_preview
|
||||
diff = build_diff_preview(
|
||||
"edit_file",
|
||||
{"filepath": "/tmp/does-not-exist.py", "old_string": "foo", "new_string": "bar"},
|
||||
)
|
||||
assert diff is not None
|
||||
assert "-foo" in diff
|
||||
assert "+bar" in diff
|
||||
|
||||
def test_edit_file_replace_first_only_by_default(self, tmp_path):
|
||||
from praisonaiagents.approval.utils import build_diff_preview
|
||||
target = tmp_path / "dup.py"
|
||||
target.write_text("foo\nfoo\n", encoding="utf-8")
|
||||
diff = build_diff_preview(
|
||||
"edit_file",
|
||||
{"filepath": str(target), "old_string": "foo", "new_string": "bar"},
|
||||
)
|
||||
assert diff is not None
|
||||
# Only the first occurrence changes when replace_all is falsey.
|
||||
assert diff.count("+bar") == 1
|
||||
assert diff.count("-foo") == 1
|
||||
|
||||
def test_edit_file_replace_all_when_requested(self, tmp_path):
|
||||
from praisonaiagents.approval.utils import build_diff_preview
|
||||
target = tmp_path / "dup.py"
|
||||
target.write_text("foo\nfoo\n", encoding="utf-8")
|
||||
diff = build_diff_preview(
|
||||
"edit_file",
|
||||
{
|
||||
"filepath": str(target),
|
||||
"old_string": "foo",
|
||||
"new_string": "bar",
|
||||
"replace_all": True,
|
||||
},
|
||||
)
|
||||
assert diff is not None
|
||||
assert diff.count("+bar") == 2
|
||||
assert diff.count("-foo") == 2
|
||||
|
||||
def test_acp_edit_file_builds_whole_file_diff(self, tmp_path):
|
||||
from praisonaiagents.approval.utils import build_diff_preview
|
||||
target = tmp_path / "acp.py"
|
||||
target.write_text("old line\n", encoding="utf-8")
|
||||
diff = build_diff_preview(
|
||||
"acp_edit_file",
|
||||
{"filepath": str(target), "new_content": "new line\n"},
|
||||
)
|
||||
assert diff is not None
|
||||
assert "-old line" in diff
|
||||
assert "+new line" in diff
|
||||
|
||||
def test_acp_edit_file_missing_new_content_returns_none(self):
|
||||
from praisonaiagents.approval.utils import build_diff_preview
|
||||
assert build_diff_preview("acp_edit_file", {"filepath": "a.py"}) is None
|
||||
|
||||
def test_write_file_builds_diff_for_new_file(self):
|
||||
from praisonaiagents.approval.utils import build_diff_preview
|
||||
diff = build_diff_preview(
|
||||
"write_file",
|
||||
{"filepath": "/tmp/definitely-missing-xyz.txt", "content": "hello\nworld\n"},
|
||||
)
|
||||
assert diff is not None
|
||||
assert "+hello" in diff
|
||||
|
||||
def test_apply_patch_returns_patch_verbatim(self):
|
||||
from praisonaiagents.approval.utils import build_diff_preview
|
||||
patch_text = "*** Update File: a.py\n-old\n+new"
|
||||
assert build_diff_preview("apply_patch", {"patch": patch_text}) == patch_text
|
||||
|
||||
def test_non_edit_tool_returns_none(self):
|
||||
from praisonaiagents.approval.utils import build_diff_preview
|
||||
assert build_diff_preview("execute_command", {"command": "ls"}) is None
|
||||
|
||||
def test_console_render_markup_colours_and_escapes(self):
|
||||
from praisonaiagents.approval.backends import ConsoleBackend
|
||||
out = ConsoleBackend._render_diff_markup(
|
||||
"@@ -1 +1 @@\n-old\n+new [bold]x[/bold]"
|
||||
)
|
||||
assert "[green]" in out and "[red]" in out and "[cyan]" in out
|
||||
assert "\\[bold]" in out
|
||||
|
||||
def test_prompt_renders_diff_when_present(self):
|
||||
from praisonaiagents.approval.backends import ConsoleBackend
|
||||
from praisonaiagents.approval.protocols import ApprovalRequest
|
||||
backend = ConsoleBackend()
|
||||
req = ApprovalRequest(
|
||||
tool_name="edit_file",
|
||||
arguments={"filepath": "a.py", "old_string": "foo", "new_string": "bar"},
|
||||
risk_level="high",
|
||||
context={"diff": "@@ -1 +1 @@\n-foo\n+bar"},
|
||||
)
|
||||
with patch("praisonaiagents.approval.backends._get_rich_console") as mock_console_cls, \
|
||||
patch("praisonaiagents.approval.backends._get_rich_panel") as mock_panel, \
|
||||
patch("praisonaiagents.approval.backends._get_rich_prompt") as mock_prompt:
|
||||
mock_console_cls.return_value = MagicMock()
|
||||
mock_panel.return_value = MagicMock()
|
||||
mock_prompt.return_value.ask.return_value = "n"
|
||||
backend._prompt_user(req)
|
||||
panel_body = mock_panel.return_value.call_args[0][0]
|
||||
assert "+bar" in panel_body and "-foo" in panel_body
|
||||
assert "Diff:" in panel_body
|
||||
|
||||
def test_prompt_falls_back_to_arguments_when_no_diff(self):
|
||||
from praisonaiagents.approval.backends import ConsoleBackend
|
||||
from praisonaiagents.approval.protocols import ApprovalRequest
|
||||
backend = ConsoleBackend()
|
||||
req = ApprovalRequest(
|
||||
tool_name="execute_command",
|
||||
arguments={"command": "ls -la"},
|
||||
risk_level="critical",
|
||||
)
|
||||
with patch("praisonaiagents.approval.backends._get_rich_console") as mock_console_cls, \
|
||||
patch("praisonaiagents.approval.backends._get_rich_panel") as mock_panel, \
|
||||
patch("praisonaiagents.approval.backends._get_rich_prompt") as mock_prompt:
|
||||
mock_console_cls.return_value = MagicMock()
|
||||
mock_panel.return_value = MagicMock()
|
||||
mock_prompt.return_value.ask.return_value = "n"
|
||||
backend._prompt_user(req)
|
||||
panel_body = mock_panel.return_value.call_args[0][0]
|
||||
assert "ls -la" in panel_body
|
||||
assert "Arguments:" in panel_body
|
||||
|
||||
|
||||
# ── Registry tests ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -1132,6 +1132,8 @@ def run_main(
|
||||
worktree: bool = typer.Option(False, "--worktree", help="Run on an isolated git worktree/branch (branch-per-task); no-op when not a git repo"),
|
||||
keep: bool = typer.Option(False, "--keep", help="With --worktree, keep the worktree/branch after the run for review instead of tearing it down"),
|
||||
append_system_prompt: Optional[str] = typer.Option(None, "--append-system-prompt", help="Append text (or @file) to the system prompt for this invocation only. Env fallback: PRAISONAI_APPEND_SYSTEM_PROMPT"),
|
||||
# Multimodal attachment for one-shot runs
|
||||
image: Optional[List[str]] = typer.Option(None, "--image", help="Attach an image (local path or http(s):// URL) to a one-shot run so the agent can see it. Repeatable."),
|
||||
):
|
||||
"""
|
||||
Run agents from a file or prompt.
|
||||
@@ -1143,6 +1145,8 @@ def run_main(
|
||||
praisonai run "Add tests" --session abc123
|
||||
praisonai run agents.yaml --interactive
|
||||
praisonai run "What is 2+2?" --profile
|
||||
praisonai run "Describe this screenshot" --image bug.png
|
||||
praisonai run "Compare these" --image a.png --image b.png
|
||||
"""
|
||||
output = get_output_controller()
|
||||
_ = get_current_context() # Initialize context
|
||||
@@ -1312,6 +1316,41 @@ def run_main(
|
||||
output.print_error("--attach is only supported for direct prompt runs")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# --image is a per-invocation multimodal attachment for direct prompt runs.
|
||||
# Only the direct-prompt (and interpolated --command) path threads it to the
|
||||
# vision-capable handle_direct_prompt/ImageHandler; the custom-agent, YAML
|
||||
# file, and profiling flows have separate execution paths that do not carry
|
||||
# it. Reject those combinations up front so an --image is never silently
|
||||
# dropped and the user gets a result without the attachment they asked for.
|
||||
if image and (agent or profile or profile_deep or _is_yaml_file(target or "")):
|
||||
output.print_error(
|
||||
"--image is only supported for direct prompt runs "
|
||||
"(not with --agent, --profile, or a YAML file)"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# --output actions builds a bare Agent (which can't carry the attachment) and
|
||||
# the vision path can't honour the structured actions output contract, so the
|
||||
# two are mutually exclusive. Reject up front rather than returning a plain
|
||||
# vision response when the user asked for actions-form output.
|
||||
if image and output_mode == "actions":
|
||||
output.print_error("--image cannot be combined with --output actions")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# ImageHandler treats a comma as the multi-image separator, so a single
|
||||
# path/URL that itself contains a comma would be split into fragments and
|
||||
# fail validation (or attach the wrong files). Reject it with a clear error
|
||||
# instead of silently corrupting the attachment; users pass multiple images
|
||||
# with repeated --image flags.
|
||||
if image:
|
||||
for _img in image:
|
||||
if "," in _img:
|
||||
output.print_error(
|
||||
f"Invalid --image value {_img!r}: paths/URLs must not contain a "
|
||||
"comma. Pass multiple images with repeated --image flags."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Handle custom agent or command
|
||||
if agent:
|
||||
from praisonai_code.cli.features.custom_definitions import load_agent_from_name
|
||||
@@ -1397,6 +1436,7 @@ def run_main(
|
||||
allow_local_tools=allow_local_tools,
|
||||
instructions=merged_instructions,
|
||||
append_system_prompt=resolved_append_prompt,
|
||||
image=image,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -1582,6 +1622,7 @@ def run_main(
|
||||
isolated=worktree,
|
||||
instructions=merged_instructions,
|
||||
append_system_prompt=resolved_append_prompt,
|
||||
image=image,
|
||||
)
|
||||
|
||||
|
||||
@@ -1756,6 +1797,7 @@ def _run_prompt(
|
||||
isolated: bool = False,
|
||||
instructions: Optional[List[str]] = None,
|
||||
append_system_prompt: Optional[str] = None,
|
||||
image: Optional[List[str]] = None,
|
||||
):
|
||||
"""Run a direct prompt."""
|
||||
output = get_output_controller()
|
||||
@@ -1851,6 +1893,7 @@ def _run_prompt(
|
||||
and thinking_budget is None
|
||||
and not isolated
|
||||
and not append_system_prompt
|
||||
and not image
|
||||
and not any([
|
||||
mcp, mcp_servers, tools, toolset, approval, approve_all_tools,
|
||||
memory, permissions_config, fork, instructions,
|
||||
@@ -1875,7 +1918,10 @@ def _run_prompt(
|
||||
):
|
||||
return
|
||||
|
||||
if output_mode == "actions":
|
||||
# An --image attachment is handled by the vision path in
|
||||
# handle_direct_prompt (ImageHandler); the "actions" fast path builds a
|
||||
# bare Agent and would silently drop it, so fall through when set.
|
||||
if output_mode == "actions" and not image:
|
||||
from praisonaiagents import Agent
|
||||
from ..state.project_sessions import build_cli_memory_config, apply_cli_session_continuity
|
||||
|
||||
@@ -2010,7 +2056,7 @@ def _run_prompt(
|
||||
args.claude_memory = False
|
||||
args.guardrail = None
|
||||
args.metrics = False
|
||||
args.image = None
|
||||
args.image = ",".join(image) if image else None
|
||||
args.image_generate = False
|
||||
args.telemetry = False
|
||||
args.mcp = mcp
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Tests for `--image` attachment on one-shot `praisonai run` (issue #4381).
|
||||
|
||||
The core Agent already accepts image input and the wrapper's
|
||||
`handle_direct_prompt` routes `self.args.image` to the vision path. The only gap
|
||||
was the CLI surface: `run` had no `--image` option and hardcoded
|
||||
`args.image = None`. These tests pin that a `--image` attachment reaches the
|
||||
agent via `args.image`, that a text-only run is unaffected (image stays None and
|
||||
the warm-runtime fast path still applies), and that an image run bypasses the
|
||||
warm runtime (which can't carry an attachment).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_code.cli.commands import run as run_cmd
|
||||
|
||||
|
||||
class _RecordingOutput:
|
||||
def __init__(self):
|
||||
self.results = []
|
||||
self.is_json_mode = False
|
||||
|
||||
def emit_result(self, message=None, data=None):
|
||||
self.results.append((message, data))
|
||||
|
||||
def print_info(self, *a, **k):
|
||||
pass
|
||||
|
||||
def print_warning(self, *a, **k):
|
||||
pass
|
||||
|
||||
|
||||
def _install_fake_praisonai(monkeypatch):
|
||||
"""Install a fake PraisonAI that records the args it receives and returns."""
|
||||
captured = {}
|
||||
|
||||
class _FakePraisonAI:
|
||||
def __init__(self, *a, **k):
|
||||
self.config_list = [{}]
|
||||
self.args = None
|
||||
|
||||
def handle_direct_prompt(self, prompt):
|
||||
captured["args"] = self.args
|
||||
captured["prompt"] = prompt
|
||||
return "done"
|
||||
|
||||
fake_main = types.ModuleType("praisonai_code.cli.main")
|
||||
fake_main.PraisonAI = _FakePraisonAI
|
||||
monkeypatch.setitem(sys.modules, "praisonai_code.cli.main", fake_main)
|
||||
return captured
|
||||
|
||||
|
||||
def test_image_reaches_agent_via_args(monkeypatch):
|
||||
"""A single `--image` attachment is passed through as `args.image`."""
|
||||
output = _RecordingOutput()
|
||||
monkeypatch.setattr(run_cmd, "get_output_controller", lambda: output)
|
||||
monkeypatch.setattr(run_cmd, "_try_attach_runtime", lambda *a, **k: False)
|
||||
|
||||
captured = _install_fake_praisonai(monkeypatch)
|
||||
|
||||
run_cmd._run_prompt(
|
||||
"describe this",
|
||||
no_save=True,
|
||||
image=["bug.png"],
|
||||
)
|
||||
|
||||
assert captured["args"].image == "bug.png"
|
||||
|
||||
|
||||
def test_multiple_images_are_comma_joined(monkeypatch):
|
||||
"""Repeated `--image` flags are joined for the multi-image vision path."""
|
||||
output = _RecordingOutput()
|
||||
monkeypatch.setattr(run_cmd, "get_output_controller", lambda: output)
|
||||
monkeypatch.setattr(run_cmd, "_try_attach_runtime", lambda *a, **k: False)
|
||||
|
||||
captured = _install_fake_praisonai(monkeypatch)
|
||||
|
||||
run_cmd._run_prompt(
|
||||
"compare these",
|
||||
no_save=True,
|
||||
image=["a.png", "b.png"],
|
||||
)
|
||||
|
||||
assert captured["args"].image == "a.png,b.png"
|
||||
|
||||
|
||||
def test_text_only_run_leaves_image_none(monkeypatch):
|
||||
"""A run without `--image` keeps `args.image` None (unchanged behaviour)."""
|
||||
output = _RecordingOutput()
|
||||
monkeypatch.setattr(run_cmd, "get_output_controller", lambda: output)
|
||||
monkeypatch.setattr(run_cmd, "_try_attach_runtime", lambda *a, **k: False)
|
||||
|
||||
captured = _install_fake_praisonai(monkeypatch)
|
||||
|
||||
run_cmd._run_prompt("just text", no_save=True)
|
||||
|
||||
assert captured["args"].image is None
|
||||
|
||||
|
||||
def test_image_run_bypasses_warm_runtime(monkeypatch):
|
||||
"""An image attachment must run in-process, never via the warm runtime.
|
||||
|
||||
The warm runtime is a separate process that can't carry the CLI's
|
||||
per-invocation attachment, so forwarding an image run would silently drop
|
||||
it. The run must stay in-process when an image is set.
|
||||
"""
|
||||
output = _RecordingOutput()
|
||||
monkeypatch.setattr(run_cmd, "get_output_controller", lambda: output)
|
||||
|
||||
attach_calls = []
|
||||
|
||||
def _fake_attach(*args, **kwargs):
|
||||
attach_calls.append((args, kwargs))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(run_cmd, "_try_attach_runtime", _fake_attach)
|
||||
_install_fake_praisonai(monkeypatch)
|
||||
|
||||
run_cmd._run_prompt("describe this", no_save=True, image=["bug.png"])
|
||||
|
||||
assert not attach_calls, "image run must not forward to warm runtime"
|
||||
|
||||
|
||||
def test_text_only_run_still_allows_warm_runtime(monkeypatch):
|
||||
"""Without an image, an eligible no-save run still attaches warm."""
|
||||
output = _RecordingOutput()
|
||||
monkeypatch.setattr(run_cmd, "get_output_controller", lambda: output)
|
||||
|
||||
attach_calls = []
|
||||
|
||||
def _fake_attach(*args, **kwargs):
|
||||
attach_calls.append((args, kwargs))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(run_cmd, "_try_attach_runtime", _fake_attach)
|
||||
_install_fake_praisonai(monkeypatch)
|
||||
|
||||
run_cmd._run_prompt("just text", no_save=True)
|
||||
|
||||
assert attach_calls, "an eligible no-save run should attach to the warm runtime"
|
||||
|
||||
|
||||
def _invoke_run(monkeypatch, args):
|
||||
"""Invoke the `run` command function, stubbing credential onboarding.
|
||||
|
||||
``run_main`` is the callback of a Typer group; mounting it as a plain
|
||||
command on a throwaway Typer app lets ``CliRunner`` parse the options the
|
||||
same way the real CLI does. The guards under test raise ``typer.Exit``
|
||||
before any agent runs, so the only side effect that must be neutralised is
|
||||
the pre-run credential check.
|
||||
"""
|
||||
import typer
|
||||
from typer.testing import CliRunner
|
||||
|
||||
import praisonai_code.llm.credentials as _creds
|
||||
import praisonai_code._wrapper_bridge as _bridge
|
||||
|
||||
monkeypatch.setattr(
|
||||
_creds, "ensure_configured_or_onboard", lambda *a, **k: "gpt-4o"
|
||||
)
|
||||
# Plain-text direct-prompt runs gate on the wrapper being installed; in the
|
||||
# test env it is absent, so pretend it's available to reach the guards.
|
||||
monkeypatch.setattr(_bridge, "wrapper_available", lambda *a, **k: True)
|
||||
test_app = typer.Typer()
|
||||
test_app.command()(run_cmd.run_main)
|
||||
return CliRunner().invoke(test_app, args)
|
||||
|
||||
|
||||
def test_image_with_agent_is_rejected(monkeypatch):
|
||||
"""`--image` with `--agent` fails before running (no silent drop)."""
|
||||
result = _invoke_run(
|
||||
monkeypatch, ["describe this", "--agent", "researcher", "--image", "a.png"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "--image is only supported for direct prompt runs" in result.output
|
||||
|
||||
|
||||
def test_image_with_profile_is_rejected(monkeypatch):
|
||||
"""`--image` with `--profile` fails before running (no silent drop)."""
|
||||
result = _invoke_run(
|
||||
monkeypatch, ["describe this", "--profile", "--image", "a.png"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "--image is only supported for direct prompt runs" in result.output
|
||||
|
||||
|
||||
def test_image_with_yaml_file_is_rejected(monkeypatch, tmp_path):
|
||||
"""`--image` with a YAML-file target fails before running (no silent drop)."""
|
||||
yaml_file = tmp_path / "agents.yaml"
|
||||
yaml_file.write_text("framework: praisonai\n")
|
||||
result = _invoke_run(
|
||||
monkeypatch, [str(yaml_file), "--image", "a.png"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "--image is only supported for direct prompt runs" in result.output
|
||||
|
||||
|
||||
def test_image_with_actions_output_is_rejected(monkeypatch):
|
||||
"""`--image` with `--output actions` fails (actions contract can't be met)."""
|
||||
result = _invoke_run(
|
||||
monkeypatch, ["describe this", "--output", "actions", "--image", "a.png"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "--image cannot be combined with --output actions" in result.output
|
||||
|
||||
|
||||
def test_image_value_with_comma_is_rejected(monkeypatch):
|
||||
"""A single `--image` value containing a comma is rejected, not corrupted.
|
||||
|
||||
ImageHandler splits on commas for the multi-image path, so a comma inside a
|
||||
single path/URL would be fragmented. Users pass multiple images with
|
||||
repeated `--image` flags instead.
|
||||
"""
|
||||
result = _invoke_run(
|
||||
monkeypatch, ["describe this", "--image", "a,b.png"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "must not contain a comma" in result.output
|
||||
@@ -26,9 +26,10 @@ wording and the exact click are below.
|
||||
Not sure which Mac you have: → About This Mac. "Chip" means Apple silicon,
|
||||
"Processor" means Intel.
|
||||
|
||||
Gatekeeper refuses an unsigned app on a double-click with *"PraisonAI is
|
||||
damaged and can't be opened"* — which is misleading. Nothing is damaged; the
|
||||
app simply has no signature.
|
||||
Gatekeeper refuses an app it cannot verify. The app carries an ad-hoc
|
||||
signature, which makes the signature *valid* but not *trusted* — so macOS says
|
||||
it cannot check for malicious software, or that the developer is
|
||||
unidentified.
|
||||
|
||||
1. Drag **PraisonAI** to Applications.
|
||||
2. **Right-click** it → **Open** → **Open**.
|
||||
@@ -42,6 +43,18 @@ it downloaded the file:
|
||||
xattr -dr com.apple.quarantine /Applications/PraisonAI.app
|
||||
```
|
||||
|
||||
### If you see *"PraisonAI is damaged and can't be opened"*
|
||||
|
||||
That message means something different, and right-click → Open will **not**
|
||||
get past it: macOS says "damaged" when a signature is present but *invalid*,
|
||||
not when one is merely untrusted.
|
||||
|
||||
**v4.7.2 shipped in exactly that state.** The bundle was never signed, so the
|
||||
only signature on it was the one the Rust linker leaves on the executable, and
|
||||
that signature declares a resource seal an unsigned bundle does not have. Every
|
||||
Mac rejected it. Use v4.7.3 or later; the release build now refuses to attach a
|
||||
macOS bundle whose signature does not verify.
|
||||
|
||||
## Windows
|
||||
|
||||
SmartScreen shows a full-screen *"Windows protected your PC"* with the Run
|
||||
|
||||
@@ -1136,15 +1136,26 @@ def _get_agent(session_id: str = "default", tools: bool = True):
|
||||
# Every event carries `msg_id` so the client can address a specific message
|
||||
# rather than assuming the last one is the live one.
|
||||
#
|
||||
# start {msg_id}
|
||||
# reasoning {msg_id, text} incremental, collapsible
|
||||
# delta {msg_id, text} assistant text
|
||||
# tool_call {msg_id, call_id, name, args}
|
||||
# tool_result {msg_id, call_id, name, ok, output, seconds}
|
||||
# usage {msg_id, chars, seconds, ttft}
|
||||
# cancelled {msg_id, run_id}
|
||||
# error {msg_id, message, kind}
|
||||
# end {msg_id}
|
||||
# This list is the whole vocabulary: eleven events, no more. A client written
|
||||
# against a subset silently ignores the rest, so approval_request in particular
|
||||
# -- the human-in-the-loop tool gate -- must appear here or the run blocks
|
||||
# invisibly until its timeout. It is not a comment that can drift silently --
|
||||
# the StreamProtocolVocabulary test in test_portability.py parses these names
|
||||
# *and* every emit(...) / _emit_now(...) call site and asserts the two sets
|
||||
# match in both directions, so a new event with no line here (or a line here
|
||||
# with no emitter) fails CI.
|
||||
#
|
||||
# start {msg_id, run_id}
|
||||
# reasoning {msg_id, text} incremental, collapsible
|
||||
# delta {msg_id, text} assistant text
|
||||
# tool_drafting {msg_id, name} "preparing tool..." before the call
|
||||
# tool_call {msg_id, call_id, name, args}
|
||||
# tool_result {msg_id, call_id, name, ok, output, seconds}
|
||||
# approval_request {msg_id, approval_id, call_id, name, args} human-in-the-loop gate
|
||||
# usage {msg_id, chars, seconds, ttft}
|
||||
# cancelled {msg_id, run_id}
|
||||
# error {msg_id, message, kind}
|
||||
# end {msg_id, user_index, assistant_index, versions, active}
|
||||
|
||||
def _classify_stream_item(item):
|
||||
"""Map one yielded item onto a protocol event.
|
||||
|
||||
@@ -13,6 +13,7 @@ exercised rather than merely written.
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -329,6 +330,13 @@ class SecretDeletion(unittest.TestCase):
|
||||
# The key is in both stores and the file store cannot be written to.
|
||||
# Reporting success here told the user the key was gone while `get`
|
||||
# went on serving the plaintext copy.
|
||||
if os.name == "nt":
|
||||
# The precondition is an unwritable store, established with
|
||||
# chmod(0o500). Windows does not honour POSIX mode bits for
|
||||
# directory writability, so the delete would succeed and the
|
||||
# scenario this guards -- a store that *cannot* perform the delete
|
||||
# -- is unreachable here.
|
||||
self.skipTest("chmod(0o500) does not make a directory unwritable on Windows")
|
||||
self.store.set("api_key", "sk-live")
|
||||
# A copy in the fallback that a successful primary write did not clear
|
||||
# -- written by an older build, or left by a crash between the two
|
||||
@@ -529,5 +537,93 @@ class ProcessStartTime(unittest.TestCase):
|
||||
self.assertEqual(server._start_time(2 ** 22), 0)
|
||||
|
||||
|
||||
class StreamProtocolVocabulary(unittest.TestCase):
|
||||
"""The documented stream-protocol events must match the ones emitted.
|
||||
|
||||
A comment cannot fail CI, so the "stream protocol v2" block in server.py
|
||||
drifted: it listed nine events while the engine emitted eleven, and the two
|
||||
missing ones included approval_request -- the human-in-the-loop tool gate a
|
||||
client cannot render if it never hears the event. This test makes the
|
||||
comment a checked artifact: every emit(...) call site's name must appear in
|
||||
the documented list, and every documented name must be emitted, so either
|
||||
direction of drift fails here rather than silently on the wire.
|
||||
"""
|
||||
|
||||
SOURCE = pathlib.Path(server.__file__).resolve()
|
||||
|
||||
EXPECTED = {
|
||||
"start", "reasoning", "delta", "tool_drafting", "tool_call",
|
||||
"tool_result", "approval_request", "usage", "cancelled", "error", "end",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.text = cls.SOURCE.read_text(encoding="utf-8")
|
||||
|
||||
def _documented_events(self):
|
||||
"""The event names listed under the 'stream protocol v2' comment."""
|
||||
marker = "--- stream protocol v2"
|
||||
start = self.text.index(marker)
|
||||
block = self.text[start:self.text.index("\ndef ", start)]
|
||||
events = set()
|
||||
for line in block.splitlines():
|
||||
m = re.match(r"#\s+([a-z_]+)\s+\{", line)
|
||||
if m:
|
||||
events.add(m.group(1))
|
||||
return events
|
||||
|
||||
def _emitted_events(self):
|
||||
"""Every literal event name the engine can put on the wire.
|
||||
|
||||
Two dispatch forms reach the socket. Most call sites name the event
|
||||
inline -- emit("delta", ...) -- and the first pattern catches those.
|
||||
The stream loop also forwards a *variable*: emit(event, frame), where
|
||||
event comes from _classify_stream_item. A name added to that classifier
|
||||
would drift onto the wire invisibly to a guard that only reads literal
|
||||
emit() arguments, so the second pattern reads the classifier's returned
|
||||
events as well -- the only place the variable is assigned.
|
||||
"""
|
||||
literal = re.findall(r'(?:_emit_now|emit)\(\s*"([a-z_]+)"', self.text)
|
||||
classified = re.findall(
|
||||
r'return\s+"([a-z_]+)"\s*,', self._classifier_block())
|
||||
return set(literal) | set(classified)
|
||||
|
||||
def _classifier_block(self):
|
||||
"""The body of _classify_stream_item, source of the forwarded event."""
|
||||
start = self.text.index("def _classify_stream_item")
|
||||
return self.text[start:self.text.index("\ndef ", start + 1)]
|
||||
|
||||
def test_every_emitted_event_is_documented(self):
|
||||
documented = self._documented_events()
|
||||
undocumented = self._emitted_events() - documented
|
||||
self.assertEqual(
|
||||
undocumented, set(),
|
||||
f"these events are emitted but not documented in the "
|
||||
f"stream-protocol comment: {sorted(undocumented)}")
|
||||
|
||||
def test_every_documented_event_is_emitted(self):
|
||||
emitted = self._emitted_events()
|
||||
unemitted = self._documented_events() - emitted
|
||||
self.assertEqual(
|
||||
unemitted, set(),
|
||||
f"these events are documented but never emitted -- the comment is "
|
||||
f"stale: {sorted(unemitted)}")
|
||||
|
||||
def test_the_two_events_the_drift_hid_are_present(self):
|
||||
# The specific regression: approval_request and tool_drafting were live
|
||||
# on the wire and absent from the spec.
|
||||
documented = self._documented_events()
|
||||
for event in ("approval_request", "tool_drafting"):
|
||||
self.assertIn(event, documented,
|
||||
f"{event} is emitted but missing from the spec again")
|
||||
|
||||
def test_the_vocabulary_is_the_eleven_events_expected(self):
|
||||
# A belt-and-braces check so a matched pair of *both* sides drifting
|
||||
# (an event added to the comment and an emitter in the same PR, but
|
||||
# neither reviewed) is still measured against a fixed expectation.
|
||||
self.assertEqual(self._documented_events(), self.EXPECTED)
|
||||
self.assertEqual(self._emitted_events(), self.EXPECTED)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -32,7 +32,17 @@ def _wait(predicate, timeout=20.0):
|
||||
|
||||
|
||||
def _alive(pid):
|
||||
"""True while the pid exists, including as a zombie we have not reaped."""
|
||||
"""True while the pid exists, including as a zombie we have not reaped.
|
||||
|
||||
os.kill(pid, 0) is the POSIX liveness idiom, but on Windows Python maps
|
||||
every signal other than CTRL_C/CTRL_BREAK to TerminateProcess: signal 0
|
||||
would try to *kill* a live pid, and a dead one raises OSError(WinError 87)
|
||||
rather than ProcessLookupError -- so the probe both lies and, worse, is
|
||||
destructive. Windows therefore gets an OpenProcess-based check that only
|
||||
observes, mirroring how training.py already forks its termination path.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
return _alive_windows(pid)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
@@ -42,6 +52,35 @@ def _alive(pid):
|
||||
return True
|
||||
|
||||
|
||||
def _alive_windows(pid):
|
||||
"""Observe, never signal: open the process and read its exit code.
|
||||
|
||||
STILL_ACTIVE (259) means running. A pid that cannot be opened -- invalid
|
||||
(WinError 87) or already gone -- reads as dead. This is the non-destructive
|
||||
counterpart to os.kill(pid, 0), which on Windows would terminate the pid.
|
||||
"""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
STILL_ACTIVE = 259
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
# A HANDLE is pointer-sized; without restype ctypes truncates it to a C int
|
||||
# and the CloseHandle below would free the wrong value on 64-bit.
|
||||
kernel32.OpenProcess.restype = wintypes.HANDLE
|
||||
kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
||||
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
code = wintypes.DWORD()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
||||
return False
|
||||
return code.value == STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
def _script(body):
|
||||
"""Substitute the command, not the spawn.
|
||||
|
||||
@@ -604,8 +643,10 @@ class RunLifecycle(unittest.TestCase):
|
||||
self.assertTrue(_wait(lambda: run.state in training.TERMINAL, 15))
|
||||
self.assertEqual(run.state, training.CANCELLED)
|
||||
self.assertIsNotNone(run._proc.poll(), "the child was never reaped")
|
||||
with self.assertRaises(OSError):
|
||||
os.kill(pid, 0) # gone, not a zombie left behind
|
||||
# Gone, not a zombie left behind. os.kill(pid, 0) is the POSIX way to
|
||||
# ask, but on Windows signal 0 is not a query -- it terminates -- so the
|
||||
# liveness probe is factored into _alive, which observes on either OS.
|
||||
self.assertFalse(_alive(pid), "the child was left behind after reaping")
|
||||
|
||||
def test_stopping_also_kills_what_the_trainer_spawned(self):
|
||||
"""The orphan case, which is the one that actually bites.
|
||||
|
||||
@@ -171,3 +171,65 @@ test('the macOS window overlay repeats the base window faithfully', () => {
|
||||
assert.equal(mac.titleBarStyle, 'Overlay');
|
||||
assert.equal(mac.hiddenTitle, true);
|
||||
});
|
||||
|
||||
test('the macOS bundle is configured to be signed', () => {
|
||||
// Without an identity Tauri never runs codesign on the bundle, and the only
|
||||
// signature left is the one rustc's linker applies to the bare executable.
|
||||
// That signature's CodeDirectory declares a resource seal the unsigned
|
||||
// bundle does not have, so macOS calls the app "damaged and can't be
|
||||
// opened" -- not "unidentified developer" -- and right-click -> Open cannot
|
||||
// bypass it. v4.7.2 shipped exactly that.
|
||||
//
|
||||
// "-" is the ad-hoc identity. It is not a Developer ID and does not make the
|
||||
// app trusted; it makes the signature *valid*, which is the difference
|
||||
// between "damaged" and a warning the user can click past.
|
||||
const mac = config.bundle.macOS;
|
||||
assert.ok(mac, 'no macOS bundle config at all');
|
||||
// Pinned to "-", not merely truthy: any other value is either a Developer ID
|
||||
// we do not have (the build has no keychain to satisfy it, so codesign
|
||||
// fails) or a typo Tauri passes straight to codesign. Only "-" is the ad-hoc
|
||||
// identity that turns the shipped "damaged" bundle into a valid signature.
|
||||
assert.equal(mac.signingIdentity, '-',
|
||||
'the macOS bundle must use the ad-hoc signing identity "-"');
|
||||
});
|
||||
|
||||
test('the release build verifies the signature before uploading', () => {
|
||||
// The config alone is not enough -- if Tauri ever stops honouring it, the
|
||||
// build must fail rather than attach another unopenable DMG.
|
||||
//
|
||||
// Comments are stripped first. The first version of this matched the phrase
|
||||
// "codesign --verify" inside the comment that explains the gate, so deleting
|
||||
// the actual command changed nothing and the test still passed.
|
||||
const runnable = workflow
|
||||
.split('\n')
|
||||
.filter((line) => !/^\s*#/.test(line))
|
||||
.join('\n');
|
||||
assert.match(runnable, /codesign\s+--verify\s+--deep\s+--strict/,
|
||||
'nothing checks the signature before the bundle is attached');
|
||||
assert.ok(runnable.indexOf('codesign --verify') < runnable.indexOf('gh release upload'),
|
||||
'the signature is verified after upload, which is too late');
|
||||
|
||||
// The gate is worthless if it never runs. Its step is the only place the
|
||||
// codesign command lives, so pin that step to macOS: flipping the `if` to
|
||||
// skip macOS -- or dropping the condition so it runs on Linux where codesign
|
||||
// does not exist and the step is silently skipped -- would otherwise still
|
||||
// pass every assertion above.
|
||||
const stepStart = runnable.lastIndexOf('- name:',
|
||||
runnable.indexOf('codesign --verify'));
|
||||
const nextStep = runnable.indexOf('\n - name:', stepStart + 1);
|
||||
const gate = runnable.slice(stepStart, nextStep === -1 ? undefined : nextStep);
|
||||
assert.match(gate, /if:\s*runner\.os\s*==\s*'macOS'/,
|
||||
'the signature-verification step is not scoped to macOS runners');
|
||||
});
|
||||
|
||||
test('the macOS overlay does not drop the bundle configuration', () => {
|
||||
// Tauri merges the overlay with RFC 7386, which replaces rather than merges.
|
||||
// An overlay that grew a "bundle" key would silently discard the signing
|
||||
// identity along with everything else under it.
|
||||
const overlay = JSON.parse(
|
||||
readFileSync(new URL('src/praisonai-desktop/src-tauri/tauri.macos.conf.json', root), 'utf8'));
|
||||
if (overlay.bundle) {
|
||||
assert.ok(overlay.bundle.macOS?.signingIdentity,
|
||||
'the overlay defines bundle but omits signingIdentity, which replaces it away');
|
||||
}
|
||||
});
|
||||
@@ -43,7 +43,8 @@
|
||||
"shortDescription": "Local AI agents on your desktop",
|
||||
"longDescription": "PraisonAI runs AI agents locally. Chat with a model, give it tools and files, and fine-tune your own.",
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15"
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": "-"
|
||||
},
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-08-25",
|
||||
"lastUpdated": "2026-08-26",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"status": "PARITY_ACHIEVED",
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
> **Python Features:** 411 | **Rust Features:** 667 | **Parity:** 69.8%
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **What this measures:** whether a matching *exported symbol name* exists in the
|
||||
> Rust crate's public surface. It does **not** verify that the capability is
|
||||
> reachable or behaves like its Python counterpart — an exported item whose body
|
||||
> returns `Err("not yet implemented")` still counts here. Treat `✅` as "a symbol
|
||||
> of this name is exported", not "this works". For a capability with a testable
|
||||
> contract, rely on its conformance suite rather than this table.
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|
||||
@@ -55,6 +55,22 @@ Simpler than competitors • More extensible • Faster • Agent-centric
|
||||
/Users/praison/PraisonAIDocs/ # Documentation (Mintlify)
|
||||
```
|
||||
|
||||
### 2.1.1 Issue routing and npm mirror
|
||||
|
||||
| Step | Location |
|
||||
|------|----------|
|
||||
| **Report issues** | [MervinPraison/PraisonAI](https://github.com/MervinPraison/PraisonAI) — use the bug/feature templates |
|
||||
| **Implement fixes** | **`src/praisonai-ts/`** in this monorepo (this directory) |
|
||||
| **npm mirror** | [MervinPraison/praisonai-js](https://github.com/MervinPraison/praisonai-js) — updated from the monorepo, not the fix target |
|
||||
|
||||
After a TypeScript PR merges on PraisonAI, maintainers sync the npm mirror:
|
||||
|
||||
```bash
|
||||
gh workflow run "Sync to praisonai-js" --repo MervinPraison/PraisonAI
|
||||
```
|
||||
|
||||
Direction is **monorepo → praisonai-js** only. Do not implement TS fixes in praisonai-js for PraisonAI issues.
|
||||
|
||||
### 2.2 TypeScript SDK Source Structure
|
||||
|
||||
```
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-08-25",
|
||||
"lastUpdated": "2026-08-26",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 411,
|
||||
"pythonWrapperFeatures": 21,
|
||||
"typescriptFeatures": 1190,
|
||||
"gapCount": 117,
|
||||
"typescriptFeatures": 1193,
|
||||
"gapCount": 116,
|
||||
"priorityP0": 2,
|
||||
"priorityP1": 5,
|
||||
"priorityP2": 6,
|
||||
"priorityP3": 104
|
||||
"priorityP3": 103
|
||||
},
|
||||
"pythonCoreSDK": {
|
||||
"path": "/home/runner/work/PraisonAI/PraisonAI/src/praisonai-agents/praisonaiagents",
|
||||
@@ -1696,6 +1696,8 @@
|
||||
"modules": {
|
||||
"agent": [
|
||||
"Agent",
|
||||
"AgentEvent",
|
||||
"AgentStreamOptions",
|
||||
"AgentTeam",
|
||||
"AgentTeamConfig",
|
||||
"Agents",
|
||||
@@ -1748,6 +1750,7 @@
|
||||
"SimpleAgentConfig",
|
||||
"SimpleRouteConfig",
|
||||
"SimpleRouterConfig",
|
||||
"StopReason",
|
||||
"createAudioAgent",
|
||||
"createContextAgent",
|
||||
"createDeepResearchAgent",
|
||||
@@ -5685,10 +5688,10 @@
|
||||
{
|
||||
"feature": "StopReason",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"typescript": true,
|
||||
"priority": "P3",
|
||||
"effort": "high",
|
||||
"status": "TODO",
|
||||
"status": "DONE",
|
||||
"category": "other"
|
||||
},
|
||||
{
|
||||
|
||||
+431
-421
@@ -1,453 +1,463 @@
|
||||
# Feature Parity Tracker
|
||||
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-08-25
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-08-26
|
||||
> **Source of Truth:** Python SDK (praisonaiagents)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **What this measures:** whether a matching *exported symbol name* exists in
|
||||
> the TypeScript SDK's public surface (parsed from `src/index.ts`). It does **not**
|
||||
> verify that the capability is reachable, wired up, or behaves like its Python
|
||||
> counterpart. A `✅ exported` cell means the name is exported — not that it works.
|
||||
> Counts include barrel re-exports, so `TypeScript Features` reflects module
|
||||
> structure, not distinct capabilities, and is not directly comparable to the
|
||||
> Python count. For a capability with a testable contract, rely on its conformance
|
||||
> suite rather than this table.
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 411 |
|
||||
| Python Wrapper Features | 21 |
|
||||
| TypeScript Features | 1190 |
|
||||
| **Gap Count** | **117** |
|
||||
| TypeScript Features | 1193 |
|
||||
| **Gap Count** | **116** |
|
||||
| P0 (Critical) | 2 |
|
||||
| P1 (High) | 5 |
|
||||
| P2 (Medium) | 6 |
|
||||
| P3 (Low) | 104 |
|
||||
| P3 (Low) | 103 |
|
||||
|
||||
## Gap Matrix
|
||||
|
||||
### P0_CoreParity (52 done, 2 todo)
|
||||
### P0_CoreParity (52 exported, 2 missing)
|
||||
|
||||
| Feature | Python | TypeScript | Effort | Status |
|
||||
|---------|--------|------------|--------|--------|
|
||||
| `HandoffToolPolicy` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `parallel\_handoffs` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `Agent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `AudioAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `AudioConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `BaseTool` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `CodeAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `CodeConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `CodeExecutionStep` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ContextAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ContextPolicy` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `DeepResearchAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `DeepResearchResponse` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `EmbeddingAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `EmbeddingConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ExpandResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ExpandStrategy` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `FileSearchCall` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `FunctionTool` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Handoff` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `HandoffConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HandoffInputData` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `HandoffResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ImageAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `MCPCall` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `OCRAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `OCRConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `PromptExpanderAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Provider` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `QueryRewriterAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `RECOMMENDED\_PROMPT\_PREFIX` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `RealtimeAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `RealtimeConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ReasoningStep` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `RewriteResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `RewriteStrategy` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ToolRegistry` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ToolResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ToolValidationError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `Tools` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `VideoAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `VideoConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `VisionAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `VisionConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `WebSearchCall` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `create\_context\_agent` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_registry` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_tool` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `handoff` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `handoff\_filters` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `prompt\_with\_handoff\_instructions` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `register\_tool` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `tool` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `validate\_tool` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HandoffToolPolicy` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `parallel\_handoffs` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `Agent` | ✅ | ✅ | high | ✅ exported |
|
||||
| `AudioAgent` | ✅ | ✅ | high | ✅ exported |
|
||||
| `AudioConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `BaseTool` | ✅ | ✅ | high | ✅ exported |
|
||||
| `CodeAgent` | ✅ | ✅ | high | ✅ exported |
|
||||
| `CodeConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `CodeExecutionStep` | ✅ | ✅ | high | ✅ exported |
|
||||
| `ContextAgent` | ✅ | ✅ | high | ✅ exported |
|
||||
| `ContextPolicy` | ✅ | ✅ | high | ✅ exported |
|
||||
| `DeepResearchAgent` | ✅ | ✅ | high | ✅ exported |
|
||||
| `DeepResearchResponse` | ✅ | ✅ | high | ✅ exported |
|
||||
| `EmbeddingAgent` | ✅ | ✅ | high | ✅ exported |
|
||||
| `EmbeddingConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `ExpandResult` | ✅ | ✅ | low | ✅ exported |
|
||||
| `ExpandStrategy` | ✅ | ✅ | high | ✅ exported |
|
||||
| `FileSearchCall` | ✅ | ✅ | high | ✅ exported |
|
||||
| `FunctionTool` | ✅ | ✅ | high | ✅ exported |
|
||||
| `Handoff` | ✅ | ✅ | high | ✅ exported |
|
||||
| `HandoffConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `HandoffInputData` | ✅ | ✅ | high | ✅ exported |
|
||||
| `HandoffResult` | ✅ | ✅ | low | ✅ exported |
|
||||
| `ImageAgent` | ✅ | ✅ | high | ✅ exported |
|
||||
| `MCPCall` | ✅ | ✅ | high | ✅ exported |
|
||||
| `OCRAgent` | ✅ | ✅ | high | ✅ exported |
|
||||
| `OCRConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `PromptExpanderAgent` | ✅ | ✅ | high | ✅ exported |
|
||||
| `Provider` | ✅ | ✅ | high | ✅ exported |
|
||||
| `QueryRewriterAgent` | ✅ | ✅ | high | ✅ exported |
|
||||
| `RECOMMENDED\_PROMPT\_PREFIX` | ✅ | ✅ | low | ✅ exported |
|
||||
| `RealtimeAgent` | ✅ | ✅ | high | ✅ exported |
|
||||
| `RealtimeConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `ReasoningStep` | ✅ | ✅ | high | ✅ exported |
|
||||
| `RewriteResult` | ✅ | ✅ | low | ✅ exported |
|
||||
| `RewriteStrategy` | ✅ | ✅ | high | ✅ exported |
|
||||
| `ToolRegistry` | ✅ | ✅ | high | ✅ exported |
|
||||
| `ToolResult` | ✅ | ✅ | low | ✅ exported |
|
||||
| `ToolValidationError` | ✅ | ✅ | low | ✅ exported |
|
||||
| `Tools` | ✅ | ✅ | high | ✅ exported |
|
||||
| `VideoAgent` | ✅ | ✅ | high | ✅ exported |
|
||||
| `VideoConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `VisionAgent` | ✅ | ✅ | high | ✅ exported |
|
||||
| `VisionConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `WebSearchCall` | ✅ | ✅ | high | ✅ exported |
|
||||
| `create\_context\_agent` | ✅ | ✅ | low | ✅ exported |
|
||||
| `get\_registry` | ✅ | ✅ | low | ✅ exported |
|
||||
| `get\_tool` | ✅ | ✅ | low | ✅ exported |
|
||||
| `handoff` | ✅ | ✅ | low | ✅ exported |
|
||||
| `handoff\_filters` | ✅ | ✅ | low | ✅ exported |
|
||||
| `prompt\_with\_handoff\_instructions` | ✅ | ✅ | low | ✅ exported |
|
||||
| `register\_tool` | ✅ | ✅ | low | ✅ exported |
|
||||
| `tool` | ✅ | ✅ | low | ✅ exported |
|
||||
| `validate\_tool` | ✅ | ✅ | low | ✅ exported |
|
||||
|
||||
### P1_Persistence (20 done, 5 todo)
|
||||
### P1_Persistence (20 exported, 5 missing)
|
||||
|
||||
| Feature | Python | TypeScript | Effort | Status |
|
||||
|---------|--------|------------|--------|--------|
|
||||
| `Include` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `MAX\_NESTING\_DEPTH` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `YAMLWorkflowParser` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `if\_` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `include` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `AgentFlow` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Chunking` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `If` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Knowledge` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Loop` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Memory` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Parallel` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Pipeline` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Repeat` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Route` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Session` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `StepResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `Workflow` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `WorkflowContext` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `WorkflowHooksConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `loop` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `parallel` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `repeat` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `route` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `when` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `Include` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `MAX\_NESTING\_DEPTH` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `YAMLWorkflowParser` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `if\_` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `include` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `AgentFlow` | ✅ | ✅ | high | ✅ exported |
|
||||
| `Chunking` | ✅ | ✅ | high | ✅ exported |
|
||||
| `If` | ✅ | ✅ | high | ✅ exported |
|
||||
| `Knowledge` | ✅ | ✅ | high | ✅ exported |
|
||||
| `Loop` | ✅ | ✅ | high | ✅ exported |
|
||||
| `Memory` | ✅ | ✅ | high | ✅ exported |
|
||||
| `Parallel` | ✅ | ✅ | high | ✅ exported |
|
||||
| `Pipeline` | ✅ | ✅ | high | ✅ exported |
|
||||
| `Repeat` | ✅ | ✅ | high | ✅ exported |
|
||||
| `Route` | ✅ | ✅ | high | ✅ exported |
|
||||
| `Session` | ✅ | ✅ | high | ✅ exported |
|
||||
| `StepResult` | ✅ | ✅ | low | ✅ exported |
|
||||
| `Workflow` | ✅ | ✅ | high | ✅ exported |
|
||||
| `WorkflowContext` | ✅ | ✅ | high | ✅ exported |
|
||||
| `WorkflowHooksConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `loop` | ✅ | ✅ | low | ✅ exported |
|
||||
| `parallel` | ✅ | ✅ | low | ✅ exported |
|
||||
| `repeat` | ✅ | ✅ | low | ✅ exported |
|
||||
| `route` | ✅ | ✅ | low | ✅ exported |
|
||||
| `when` | ✅ | ✅ | low | ✅ exported |
|
||||
|
||||
### P2_CLI (41 done, 6 todo)
|
||||
### P2_CLI (41 exported, 6 missing)
|
||||
|
||||
| Feature | Python | TypeScript | Effort | Status |
|
||||
|---------|--------|------------|--------|--------|
|
||||
| `EnforcementLevel` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `SkillState` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `discover\_skills` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `load\_skill` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `validate` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `validate\_metadata` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `ApprovalCallback` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Citation` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `CitationsMode` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ContextConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ContextManager` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ContextPack` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `FastContext` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `FastContextResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `FileMatch` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `GuardrailResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `LLMGuardrail` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `LineRange` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `MCP` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ManagerConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `MinimalTelemetry` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `OptimizerStrategy` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Plan` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `PlanStep` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `PlanStorage` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `PlanningAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `RAG` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `RAGCitation` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `RAGConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `RAGResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `READ\_ONLY\_TOOLS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `RESTRICTED\_TOOLS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `RetrievalConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `RetrievalPolicy` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `SkillLoader` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `SkillManager` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `SkillMetadata` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `SkillProperties` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `TelemetryCollector` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `TodoItem` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `TodoList` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `cleanup\_telemetry\_resources` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `disable\_performance\_mode` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `disable\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `enable\_performance\_mode` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `enable\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `EnforcementLevel` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `SkillState` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `discover\_skills` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `load\_skill` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `validate` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `validate\_metadata` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `ApprovalCallback` | ✅ | ✅ | high | ✅ exported |
|
||||
| `Citation` | ✅ | ✅ | high | ✅ exported |
|
||||
| `CitationsMode` | ✅ | ✅ | high | ✅ exported |
|
||||
| `ContextConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `ContextManager` | ✅ | ✅ | high | ✅ exported |
|
||||
| `ContextPack` | ✅ | ✅ | high | ✅ exported |
|
||||
| `FastContext` | ✅ | ✅ | high | ✅ exported |
|
||||
| `FastContextResult` | ✅ | ✅ | low | ✅ exported |
|
||||
| `FileMatch` | ✅ | ✅ | high | ✅ exported |
|
||||
| `GuardrailResult` | ✅ | ✅ | low | ✅ exported |
|
||||
| `LLMGuardrail` | ✅ | ✅ | high | ✅ exported |
|
||||
| `LineRange` | ✅ | ✅ | high | ✅ exported |
|
||||
| `MCP` | ✅ | ✅ | low | ✅ exported |
|
||||
| `ManagerConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `MinimalTelemetry` | ✅ | ✅ | high | ✅ exported |
|
||||
| `OptimizerStrategy` | ✅ | ✅ | high | ✅ exported |
|
||||
| `Plan` | ✅ | ✅ | high | ✅ exported |
|
||||
| `PlanStep` | ✅ | ✅ | high | ✅ exported |
|
||||
| `PlanStorage` | ✅ | ✅ | high | ✅ exported |
|
||||
| `PlanningAgent` | ✅ | ✅ | high | ✅ exported |
|
||||
| `RAG` | ✅ | ✅ | low | ✅ exported |
|
||||
| `RAGCitation` | ✅ | ✅ | high | ✅ exported |
|
||||
| `RAGConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `RAGResult` | ✅ | ✅ | low | ✅ exported |
|
||||
| `READ\_ONLY\_TOOLS` | ✅ | ✅ | low | ✅ exported |
|
||||
| `RESTRICTED\_TOOLS` | ✅ | ✅ | low | ✅ exported |
|
||||
| `RetrievalConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `RetrievalPolicy` | ✅ | ✅ | high | ✅ exported |
|
||||
| `SkillLoader` | ✅ | ✅ | high | ✅ exported |
|
||||
| `SkillManager` | ✅ | ✅ | high | ✅ exported |
|
||||
| `SkillMetadata` | ✅ | ✅ | high | ✅ exported |
|
||||
| `SkillProperties` | ✅ | ✅ | high | ✅ exported |
|
||||
| `TelemetryCollector` | ✅ | ✅ | high | ✅ exported |
|
||||
| `TodoItem` | ✅ | ✅ | high | ✅ exported |
|
||||
| `TodoList` | ✅ | ✅ | high | ✅ exported |
|
||||
| `cleanup\_telemetry\_resources` | ✅ | ✅ | low | ✅ exported |
|
||||
| `disable\_performance\_mode` | ✅ | ✅ | low | ✅ exported |
|
||||
| `disable\_telemetry` | ✅ | ✅ | low | ✅ exported |
|
||||
| `enable\_performance\_mode` | ✅ | ✅ | low | ✅ exported |
|
||||
| `enable\_telemetry` | ✅ | ✅ | low | ✅ exported |
|
||||
| `get\_telemetry` | ✅ | ✅ | low | ✅ exported |
|
||||
|
||||
### P3_Advanced (181 done, 104 todo)
|
||||
### P3_Advanced (182 exported, 103 missing)
|
||||
|
||||
| Feature | Python | TypeScript | Effort | Status |
|
||||
|---------|--------|------------|--------|--------|
|
||||
| `A2UI` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `AGGRESSIVE\_POLICY` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `AgentMessageEvent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `AgentRunOutcome` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `AgentRuntimeProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `AsyncLearnProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `AutoApproveBackend` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `BALANCED\_POLICY` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `BackendNotAvailableError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `BaseFrameworkAdapter` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `BasePlatformAdapter` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `BotOSConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `BotOSProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `CONSERVATIVE\_POLICY` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `ChromaMemory` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `CliBackendConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `CliBackendDelta` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `CliBackendProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `CliBackendResult` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `CliSessionBinding` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `CompactionRoute` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `CompactionStrategy` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ConsoleBackend` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ContextBudgetResult` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `ContextCompactionPolicy` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ContextCompactionPolicyProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `CorpusStats` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `CustomToolUseEvent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `DoomLoopDetector` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ErrorContextProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `EscalationPipeline` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `EscalationStage` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `FileTracker` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `FrameworkAdapterProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `GatewayEventType` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `Goal` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `GoalConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `GoalEngineer` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `GoalVerificationResult` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `HarnessProfile` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `Heartbeat` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `HeartbeatConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `IndexResult` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `KnowledgeStoreProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `LLMError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `LearnBackend` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `LearnManager` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `LearnManagerProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `LearnMode` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `LearnProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `LearnScope` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ManagedBackendProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `ManagedEvent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `NetworkError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `ObservabilityEventType` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ObservabilityHooks` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `PlatformCapabilities` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `PraisonAIConfigError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `PraisonAIError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `PreCompactionMemoryFlushConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `RetryBackoffConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `RulesConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `RunOutcome` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `RunStatus` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ScopeRequiredError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `SendResult` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `SessionErrorEvent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `SessionIdleEvent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `StopReason` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `StructuredFormatter` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `SuccessCriterion` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `TerminationReason` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ToolExecutionError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `ToolSearchConfig` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `ToolUseEvent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ToolsetRegistry` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ToolsetSpec` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `ValidationError` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `\_\_version\_\_` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `add\_memory\_adapter` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `add\_memory\_factory` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `configure\_structured\_logging` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `get\_default\_policy` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `get\_logger` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `get\_memory\_adapter` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `get\_toolset` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `get\_toolset\_registry` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `has\_memory\_adapter` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `has\_toolset` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `list\_memory\_adapters` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `list\_runtimes` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `list\_toolsets` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `register\_memory\_adapter` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `register\_memory\_factory` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `register\_profile` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `register\_runtime` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `register\_toolset` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `resolve\_harness` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `resolve\_runtime` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `resolve\_toolset` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `resolve\_toolsets` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `termination\_to\_run\_status` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `unregister\_toolset` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `validate\_decision\_string` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `A2A` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `AGUI` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `AUTONOMY\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `AgentAppConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `AgentAppProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `AgentManager` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `AgentOSConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `AgentOSProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `AgentPluginProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `AgentTeam` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `Agents` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ArrayMode` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `AuthProfile` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `AutoAgents` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `AutoMemory` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `AutoRagAgent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `AutoRagConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `AutonomyConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `AutonomyLevel` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `BotChannel` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `BotConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `BotMessage` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `BotProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `BotUser` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `BudgetExceededError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `CACHING\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `CONTEXT\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `CachingConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ChunkingStrategy` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ConditionProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `ConfigValidationError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ContextEvent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ContextEventType` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ContextListSink` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ContextNoOpSink` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ContextTraceEmitter` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ContextTraceSink` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ContextTraceSinkProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `DefaultsConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `DictCondition` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `EXECUTION\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `EmbeddingResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `EventType` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ExecutionConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ExecutionPreset` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ExpressionCondition` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `FailoverConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `FailoverManager` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `FlowDisplay` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `FunctionPlugin` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `GUARDRAIL\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `GatewayClientProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `GatewayConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `GatewayEvent` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `GatewayMessage` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `GatewayProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `GatewaySessionProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `GuardrailAction` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `GuardrailConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HandoffCycleError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HandoffDepthError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HandoffError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HandoffTimeoutError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `HookPluginProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `HooksConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `KNOWLEDGE\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `KnowledgeConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `LLMPluginProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `LearnConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `MEMORY\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `MEMORY\_URL\_SCHEMES` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `MULTI\_AGENT\_EXECUTION\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `MULTI\_AGENT\_OUTPUT\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `MemoryBackend` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `MemoryConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `MessageType` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `MultiAgentExecutionConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `MultiAgentHooksConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `MultiAgentMemoryConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `MultiAgentOutputConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `MultiAgentPlanningConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `OUTPUT\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `OutputConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `OutputPreset` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `PLANNING\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `PlanningConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `Plugin` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `PluginHook` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `PluginInfo` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `PluginManager` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `PluginMetadata` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `PluginParseError` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `PluginProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `PluginsConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `PraisonAIAgents` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `PraisonConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ProviderStatus` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `REFLECTION\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `RagRetrievalPolicy` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ReflectionConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ReflectionOutput` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `ResourceLimits` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `RoutingConditionProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `SandboxConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `SandboxProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `SandboxResult` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `SandboxStatus` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `SecurityPolicy` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `SessionConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `SkillsConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `Task` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `TaskOutput` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `TemplateConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ToolPluginProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `TraceSink` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `TraceSinkProtocol` | ✅ | ✅ | medium | ✅ DONE |
|
||||
| `WEB\_PRESETS` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `WebConfig` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `WebSearchProvider` | ✅ | ✅ | high | ✅ DONE |
|
||||
| `aembed` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `aembedding` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `aembeddings` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `apply\_config\_defaults` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `async\_display\_callbacks` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `clean\_triple\_backticks` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `config` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `detect\_url\_scheme` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `discover\_and\_load\_plugins` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `discover\_plugins` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `display\_error` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `display\_generating` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `display\_instruction` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `display\_interaction` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `display\_self\_reflection` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `display\_tool\_call` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `embed` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `embedding` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `embeddings` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `ensure\_plugin\_dir` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `error\_logs` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `evaluate\_condition` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_config` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_config\_path` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_default` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_default\_plugin\_dirs` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_defaults\_config` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_dimensions` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_plugin\_manager` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_plugin\_template` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_plugins\_config` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `is\_path\_like` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `is\_policy\_string` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `load\_plugin` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `memory` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `parse\_plugin\_header` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `parse\_plugin\_header\_from\_file` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `parse\_policy\_string` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `register\_display\_callback` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_autonomy` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_caching` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_context` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_execution` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_guardrail\_policies` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_guardrails` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_hooks` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_knowledge` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_memory` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_output` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_planning` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_reflection` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_routing` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_skills` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `resolve\_web` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `suggest\_similar` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `sync\_display\_callbacks` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `tools` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `trace\_context` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `track\_workflow` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `validate\_config` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `workflows` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `A2UI` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `AGGRESSIVE\_POLICY` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `AgentMessageEvent` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `AgentRunOutcome` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `AgentRuntimeProtocol` | ✅ | ❌ | medium | ⏳ missing |
|
||||
| `AsyncLearnProtocol` | ✅ | ❌ | medium | ⏳ missing |
|
||||
| `AutoApproveBackend` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `BALANCED\_POLICY` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `BackendNotAvailableError` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `BaseFrameworkAdapter` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `BasePlatformAdapter` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `BotOSConfig` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `BotOSProtocol` | ✅ | ❌ | medium | ⏳ missing |
|
||||
| `CONSERVATIVE\_POLICY` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `ChromaMemory` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `CliBackendConfig` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `CliBackendDelta` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `CliBackendProtocol` | ✅ | ❌ | medium | ⏳ missing |
|
||||
| `CliBackendResult` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `CliSessionBinding` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `CompactionRoute` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `CompactionStrategy` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `ConsoleBackend` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `ContextBudgetResult` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `ContextCompactionPolicy` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `ContextCompactionPolicyProtocol` | ✅ | ❌ | medium | ⏳ missing |
|
||||
| `CorpusStats` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `CustomToolUseEvent` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `DoomLoopDetector` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `ErrorContextProtocol` | ✅ | ❌ | medium | ⏳ missing |
|
||||
| `EscalationPipeline` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `EscalationStage` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `FileTracker` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `FrameworkAdapterProtocol` | ✅ | ❌ | medium | ⏳ missing |
|
||||
| `GatewayEventType` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `Goal` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `GoalConfig` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `GoalEngineer` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `GoalVerificationResult` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `HarnessProfile` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `Heartbeat` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `HeartbeatConfig` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `IndexResult` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `KnowledgeStoreProtocol` | ✅ | ❌ | medium | ⏳ missing |
|
||||
| `LLMError` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `LearnBackend` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `LearnManager` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `LearnManagerProtocol` | ✅ | ❌ | medium | ⏳ missing |
|
||||
| `LearnMode` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `LearnProtocol` | ✅ | ❌ | medium | ⏳ missing |
|
||||
| `LearnScope` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `ManagedBackendProtocol` | ✅ | ❌ | medium | ⏳ missing |
|
||||
| `ManagedEvent` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `NetworkError` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `ObservabilityEventType` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `ObservabilityHooks` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `PlatformCapabilities` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `PraisonAIConfigError` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `PraisonAIError` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `PreCompactionMemoryFlushConfig` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `RetryBackoffConfig` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `RulesConfig` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `RunOutcome` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `RunStatus` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `ScopeRequiredError` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `SendResult` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `SessionErrorEvent` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `SessionIdleEvent` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `StructuredFormatter` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `SuccessCriterion` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `TerminationReason` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `ToolExecutionError` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `ToolSearchConfig` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `ToolUseEvent` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `ToolsetRegistry` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `ToolsetSpec` | ✅ | ❌ | high | ⏳ missing |
|
||||
| `ValidationError` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `\_\_version\_\_` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `add\_memory\_adapter` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `add\_memory\_factory` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `configure\_structured\_logging` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `get\_default\_policy` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `get\_logger` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `get\_memory\_adapter` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `get\_toolset` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `get\_toolset\_registry` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `has\_memory\_adapter` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `has\_toolset` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `list\_memory\_adapters` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `list\_runtimes` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `list\_toolsets` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `register\_memory\_adapter` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `register\_memory\_factory` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `register\_profile` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `register\_runtime` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `register\_toolset` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `resolve\_harness` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `resolve\_runtime` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `resolve\_toolset` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `resolve\_toolsets` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `termination\_to\_run\_status` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `unregister\_toolset` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `validate\_decision\_string` | ✅ | ❌ | low | ⏳ missing |
|
||||
| `A2A` | ✅ | ✅ | low | ✅ exported |
|
||||
| `AGUI` | ✅ | ✅ | low | ✅ exported |
|
||||
| `AUTONOMY\_PRESETS` | ✅ | ✅ | low | ✅ exported |
|
||||
| `AgentAppConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `AgentAppProtocol` | ✅ | ✅ | medium | ✅ exported |
|
||||
| `AgentManager` | ✅ | ✅ | high | ✅ exported |
|
||||
| `AgentOSConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `AgentOSProtocol` | ✅ | ✅ | medium | ✅ exported |
|
||||
| `AgentPluginProtocol` | ✅ | ✅ | medium | ✅ exported |
|
||||
| `AgentTeam` | ✅ | ✅ | high | ✅ exported |
|
||||
| `Agents` | ✅ | ✅ | high | ✅ exported |
|
||||
| `ArrayMode` | ✅ | ✅ | high | ✅ exported |
|
||||
| `AuthProfile` | ✅ | ✅ | high | ✅ exported |
|
||||
| `AutoAgents` | ✅ | ✅ | high | ✅ exported |
|
||||
| `AutoMemory` | ✅ | ✅ | high | ✅ exported |
|
||||
| `AutoRagAgent` | ✅ | ✅ | high | ✅ exported |
|
||||
| `AutoRagConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `AutonomyConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `AutonomyLevel` | ✅ | ✅ | high | ✅ exported |
|
||||
| `BotChannel` | ✅ | ✅ | high | ✅ exported |
|
||||
| `BotConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `BotMessage` | ✅ | ✅ | high | ✅ exported |
|
||||
| `BotProtocol` | ✅ | ✅ | medium | ✅ exported |
|
||||
| `BotUser` | ✅ | ✅ | high | ✅ exported |
|
||||
| `BudgetExceededError` | ✅ | ✅ | low | ✅ exported |
|
||||
| `CACHING\_PRESETS` | ✅ | ✅ | low | ✅ exported |
|
||||
| `CONTEXT\_PRESETS` | ✅ | ✅ | low | ✅ exported |
|
||||
| `CachingConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `ChunkingStrategy` | ✅ | ✅ | high | ✅ exported |
|
||||
| `ConditionProtocol` | ✅ | ✅ | medium | ✅ exported |
|
||||
| `ConfigValidationError` | ✅ | ✅ | low | ✅ exported |
|
||||
| `ContextEvent` | ✅ | ✅ | high | ✅ exported |
|
||||
| `ContextEventType` | ✅ | ✅ | high | ✅ exported |
|
||||
| `ContextListSink` | ✅ | ✅ | high | ✅ exported |
|
||||
| `ContextNoOpSink` | ✅ | ✅ | high | ✅ exported |
|
||||
| `ContextTraceEmitter` | ✅ | ✅ | high | ✅ exported |
|
||||
| `ContextTraceSink` | ✅ | ✅ | high | ✅ exported |
|
||||
| `ContextTraceSinkProtocol` | ✅ | ✅ | medium | ✅ exported |
|
||||
| `DefaultsConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `DictCondition` | ✅ | ✅ | high | ✅ exported |
|
||||
| `EXECUTION\_PRESETS` | ✅ | ✅ | low | ✅ exported |
|
||||
| `EmbeddingResult` | ✅ | ✅ | low | ✅ exported |
|
||||
| `EventType` | ✅ | ✅ | high | ✅ exported |
|
||||
| `ExecutionConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `ExecutionPreset` | ✅ | ✅ | high | ✅ exported |
|
||||
| `ExpressionCondition` | ✅ | ✅ | high | ✅ exported |
|
||||
| `FailoverConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `FailoverManager` | ✅ | ✅ | high | ✅ exported |
|
||||
| `FlowDisplay` | ✅ | ✅ | high | ✅ exported |
|
||||
| `FunctionPlugin` | ✅ | ✅ | high | ✅ exported |
|
||||
| `GUARDRAIL\_PRESETS` | ✅ | ✅ | low | ✅ exported |
|
||||
| `GatewayClientProtocol` | ✅ | ✅ | medium | ✅ exported |
|
||||
| `GatewayConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `GatewayEvent` | ✅ | ✅ | high | ✅ exported |
|
||||
| `GatewayMessage` | ✅ | ✅ | high | ✅ exported |
|
||||
| `GatewayProtocol` | ✅ | ✅ | medium | ✅ exported |
|
||||
| `GatewaySessionProtocol` | ✅ | ✅ | medium | ✅ exported |
|
||||
| `GuardrailAction` | ✅ | ✅ | high | ✅ exported |
|
||||
| `GuardrailConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `HandoffCycleError` | ✅ | ✅ | low | ✅ exported |
|
||||
| `HandoffDepthError` | ✅ | ✅ | low | ✅ exported |
|
||||
| `HandoffError` | ✅ | ✅ | low | ✅ exported |
|
||||
| `HandoffTimeoutError` | ✅ | ✅ | low | ✅ exported |
|
||||
| `HookPluginProtocol` | ✅ | ✅ | medium | ✅ exported |
|
||||
| `HooksConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `KNOWLEDGE\_PRESETS` | ✅ | ✅ | low | ✅ exported |
|
||||
| `KnowledgeConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `LLMPluginProtocol` | ✅ | ✅ | medium | ✅ exported |
|
||||
| `LearnConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `MEMORY\_PRESETS` | ✅ | ✅ | low | ✅ exported |
|
||||
| `MEMORY\_URL\_SCHEMES` | ✅ | ✅ | low | ✅ exported |
|
||||
| `MULTI\_AGENT\_EXECUTION\_PRESETS` | ✅ | ✅ | low | ✅ exported |
|
||||
| `MULTI\_AGENT\_OUTPUT\_PRESETS` | ✅ | ✅ | low | ✅ exported |
|
||||
| `MemoryBackend` | ✅ | ✅ | high | ✅ exported |
|
||||
| `MemoryConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `MessageType` | ✅ | ✅ | high | ✅ exported |
|
||||
| `MultiAgentExecutionConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `MultiAgentHooksConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `MultiAgentMemoryConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `MultiAgentOutputConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `MultiAgentPlanningConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `OUTPUT\_PRESETS` | ✅ | ✅ | low | ✅ exported |
|
||||
| `OutputConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `OutputPreset` | ✅ | ✅ | high | ✅ exported |
|
||||
| `PLANNING\_PRESETS` | ✅ | ✅ | low | ✅ exported |
|
||||
| `PlanningConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `Plugin` | ✅ | ✅ | high | ✅ exported |
|
||||
| `PluginHook` | ✅ | ✅ | high | ✅ exported |
|
||||
| `PluginInfo` | ✅ | ✅ | high | ✅ exported |
|
||||
| `PluginManager` | ✅ | ✅ | high | ✅ exported |
|
||||
| `PluginMetadata` | ✅ | ✅ | high | ✅ exported |
|
||||
| `PluginParseError` | ✅ | ✅ | low | ✅ exported |
|
||||
| `PluginProtocol` | ✅ | ✅ | medium | ✅ exported |
|
||||
| `PluginsConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `PraisonAIAgents` | ✅ | ✅ | high | ✅ exported |
|
||||
| `PraisonConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `ProviderStatus` | ✅ | ✅ | high | ✅ exported |
|
||||
| `REFLECTION\_PRESETS` | ✅ | ✅ | low | ✅ exported |
|
||||
| `RagRetrievalPolicy` | ✅ | ✅ | high | ✅ exported |
|
||||
| `ReflectionConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `ReflectionOutput` | ✅ | ✅ | high | ✅ exported |
|
||||
| `ResourceLimits` | ✅ | ✅ | high | ✅ exported |
|
||||
| `RoutingConditionProtocol` | ✅ | ✅ | medium | ✅ exported |
|
||||
| `SandboxConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `SandboxProtocol` | ✅ | ✅ | medium | ✅ exported |
|
||||
| `SandboxResult` | ✅ | ✅ | low | ✅ exported |
|
||||
| `SandboxStatus` | ✅ | ✅ | high | ✅ exported |
|
||||
| `SecurityPolicy` | ✅ | ✅ | high | ✅ exported |
|
||||
| `SessionConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `SkillsConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `StopReason` | ✅ | ✅ | high | ✅ exported |
|
||||
| `Task` | ✅ | ✅ | high | ✅ exported |
|
||||
| `TaskOutput` | ✅ | ✅ | high | ✅ exported |
|
||||
| `TemplateConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `ToolPluginProtocol` | ✅ | ✅ | medium | ✅ exported |
|
||||
| `TraceSink` | ✅ | ✅ | high | ✅ exported |
|
||||
| `TraceSinkProtocol` | ✅ | ✅ | medium | ✅ exported |
|
||||
| `WEB\_PRESETS` | ✅ | ✅ | low | ✅ exported |
|
||||
| `WebConfig` | ✅ | ✅ | low | ✅ exported |
|
||||
| `WebSearchProvider` | ✅ | ✅ | high | ✅ exported |
|
||||
| `aembed` | ✅ | ✅ | low | ✅ exported |
|
||||
| `aembedding` | ✅ | ✅ | low | ✅ exported |
|
||||
| `aembeddings` | ✅ | ✅ | low | ✅ exported |
|
||||
| `apply\_config\_defaults` | ✅ | ✅ | low | ✅ exported |
|
||||
| `async\_display\_callbacks` | ✅ | ✅ | low | ✅ exported |
|
||||
| `clean\_triple\_backticks` | ✅ | ✅ | low | ✅ exported |
|
||||
| `config` | ✅ | ✅ | low | ✅ exported |
|
||||
| `detect\_url\_scheme` | ✅ | ✅ | low | ✅ exported |
|
||||
| `discover\_and\_load\_plugins` | ✅ | ✅ | low | ✅ exported |
|
||||
| `discover\_plugins` | ✅ | ✅ | low | ✅ exported |
|
||||
| `display\_error` | ✅ | ✅ | low | ✅ exported |
|
||||
| `display\_generating` | ✅ | ✅ | low | ✅ exported |
|
||||
| `display\_instruction` | ✅ | ✅ | low | ✅ exported |
|
||||
| `display\_interaction` | ✅ | ✅ | low | ✅ exported |
|
||||
| `display\_self\_reflection` | ✅ | ✅ | low | ✅ exported |
|
||||
| `display\_tool\_call` | ✅ | ✅ | low | ✅ exported |
|
||||
| `embed` | ✅ | ✅ | low | ✅ exported |
|
||||
| `embedding` | ✅ | ✅ | low | ✅ exported |
|
||||
| `embeddings` | ✅ | ✅ | low | ✅ exported |
|
||||
| `ensure\_plugin\_dir` | ✅ | ✅ | low | ✅ exported |
|
||||
| `error\_logs` | ✅ | ✅ | low | ✅ exported |
|
||||
| `evaluate\_condition` | ✅ | ✅ | low | ✅ exported |
|
||||
| `get\_config` | ✅ | ✅ | low | ✅ exported |
|
||||
| `get\_config\_path` | ✅ | ✅ | low | ✅ exported |
|
||||
| `get\_default` | ✅ | ✅ | low | ✅ exported |
|
||||
| `get\_default\_plugin\_dirs` | ✅ | ✅ | low | ✅ exported |
|
||||
| `get\_defaults\_config` | ✅ | ✅ | low | ✅ exported |
|
||||
| `get\_dimensions` | ✅ | ✅ | low | ✅ exported |
|
||||
| `get\_plugin\_manager` | ✅ | ✅ | low | ✅ exported |
|
||||
| `get\_plugin\_template` | ✅ | ✅ | low | ✅ exported |
|
||||
| `get\_plugins\_config` | ✅ | ✅ | low | ✅ exported |
|
||||
| `is\_path\_like` | ✅ | ✅ | low | ✅ exported |
|
||||
| `is\_policy\_string` | ✅ | ✅ | low | ✅ exported |
|
||||
| `load\_plugin` | ✅ | ✅ | low | ✅ exported |
|
||||
| `memory` | ✅ | ✅ | low | ✅ exported |
|
||||
| `parse\_plugin\_header` | ✅ | ✅ | low | ✅ exported |
|
||||
| `parse\_plugin\_header\_from\_file` | ✅ | ✅ | low | ✅ exported |
|
||||
| `parse\_policy\_string` | ✅ | ✅ | low | ✅ exported |
|
||||
| `register\_display\_callback` | ✅ | ✅ | low | ✅ exported |
|
||||
| `resolve` | ✅ | ✅ | low | ✅ exported |
|
||||
| `resolve\_autonomy` | ✅ | ✅ | low | ✅ exported |
|
||||
| `resolve\_caching` | ✅ | ✅ | low | ✅ exported |
|
||||
| `resolve\_context` | ✅ | ✅ | low | ✅ exported |
|
||||
| `resolve\_execution` | ✅ | ✅ | low | ✅ exported |
|
||||
| `resolve\_guardrail\_policies` | ✅ | ✅ | low | ✅ exported |
|
||||
| `resolve\_guardrails` | ✅ | ✅ | low | ✅ exported |
|
||||
| `resolve\_hooks` | ✅ | ✅ | low | ✅ exported |
|
||||
| `resolve\_knowledge` | ✅ | ✅ | low | ✅ exported |
|
||||
| `resolve\_memory` | ✅ | ✅ | low | ✅ exported |
|
||||
| `resolve\_output` | ✅ | ✅ | low | ✅ exported |
|
||||
| `resolve\_planning` | ✅ | ✅ | low | ✅ exported |
|
||||
| `resolve\_reflection` | ✅ | ✅ | low | ✅ exported |
|
||||
| `resolve\_routing` | ✅ | ✅ | low | ✅ exported |
|
||||
| `resolve\_skills` | ✅ | ✅ | low | ✅ exported |
|
||||
| `resolve\_web` | ✅ | ✅ | low | ✅ exported |
|
||||
| `suggest\_similar` | ✅ | ✅ | low | ✅ exported |
|
||||
| `sync\_display\_callbacks` | ✅ | ✅ | low | ✅ exported |
|
||||
| `tools` | ✅ | ✅ | low | ✅ exported |
|
||||
| `trace\_context` | ✅ | ✅ | low | ✅ exported |
|
||||
| `track\_workflow` | ✅ | ✅ | low | ✅ exported |
|
||||
| `validate\_config` | ✅ | ✅ | low | ✅ exported |
|
||||
| `workflows` | ✅ | ✅ | low | ✅ exported |
|
||||
|
||||
## Python Core SDK Exports
|
||||
|
||||
@@ -719,10 +729,10 @@ from praisonaiagents import AgentFlow, If, Include, Loop, MAX_NESTING_DEPTH, Par
|
||||
**Path:** `/home/runner/work/PraisonAI/PraisonAI/src/praisonai-ts/src`
|
||||
|
||||
<details>
|
||||
<summary><strong>agent</strong> (64 exports)</summary>
|
||||
<summary><strong>agent</strong> (67 exports)</summary>
|
||||
|
||||
```typescript
|
||||
import { Agent, AgentTeam, AgentTeamConfig, Agents, AudioAgent, AudioAgentConfig, AudioProvider, AudioSpeakOptions, AudioSpeakResult, AudioTranscribeOptions... } from 'praisonai';
|
||||
import { Agent, AgentEvent, AgentStreamOptions, AgentTeam, AgentTeamConfig, Agents, AudioAgent, AudioAgentConfig, AudioProvider, AudioSpeakOptions... } from 'praisonai';
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -3,12 +3,49 @@
|
||||
"version": "1.7.4",
|
||||
"description": "PraisonAI TypeScript AI Agents Framework - Node.js, npm, and Javascript AI Agents Framework",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/esm/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/esm/index.js",
|
||||
"require": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/*.d.ts",
|
||||
"import": "./dist/esm/*.js",
|
||||
"require": "./dist/*.js",
|
||||
"default": "./dist/*.js"
|
||||
},
|
||||
"./*/index": {
|
||||
"types": "./dist/*/index.d.ts",
|
||||
"import": "./dist/esm/*/index.js",
|
||||
"require": "./dist/*/index.js",
|
||||
"default": "./dist/*/index.js"
|
||||
},
|
||||
"./ai": {
|
||||
"types": "./dist/ai/index.d.ts",
|
||||
"import": "./dist/esm/ai/index.js",
|
||||
"require": "./dist/ai/index.js",
|
||||
"default": "./dist/ai/index.js"
|
||||
},
|
||||
"./tools": {
|
||||
"types": "./dist/tools/index.d.ts",
|
||||
"import": "./dist/esm/tools/index.js",
|
||||
"require": "./dist/tools/index.js",
|
||||
"default": "./dist/tools/index.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"bin": {
|
||||
"praisonai-ts": "./dist/cli/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "npm run build:cjs && npm run build:esm",
|
||||
"build:cjs": "tsc",
|
||||
"build:esm": "tsc -p tsconfig.esm.json && node scripts/esm-shim.js",
|
||||
"verify:dist": "node scripts/verify-dist.mjs",
|
||||
"test": "jest",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"prepare": "npm run build",
|
||||
@@ -70,9 +107,7 @@
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"axios": "1.8.4",
|
||||
"dotenv": "^16.4.7",
|
||||
"fast-xml-parser": "^4.5.1",
|
||||
"node-fetch": "^2.6.9",
|
||||
"openai": "^4.81.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
Generated
-12
@@ -14,15 +14,9 @@ importers:
|
||||
axios:
|
||||
specifier: 1.8.4
|
||||
version: 1.8.4
|
||||
dotenv:
|
||||
specifier: ^16.4.7
|
||||
version: 16.5.0
|
||||
fast-xml-parser:
|
||||
specifier: ^4.5.1
|
||||
version: 4.5.3
|
||||
node-fetch:
|
||||
specifier: ^2.6.9
|
||||
version: 2.7.0
|
||||
openai:
|
||||
specifier: ^4.81.0
|
||||
version: 4.104.0([email protected])
|
||||
@@ -1379,10 +1373,6 @@ packages:
|
||||
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4891,8 +4881,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Post-processes the ESM build in dist/esm so it is valid native ESM:
|
||||
// 1. Rewrites extensionless relative specifiers (import/export ... from './x')
|
||||
// to './x.js' or './x/index.js' as required by Node's ESM resolver.
|
||||
// 2. Prepends a createRequire(import.meta.url) banner (plus __filename/__dirname
|
||||
// shims) to any emitted file that still relies on CommonJS globals, so the
|
||||
// rare require()/__dirname site keeps working under ESM.
|
||||
// 3. Writes dist/esm/package.json with { "type": "module" } so Node treats the
|
||||
// directory as ESM regardless of the root package.json "type".
|
||||
// Source files are never touched; the CJS build in dist/ is unaffected.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DIST_DIR = path.join(__dirname, '..', 'dist');
|
||||
const ESM_DIR = path.join(DIST_DIR, 'esm');
|
||||
|
||||
function walk(dir) {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...walk(full));
|
||||
} else if (entry.isFile() && full.endsWith('.js')) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveSpecifier(fileDir, spec) {
|
||||
// Only rewrite relative specifiers that lack an extension.
|
||||
if (!spec.startsWith('./') && !spec.startsWith('../')) return null;
|
||||
if (/\.[a-zA-Z0-9]+$/.test(spec)) return null; // already has an extension
|
||||
|
||||
const target = path.resolve(fileDir, spec);
|
||||
try {
|
||||
if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
|
||||
return spec.replace(/\/?$/, '') + '/index.js';
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return spec + '.js';
|
||||
}
|
||||
|
||||
function rewriteSpecifiers(code, fileDir) {
|
||||
// Matches static import/export ... from '...' and dynamic import('...').
|
||||
const patterns = [
|
||||
/((?:import|export)[\s\S]*?from\s*)(['"])(\.[^'"]+)\2/g,
|
||||
/(\bimport\s*\(\s*)(['"])(\.[^'"]+)\2(\s*\))/g,
|
||||
];
|
||||
let out = code;
|
||||
// from '...'
|
||||
out = out.replace(patterns[0], (match, pre, quote, spec) => {
|
||||
const resolved = resolveSpecifier(fileDir, spec);
|
||||
return resolved ? `${pre}${quote}${resolved}${quote}` : match;
|
||||
});
|
||||
// import('...')
|
||||
out = out.replace(patterns[1], (match, pre, quote, spec, post) => {
|
||||
const resolved = resolveSpecifier(fileDir, spec);
|
||||
return resolved ? `${pre}${quote}${resolved}${quote}${post}` : match;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveCjsTarget(fileDir, spec) {
|
||||
// Every ESM file in dist/esm has a CommonJS twin at the mirrored path in dist/.
|
||||
// Resolve the specifier from the TWIN's directory so it lands wherever the CJS
|
||||
// build's require would (internal module, package.json at the root, etc.), and
|
||||
// return that concrete file so createRequire loads real CommonJS/JSON.
|
||||
const relDir = path.relative(ESM_DIR, fileDir);
|
||||
// Files outside dist/esm are not part of the ESM build; leave them alone.
|
||||
if (relDir.startsWith('..') || path.isAbsolute(relDir)) return null;
|
||||
const cjsDir = path.join(DIST_DIR, relDir);
|
||||
const target = path.resolve(cjsDir, spec);
|
||||
if (/\.[a-zA-Z0-9]+$/.test(spec)) {
|
||||
return fs.existsSync(target) ? target : null;
|
||||
}
|
||||
if (fs.existsSync(target + '.js')) return target + '.js';
|
||||
const asIndex = path.join(target, 'index.js');
|
||||
if (fs.existsSync(asIndex)) return asIndex;
|
||||
return null;
|
||||
}
|
||||
|
||||
function rewriteRelativeRequires(code, fileDir) {
|
||||
// `tsc --module esnext` leaves explicit require('./x') calls untouched. Under
|
||||
// native ESM (dist/esm is type:module) those would resolve to ESM siblings and
|
||||
// throw ERR_REQUIRE_ESM on Node <22. Repoint each internal relative require to
|
||||
// the mirrored CommonJS file in dist/ so createRequire loads real CJS.
|
||||
return code.replace(
|
||||
/(\brequire\s*\(\s*)(['"])(\.[^'"]+)\2(\s*\))/g,
|
||||
(match, pre, quote, spec, post) => {
|
||||
const cjsAbs = resolveCjsTarget(fileDir, spec);
|
||||
if (!cjsAbs) return match;
|
||||
let relToCjs = path.relative(fileDir, cjsAbs).split(path.sep).join('/');
|
||||
if (!relToCjs.startsWith('.')) relToCjs = './' + relToCjs;
|
||||
return `${pre}${quote}${relToCjs}${quote}${post}`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const CJS_BANNER = [
|
||||
"import { createRequire as __praisonCreateRequire } from 'module';",
|
||||
"import { fileURLToPath as __praisonFileURLToPath } from 'url';",
|
||||
"import { dirname as __praisonDirname } from 'path';",
|
||||
'const require = __praisonCreateRequire(import.meta.url);',
|
||||
'const __filename = __praisonFileURLToPath(import.meta.url);',
|
||||
'const __dirname = __praisonDirname(__filename);',
|
||||
// In ESM there is no CommonJS module object. Provide a stub so patterns like
|
||||
// `require.main === module` compile and evaluate to false (this file is not a
|
||||
// CLI entry point when imported), and `module.exports = ...` remains harmless.
|
||||
'const module = { exports: {} };',
|
||||
].join('\n');
|
||||
|
||||
function needsCjsBanner(code) {
|
||||
return (
|
||||
/\brequire\b/.test(code) ||
|
||||
/\b__dirname\b/.test(code) ||
|
||||
/\b__filename\b/.test(code) ||
|
||||
/\bmodule\.exports\b/.test(code) ||
|
||||
/===\s*module\b/.test(code) ||
|
||||
/\bmodule\s*===/.test(code)
|
||||
);
|
||||
}
|
||||
|
||||
function applyBanner(code) {
|
||||
if (!needsCjsBanner(code)) return code;
|
||||
const shebangMatch = code.match(/^#![^\n]*\n/);
|
||||
if (shebangMatch) {
|
||||
const shebang = shebangMatch[0];
|
||||
return shebang + CJS_BANNER + '\n' + code.slice(shebang.length);
|
||||
}
|
||||
return CJS_BANNER + '\n' + code;
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(ESM_DIR)) {
|
||||
throw new Error(`ESM output not found at ${ESM_DIR}. Run the ESM tsc build first.`);
|
||||
}
|
||||
|
||||
const files = walk(ESM_DIR);
|
||||
for (const file of files) {
|
||||
const original = fs.readFileSync(file, 'utf8');
|
||||
const fileDir = path.dirname(file);
|
||||
let code = rewriteSpecifiers(original, fileDir);
|
||||
code = rewriteRelativeRequires(code, fileDir);
|
||||
code = applyBanner(code);
|
||||
if (code !== original) {
|
||||
fs.writeFileSync(file, code);
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ESM_DIR, 'package.json'),
|
||||
JSON.stringify({ type: 'module' }, null, 2) + '\n'
|
||||
);
|
||||
|
||||
console.log(`esm-shim: processed ${files.length} files in dist/esm`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,34 @@
|
||||
// Smoke-checks that both published entry points load and export Agent:
|
||||
// - CJS: require('../dist/index.js')
|
||||
// - ESM: await import('../dist/esm/index.js')
|
||||
// Exits non-zero if either entry fails to load or Agent is missing.
|
||||
|
||||
import { createRequire } from 'module';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
|
||||
let failed = false;
|
||||
|
||||
function check(label, mod) {
|
||||
if (mod && typeof mod.Agent === 'function') {
|
||||
console.log(`verify:dist ${label} OK — Agent is a function`);
|
||||
} else {
|
||||
console.error(`verify:dist ${label} FAILED — Agent not exported as a function`);
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const cjs = require(join(root, 'dist', 'index.js'));
|
||||
check('cjs (require)', cjs);
|
||||
|
||||
const esm = await import(join(root, 'dist', 'esm', 'index.js'));
|
||||
check('esm (import)', esm);
|
||||
|
||||
if (failed) {
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('verify:dist all OK');
|
||||
@@ -108,7 +108,8 @@ export class ImageAgent {
|
||||
// Try to use OpenAI SDK directly for image generation
|
||||
try {
|
||||
const OpenAI = (await import('openai')).default;
|
||||
const client = new OpenAI();
|
||||
const { buildOpenAIClientOptions } = await import('../llm/openaiClientOptions');
|
||||
const client = new OpenAI(buildOpenAIClientOptions());
|
||||
|
||||
const response = await client.images.generate({
|
||||
model: this.imageModel,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
// Core exports - the main API surface
|
||||
export { Agent, AgentTeam, PraisonAIAgents, Agents } from './simple';
|
||||
export type { SimpleAgentConfig, AgentTeamConfig, PraisonAIAgentsConfig } from './simple';
|
||||
export type { SimpleAgentConfig, AgentTeamConfig, PraisonAIAgentsConfig, AgentEvent, AgentStreamOptions, StopReason } from './simple';
|
||||
|
||||
// AudioAgent - Speech synthesis and transcription
|
||||
export { AudioAgent, createAudioAgent } from './audio';
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { DbAdapter, DbMessage, DbRun } from '../db/types';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { LLMProvider } from '../llm/providers/types';
|
||||
import type { BackendResolutionResult } from '../llm/backend-resolver';
|
||||
import { ApprovalManager, createCLIApprovalPrompt } from '../ai/tool-approval';
|
||||
|
||||
/**
|
||||
* Agent Configuration
|
||||
@@ -40,6 +41,13 @@ import type { BackendResolutionResult } from '../llm/backend-resolver';
|
||||
* await agent.chat("Hello!");
|
||||
* ```
|
||||
*/
|
||||
/**
|
||||
* Terminal reason for the most recent agent run, mirroring Python's
|
||||
* `Agent.last_stop_reason`. Lets callers distinguish a completed run from a
|
||||
* truncated one instead of both looking identical.
|
||||
*/
|
||||
export type StopReason = 'completed' | 'max_steps' | 'cancelled' | 'error';
|
||||
|
||||
export interface SimpleAgentConfig {
|
||||
/** Agent instructions/system prompt (required) */
|
||||
instructions: string;
|
||||
@@ -56,6 +64,22 @@ export interface SimpleAgentConfig {
|
||||
* Default: "gpt-4o-mini"
|
||||
*/
|
||||
llm?: string;
|
||||
/**
|
||||
* API key for the LLM provider. Mirrors Python's per-agent `api_key`
|
||||
* (agent/agent.py). Falls back to OPENAI_API_KEY when omitted.
|
||||
*/
|
||||
apiKey?: string;
|
||||
/**
|
||||
* Base URL for the LLM provider. Mirrors Python's per-agent `base_url`
|
||||
* (agent/agent.py). Useful for OpenAI-compatible endpoints.
|
||||
*/
|
||||
baseURL?: string;
|
||||
/**
|
||||
* Custom fetch implementation. Lets a host route provider egress through
|
||||
* native code (e.g. a Tauri command), keeping the API key out of the JS
|
||||
* heap and avoiding CORS for embedded webviews. Implies browser support.
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/** Enable markdown formatting in responses */
|
||||
markdown?: boolean;
|
||||
/** Enable streaming responses (default: true) */
|
||||
@@ -75,6 +99,28 @@ export interface SimpleAgentConfig {
|
||||
outputSchemaName?: string;
|
||||
/** Map of tool function implementations */
|
||||
toolFunctions?: Record<string, Function>;
|
||||
/**
|
||||
* Human-in-the-loop approval gate for tool calls (mirrors Python's
|
||||
* `approval`). When `true`, every tool is gated behind an interactive CLI
|
||||
* prompt (approve/deny per call); pass an `ApprovalManager` instance to use
|
||||
* custom handlers / auto-approve-deny rules (e.g. a UI responder). Denied
|
||||
* calls are reported back to the model as the tool result rather than
|
||||
* throwing, so it can course-correct.
|
||||
*/
|
||||
approval?: boolean | ApprovalManager;
|
||||
/**
|
||||
* Maximum number of tool-call round-trips before the loop is aborted
|
||||
* (default: 20, matching Python's `ExecutionConfig.max_iter`). When the cap
|
||||
* is reached the run sets `lastStopReason = 'max_steps'` and throws instead
|
||||
* of silently returning an empty string, so exhaustion is observable.
|
||||
*/
|
||||
maxIterations?: number;
|
||||
/**
|
||||
* Maximum number of tool calls executed within a single round-trip
|
||||
* (default: 10, matching Python's `ExecutionConfig.max_tool_calls_per_turn`).
|
||||
* Extra tool calls beyond this cap in one turn are ignored.
|
||||
*/
|
||||
maxToolCallsPerTurn?: number;
|
||||
/** Database adapter for persistence */
|
||||
db?: DbAdapter;
|
||||
/** Session ID for conversation persistence (auto-generated if not provided) */
|
||||
@@ -103,6 +149,25 @@ export interface SimpleAgentConfig {
|
||||
backstory?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated union emitted by {@link Agent.streamEvents}.
|
||||
*
|
||||
* Mirrors Python's `StreamEvent` channel (praisonaiagents streaming/events.py):
|
||||
* structured information travels here while {@link Agent.stream} yields plain
|
||||
* text tokens. `text` carries an incremental delta; `finish` carries the full
|
||||
* response; `error` carries a thrown error.
|
||||
*/
|
||||
export type AgentEvent =
|
||||
| { type: 'text'; delta: string }
|
||||
| { type: 'finish'; text: string }
|
||||
| { type: 'error'; error: Error };
|
||||
|
||||
/** Options for {@link Agent.stream} / {@link Agent.streamEvents}. */
|
||||
export interface AgentStreamOptions {
|
||||
/** Previous result to substitute for the `{{previous}}` placeholder. */
|
||||
previousResult?: string;
|
||||
}
|
||||
|
||||
export class Agent {
|
||||
private instructions: string;
|
||||
public name: string;
|
||||
@@ -110,12 +175,20 @@ export class Agent {
|
||||
private pretty: boolean;
|
||||
private llm: string;
|
||||
private markdown: boolean;
|
||||
private stream: boolean;
|
||||
private streamEnabled: boolean;
|
||||
private llmService: OpenAIService;
|
||||
private tools?: any[];
|
||||
private outputSchema?: Record<string, any>;
|
||||
private outputSchemaName: string = 'response';
|
||||
private toolFunctions: Record<string, Function> = {};
|
||||
private approvalManager?: ApprovalManager;
|
||||
private maxIterations: number;
|
||||
private maxToolCallsPerTurn: number;
|
||||
/**
|
||||
* Terminal reason for the most recent run (mirrors Python's
|
||||
* `Agent.last_stop_reason`). `null` until the agent has run at least once.
|
||||
*/
|
||||
public lastStopReason: StopReason | null = null;
|
||||
private dbAdapter?: DbAdapter;
|
||||
private sessionId: string;
|
||||
private runId: string;
|
||||
@@ -155,7 +228,7 @@ export class Agent {
|
||||
this.pretty = config.pretty ?? process.env.PRAISON_PRETTY === 'true';
|
||||
this.llm = config.llm || process.env.OPENAI_MODEL_NAME || process.env.PRAISONAI_MODEL || 'gpt-4o-mini';
|
||||
this.markdown = config.markdown ?? true;
|
||||
this.stream = config.stream ?? true;
|
||||
this.streamEnabled = config.stream ?? true;
|
||||
// NOTE: this.tools is rebuilt below from a snapshot of config.tools —
|
||||
// aliasing the caller's array while addAutoGeneratedToolDefinition pushes
|
||||
// into it corrupted the list (raw functions + duplicate definitions were
|
||||
@@ -163,6 +236,20 @@ export class Agent {
|
||||
this.tools = undefined;
|
||||
this.outputSchema = config.outputSchema;
|
||||
this.outputSchemaName = config.outputSchemaName || 'response';
|
||||
if (config.approval instanceof ApprovalManager) {
|
||||
this.approvalManager = config.approval;
|
||||
} else if (config.approval === true) {
|
||||
// `true` shorthand: gate every tool via an interactive CLI prompt.
|
||||
// Without a handler, requestApproval() would fall through to a Promise
|
||||
// awaiting respond() that nobody calls — stalling every tool for the
|
||||
// full 5-min timeout before denying. Wire the built-in prompt so the
|
||||
// default is usable and safe (blocks until the operator answers).
|
||||
const manager = new ApprovalManager();
|
||||
manager.onApprovalRequest(createCLIApprovalPrompt());
|
||||
this.approvalManager = manager;
|
||||
}
|
||||
this.maxIterations = config.maxIterations ?? 20;
|
||||
this.maxToolCallsPerTurn = config.maxToolCallsPerTurn ?? 10;
|
||||
this.dbAdapter = config.db;
|
||||
this.sessionId = config.sessionId || this.generateSessionId();
|
||||
this.runId = config.runId || randomUUID();
|
||||
@@ -181,7 +268,11 @@ export class Agent {
|
||||
// For OpenAI, use OpenAIService directly for backward compatibility
|
||||
// For other providers, we'll use the AI SDK backend via getBackend()
|
||||
this._useAISDKBackend = providerId !== 'openai';
|
||||
this.llmService = new OpenAIService(modelId);
|
||||
this.llmService = new OpenAIService(modelId, {
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseURL,
|
||||
fetch: config.fetch,
|
||||
});
|
||||
|
||||
// Configure logging
|
||||
Logger.setVerbose(this.verbose);
|
||||
@@ -311,6 +402,31 @@ export class Agent {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten OpenAI-shape tool definitions ({ type: 'function', function: {...} })
|
||||
* into the provider-agnostic ToolDefinition shape the AI SDK backend expects.
|
||||
* Tools already in the flat shape pass through unchanged. This mirrors what
|
||||
* litellm does for the Python SDK: format once, translate at the transport —
|
||||
* so tools are never dropped by provider.
|
||||
*/
|
||||
private getFlatToolDefinitions(): Array<{ name: string; description?: string; parameters?: Record<string, any> }> {
|
||||
if (!this.tools || this.tools.length === 0) return [];
|
||||
return this.tools.map((tool: any) => {
|
||||
if (tool && tool.type === 'function' && tool.function) {
|
||||
return {
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: tool.function.parameters,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique session ID based on current hour, agent name, and random suffix
|
||||
*/
|
||||
@@ -472,7 +588,7 @@ export class Agent {
|
||||
* @param toolCalls Tool calls from the model
|
||||
* @returns Array of tool results
|
||||
*/
|
||||
private async processToolCalls(toolCalls: Array<any>): Promise<Array<{role: string, tool_call_id: string, content: string}>> {
|
||||
private async processToolCalls(toolCalls: Array<any>): Promise<Array<{role: string, tool_call_id: string, name: string, content: string}>> {
|
||||
const results = [];
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
@@ -487,7 +603,28 @@ export class Agent {
|
||||
if (!this.toolFunctions[name]) {
|
||||
throw new Error(`Function ${name} not registered`);
|
||||
}
|
||||
|
||||
|
||||
// Human-in-the-loop gate: block the tool until approved. A denial is
|
||||
// fed back to the model as the tool result so it can course-correct,
|
||||
// rather than aborting the run.
|
||||
if (this.approvalManager) {
|
||||
const approved = await this.approvalManager.requestApproval({
|
||||
toolInvocationId: id,
|
||||
toolName: name,
|
||||
input: args,
|
||||
});
|
||||
if (!approved) {
|
||||
await Logger.debug(`Tool call denied by approval gate: ${name}`);
|
||||
results.push({
|
||||
role: 'tool',
|
||||
tool_call_id: id,
|
||||
name,
|
||||
content: `Error: Tool call "${name}" was denied by the approval gate.`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Call the function - registered wrappers handle positional mapping
|
||||
const result = await this.toolFunctions[name](args);
|
||||
|
||||
@@ -501,10 +638,13 @@ export class Agent {
|
||||
? ''
|
||||
: JSON.stringify(result);
|
||||
|
||||
// Add result to messages
|
||||
// Add result to messages. `name` is required so the AI SDK adapter
|
||||
// (toAISDKPrompt) can set a non-empty toolName on the tool-result part —
|
||||
// without it, non-OpenAI providers reject the tool follow-up.
|
||||
results.push({
|
||||
role: 'tool',
|
||||
tool_call_id: id,
|
||||
name,
|
||||
content
|
||||
});
|
||||
|
||||
@@ -514,6 +654,7 @@ export class Agent {
|
||||
results.push({
|
||||
role: 'tool',
|
||||
tool_call_id: id,
|
||||
name,
|
||||
content: `Error: ${error.message || 'Unknown error'}`
|
||||
});
|
||||
}
|
||||
@@ -522,9 +663,14 @@ export class Agent {
|
||||
return results;
|
||||
}
|
||||
|
||||
async start(prompt: string, previousResult?: string): Promise<string> {
|
||||
async start(prompt: string, previousResult?: string, onToken?: (token: string) => void): Promise<string> {
|
||||
await Logger.debug(`Agent ${this.name} starting with prompt: ${prompt}`);
|
||||
|
||||
// Token sink: when a caller (e.g. stream()) supplies onToken, tokens go
|
||||
// there instead of the terminal. Defaulting to stdout preserves CLI
|
||||
// behaviour without leaking process.stdout into non-terminal hosts.
|
||||
const emitToken = onToken ?? ((token: string) => { process.stdout.write(token); });
|
||||
|
||||
try {
|
||||
// Replace placeholder with previous result if available
|
||||
if (previousResult) {
|
||||
@@ -547,26 +693,83 @@ export class Agent {
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
|
||||
let finalResponse = '';
|
||||
// Reset per run; set to a terminal value before returning/throwing so
|
||||
// callers can tell a completed run from a truncated one.
|
||||
this.lastStopReason = null;
|
||||
|
||||
// Use AI SDK backend for non-OpenAI providers
|
||||
if (this._useAISDKBackend) {
|
||||
const backend = await this.getBackend();
|
||||
|
||||
// Honesty over silence: the AI SDK backend path does not yet wire
|
||||
// tools or structured output through — say so instead of quietly
|
||||
// answering without them.
|
||||
if (this.tools && this.tools.length > 0) {
|
||||
await Logger.warn(
|
||||
`Agent ${this.name}: tools are not yet supported with non-OpenAI providers (${this.llm}) — proceeding WITHOUT tools.`
|
||||
);
|
||||
}
|
||||
if (this.outputSchema) {
|
||||
await Logger.warn(
|
||||
`Agent ${this.name}: outputSchema is not yet supported with non-OpenAI providers (${this.llm}) — proceeding without structured output.`
|
||||
);
|
||||
}
|
||||
// Tools survive on every provider: format once (provider-agnostic
|
||||
// ToolDefinition) and let the AI SDK backend translate at the
|
||||
// transport — the same contract litellm gives the Python SDK.
|
||||
const toolDefinitions = this.getFlatToolDefinitions();
|
||||
let continueConversation = true;
|
||||
let iterations = 0;
|
||||
const maxIterations = this.maxIterations;
|
||||
|
||||
if (this.stream && !this.tools) {
|
||||
while (continueConversation && iterations < maxIterations) {
|
||||
iterations++;
|
||||
|
||||
const result = await backend.generateText({
|
||||
messages,
|
||||
temperature: 0.7,
|
||||
tools: toolDefinitions,
|
||||
toolChoice: 'auto',
|
||||
});
|
||||
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: result.text || '',
|
||||
tool_calls: result.toolCalls,
|
||||
});
|
||||
|
||||
if (result.toolCalls && result.toolCalls.length > 0) {
|
||||
const toolResults = await this.processToolCalls(result.toolCalls);
|
||||
messages.push(...toolResults);
|
||||
continueConversation = true;
|
||||
} else {
|
||||
// Tool loop is done. When outputSchema is also configured, honor
|
||||
// it instead of dropping it: re-issue the final turn through
|
||||
// generateObject so structured output survives alongside tools —
|
||||
// mirrors the OpenAI native path threading responseFormat through
|
||||
// the loop.
|
||||
if (this.outputSchema) {
|
||||
const structured = await backend.generateObject({
|
||||
messages,
|
||||
schema: this.outputSchema,
|
||||
temperature: 0.7,
|
||||
});
|
||||
finalResponse = typeof structured.object === 'string'
|
||||
? structured.object
|
||||
: JSON.stringify(structured.object);
|
||||
} else {
|
||||
finalResponse = result.text || '';
|
||||
}
|
||||
continueConversation = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (continueConversation && iterations >= maxIterations) {
|
||||
await Logger.warn(`Reached maximum iterations (${maxIterations}) for tool calls`);
|
||||
throw new Error(
|
||||
`Agent ${this.name}: reached maximum tool-call iterations (${maxIterations}) without a final answer. ` +
|
||||
`Increase maxIterations in the agent config if the task legitimately needs more tool round-trips.`
|
||||
);
|
||||
}
|
||||
} else if (this.outputSchema) {
|
||||
// Structured output via the AI SDK backend's generateObject.
|
||||
const result = await backend.generateObject({
|
||||
messages,
|
||||
schema: this.outputSchema,
|
||||
temperature: 0.7,
|
||||
});
|
||||
finalResponse = typeof result.object === 'string'
|
||||
? result.object
|
||||
: JSON.stringify(result.object);
|
||||
} else if (this.streamEnabled) {
|
||||
// Streaming with AI SDK backend
|
||||
const stream = await backend.streamText({
|
||||
messages,
|
||||
@@ -576,7 +779,7 @@ export class Agent {
|
||||
let accumulated = '';
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.text) {
|
||||
process.stdout.write(chunk.text);
|
||||
emitToken(chunk.text);
|
||||
accumulated += chunk.text;
|
||||
}
|
||||
}
|
||||
@@ -589,57 +792,105 @@ export class Agent {
|
||||
});
|
||||
finalResponse = result.text;
|
||||
}
|
||||
} else if (this.stream && !this.tools && !this.outputSchema) {
|
||||
// Use streaming with full conversation history (OpenAI)
|
||||
finalResponse = await this.llmService.streamChat(
|
||||
messages,
|
||||
0.7,
|
||||
(token: string) => {
|
||||
process.stdout.write(token);
|
||||
}
|
||||
);
|
||||
} else if (this.tools) {
|
||||
// Use tools (non-streaming for now to simplify implementation)
|
||||
// Unified streaming/tools loop (OpenAI). Streaming and tools are no
|
||||
// longer mutually exclusive: when `stream` is set, text deltas, tool
|
||||
// calls and tool results interleave on one request per round-trip via
|
||||
// streamChatWithTools; otherwise a single blocking generateChat is used.
|
||||
let continueConversation = true;
|
||||
let iterations = 0;
|
||||
const maxIterations = 5; // Prevent infinite loops
|
||||
|
||||
const maxIterations = this.maxIterations; // Prevent infinite loops (configurable)
|
||||
|
||||
while (continueConversation && iterations < maxIterations) {
|
||||
iterations++;
|
||||
|
||||
// Get response from LLM (responseFormat constrains the final
|
||||
// answer when outputSchema is configured)
|
||||
const response = await this.llmService.generateChat(
|
||||
messages, 0.7, this.tools, undefined, this.getResponseFormat()
|
||||
);
|
||||
|
||||
// Add assistant response to messages
|
||||
|
||||
// Tool rounds and the structured final response are separate concerns.
|
||||
// OpenAI treats `tools` and `response_format` (json_schema) as mutually
|
||||
// exclusive: sending both can make a tool round get rejected before it
|
||||
// returns tool_calls. So during tool rounds we omit the schema and let
|
||||
// the model decide; once the model stops calling tools we re-issue a
|
||||
// final request with the schema applied to shape the answer.
|
||||
const response = this.streamEnabled
|
||||
? await this.llmService.streamChatWithTools(
|
||||
messages,
|
||||
0.7,
|
||||
this.tools,
|
||||
(token: string) => emitToken(token),
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
: await this.llmService.generateChat(
|
||||
messages, 0.7, this.tools, undefined, undefined
|
||||
);
|
||||
|
||||
// Cap tool calls executed per turn (Python parity:
|
||||
// ExecutionConfig.max_tool_calls_per_turn). Extra calls beyond the cap
|
||||
// in a single round are dropped. The assistant message must list ONLY
|
||||
// the executed calls, because every tool_call_id in an assistant
|
||||
// message needs a matching tool result message or the next request 400s.
|
||||
const perTurn = response.tool_calls
|
||||
? response.tool_calls.slice(0, this.maxToolCallsPerTurn)
|
||||
: undefined;
|
||||
|
||||
// Add assistant response to messages (only the executed tool calls)
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: response.content || '',
|
||||
tool_calls: response.tool_calls
|
||||
tool_calls: perTurn
|
||||
});
|
||||
|
||||
|
||||
// Check if there are tool calls to process
|
||||
if (response.tool_calls && response.tool_calls.length > 0) {
|
||||
// Process tool calls
|
||||
const toolResults = await this.processToolCalls(response.tool_calls);
|
||||
|
||||
// Add tool results to messages
|
||||
if (perTurn && perTurn.length > 0) {
|
||||
if (response.tool_calls!.length > this.maxToolCallsPerTurn) {
|
||||
await Logger.warn(
|
||||
`Agent ${this.name}: ${response.tool_calls!.length} tool calls in one turn exceeds ` +
|
||||
`maxToolCallsPerTurn (${this.maxToolCallsPerTurn}); executing the first ${this.maxToolCallsPerTurn}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Process tool calls and add results to messages
|
||||
const toolResults = await this.processToolCalls(perTurn);
|
||||
messages.push(...toolResults);
|
||||
|
||||
|
||||
// Continue conversation to get final response
|
||||
continueConversation = true;
|
||||
} else if (this.outputSchema) {
|
||||
// Model produced no tool calls: issue one final request that pins the
|
||||
// structured output schema. Tools are omitted here so `tools` and
|
||||
// `response_format` never coexist (OpenAI treats them as mutually
|
||||
// exclusive). generateChat sends the full history.
|
||||
const finalResp = await this.llmService.generateChat(
|
||||
messages, 0.7, undefined, undefined, this.getResponseFormat()
|
||||
);
|
||||
finalResponse = finalResp.content || response.content || '';
|
||||
continueConversation = false;
|
||||
} else {
|
||||
// No tool calls, we have our final response
|
||||
finalResponse = response.content || '';
|
||||
continueConversation = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (iterations >= maxIterations) {
|
||||
|
||||
if (continueConversation && iterations >= maxIterations) {
|
||||
// Exhaustion is observable: returning finalResponse here would be
|
||||
// '' (unresolved tool calls left no text answer), indistinguishable
|
||||
// from the model saying nothing. Mark the stop reason and throw.
|
||||
this.lastStopReason = 'max_steps';
|
||||
await Logger.warn(`Reached maximum iterations (${maxIterations}) for tool calls`);
|
||||
throw new Error(
|
||||
`Agent ${this.name}: reached maximum tool-call iterations (${maxIterations}) without a final answer. ` +
|
||||
`Increase maxIterations in the agent config if the task legitimately needs more tool round-trips.`
|
||||
);
|
||||
}
|
||||
} else if (this.streamEnabled && !this.outputSchema) {
|
||||
// Use streaming with full conversation history (OpenAI, no tools)
|
||||
finalResponse = await this.llmService.streamChat(
|
||||
messages,
|
||||
0.7,
|
||||
(token: string) => {
|
||||
emitToken(token);
|
||||
}
|
||||
);
|
||||
} else if (this.outputSchema) {
|
||||
// Structured output (no tools): go through generateChat so the full
|
||||
// `messages` history (system + prior turns + current prompt) is sent.
|
||||
@@ -667,13 +918,123 @@ export class Agent {
|
||||
finalResponse = response;
|
||||
}
|
||||
|
||||
// A run that reaches here finished normally (max_steps throws earlier).
|
||||
if (this.lastStopReason === null) {
|
||||
this.lastStopReason = 'completed';
|
||||
}
|
||||
return finalResponse;
|
||||
} catch (error) {
|
||||
// Preserve a more specific reason (e.g. 'max_steps') if already set.
|
||||
if (this.lastStopReason === null) {
|
||||
this.lastStopReason = 'error';
|
||||
}
|
||||
await Logger.error('Error in agent execution', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream the agent's response token-by-token as an async iterable of plain
|
||||
* strings — mirrors Python's `Agent.iter_stream()`, which yields bare `str`.
|
||||
* This lets any host (browser, webview, React Native, server) render an
|
||||
* answer as it arrives, instead of only receiving the final string.
|
||||
*
|
||||
* Backpressure is free (the loop pulls) and cancellation is a single path:
|
||||
* breaking out of the `for await` runs the iterator's `return()`, which
|
||||
* detaches the token sink so no further tokens are queued.
|
||||
*
|
||||
* When the underlying execution path does not stream (e.g. `stream: false`,
|
||||
* tools, or a structured `outputSchema`), no text deltas are produced; in
|
||||
* that case the full response from the terminal `finish` event is yielded as
|
||||
* a single token so callers always receive the answer.
|
||||
*
|
||||
* @param prompt - The user prompt to send to the agent.
|
||||
* @param opts - Optional {@link AgentStreamOptions} (e.g. `previousResult`).
|
||||
* @returns An async iterable of plain text tokens.
|
||||
* @example
|
||||
* ```typescript
|
||||
* for await (const token of agent.stream("Tell me a story")) {
|
||||
* process.stdout.write(token);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
async *stream(prompt: string, opts?: AgentStreamOptions): AsyncIterable<string> {
|
||||
let sawDelta = false;
|
||||
for await (const event of this.streamEvents(prompt, opts)) {
|
||||
if (event.type === 'text') {
|
||||
sawDelta = true;
|
||||
yield event.delta;
|
||||
} else if (event.type === 'finish') {
|
||||
// Non-streaming paths yield only a finish event; surface its text so
|
||||
// stream() never silently drops a successful response.
|
||||
if (!sawDelta && event.text) {
|
||||
yield event.text;
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
throw event.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream structured {@link AgentEvent}s (text deltas, finish, error) — the
|
||||
* TypeScript analogue of Python's `stream_emitter` channel. Prefer
|
||||
* {@link Agent.stream} when you only need the text tokens.
|
||||
*
|
||||
* @param prompt - The user prompt to send to the agent.
|
||||
* @param opts - Optional {@link AgentStreamOptions} (e.g. `previousResult`).
|
||||
* @returns An async iterable of {@link AgentEvent}s: zero or more `text`
|
||||
* deltas followed by a single terminal `finish` (or `error`) event.
|
||||
* @example
|
||||
* ```typescript
|
||||
* for await (const event of agent.streamEvents("Hi")) {
|
||||
* if (event.type === 'text') process.stdout.write(event.delta);
|
||||
* else if (event.type === 'finish') console.log('\n', event.text);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
async *streamEvents(prompt: string, opts?: AgentStreamOptions): AsyncIterable<AgentEvent> {
|
||||
const queue: string[] = [];
|
||||
let notify: (() => void) | null = null;
|
||||
let done = false;
|
||||
let cancelled = false;
|
||||
|
||||
const wake = () => { const n = notify; notify = null; n?.(); };
|
||||
const onToken = (token: string) => {
|
||||
if (cancelled) return;
|
||||
queue.push(token);
|
||||
wake();
|
||||
};
|
||||
|
||||
const run = this.start(prompt, opts?.previousResult, onToken)
|
||||
.then((text) => ({ text } as { text: string }))
|
||||
.catch((error) => ({
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
}))
|
||||
.finally(() => { done = true; wake(); });
|
||||
|
||||
try {
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length === 0) {
|
||||
await new Promise<void>((resolve) => { notify = resolve; });
|
||||
continue;
|
||||
}
|
||||
yield { type: 'text', delta: queue.shift()! };
|
||||
}
|
||||
|
||||
const result = await run;
|
||||
if ('error' in result) {
|
||||
yield { type: 'error', error: result.error };
|
||||
return;
|
||||
}
|
||||
yield { type: 'finish', text: result.text };
|
||||
} finally {
|
||||
// Breaking the consumer's loop lands here: stop feeding the sink so the
|
||||
// in-flight start() can settle without buffering further tokens.
|
||||
cancelled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async chat(prompt: string, previousResult?: string): Promise<string> {
|
||||
// Lazy init: restore history on first chat (like Python SDK)
|
||||
await this.initDbSession();
|
||||
|
||||
@@ -76,7 +76,8 @@ async function speakText(args: string[], options: VoiceOptions, outputFormat: st
|
||||
|
||||
try {
|
||||
const OpenAI = (await import('openai')).default;
|
||||
const client = new OpenAI();
|
||||
const { buildOpenAIClientOptions } = await import('../../llm/openaiClientOptions');
|
||||
const client = new OpenAI(buildOpenAIClientOptions());
|
||||
|
||||
const response = await client.audio.speech.create({
|
||||
model: 'tts-1',
|
||||
@@ -133,7 +134,8 @@ async function transcribeAudio(args: string[], options: VoiceOptions, outputForm
|
||||
|
||||
try {
|
||||
const OpenAI = (await import('openai')).default;
|
||||
const client = new OpenAI();
|
||||
const { buildOpenAIClientOptions } = await import('../../llm/openaiClientOptions');
|
||||
const client = new OpenAI(buildOpenAIClientOptions());
|
||||
|
||||
const file = fs.createReadStream(audioFile);
|
||||
const response = await client.audio.transcriptions.create({
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
// Agent - Single agent with instructions, tools, and optional persistence
|
||||
// AgentTeam is the primary class (Python parity), Agents/PraisonAIAgents are silent aliases
|
||||
export { Agent, AgentTeam, Agents, PraisonAIAgents, Router } from './agent';
|
||||
export type { SimpleAgentConfig, AgentTeamConfig, PraisonAIAgentsConfig, SimpleRouterConfig, SimpleRouteConfig } from './agent';
|
||||
export type { SimpleAgentConfig, AgentTeamConfig, PraisonAIAgentsConfig, SimpleRouterConfig, SimpleRouteConfig, AgentEvent, AgentStreamOptions, StopReason } from './agent';
|
||||
|
||||
// Workflow - Step-based workflow execution
|
||||
// AgentFlow is the primary class (Python parity), Workflow/Pipeline are silent aliases
|
||||
|
||||
@@ -247,7 +247,8 @@ async function embedWithNative(
|
||||
const { model: modelId } = parseEmbeddingModel(model);
|
||||
|
||||
const OpenAI = (await import('openai')).default;
|
||||
const client = new OpenAI();
|
||||
const { buildOpenAIClientOptions } = await import('./openaiClientOptions');
|
||||
const client = new OpenAI(buildOpenAIClientOptions());
|
||||
|
||||
const response = await client.embeddings.create({
|
||||
model: modelId,
|
||||
@@ -271,7 +272,8 @@ async function embedManyWithNative(
|
||||
const { model: modelId } = parseEmbeddingModel(model);
|
||||
|
||||
const OpenAI = (await import('openai')).default;
|
||||
const client = new OpenAI();
|
||||
const { buildOpenAIClientOptions } = await import('./openaiClientOptions');
|
||||
const client = new OpenAI(buildOpenAIClientOptions());
|
||||
|
||||
const response = await client.embeddings.create({
|
||||
model: modelId,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import OpenAI from 'openai';
|
||||
import dotenv from 'dotenv';
|
||||
import { buildOpenAIClientOptions, getEnv } from './openaiClientOptions';
|
||||
import { Logger } from '../utils/logger';
|
||||
import type { ChatCompletionTool, ChatCompletionToolChoiceOption, ChatCompletionMessageParam } from 'openai/resources/chat/completions';
|
||||
|
||||
// Load environment variables once at the application level
|
||||
dotenv.config();
|
||||
|
||||
// The API-key check lives in getOpenAIClient(), where the client is actually
|
||||
// created — importing the package must not throw for users of non-OpenAI
|
||||
// providers (anthropic/..., google/...) who never touch the OpenAI path.
|
||||
@@ -101,12 +98,13 @@ let openAIInstance: OpenAI | null = null;
|
||||
// Get cached OpenAI client instance
|
||||
async function getOpenAIClient(): Promise<OpenAI> {
|
||||
if (!openAIInstance) {
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
const apiKey = getEnv('OPENAI_API_KEY');
|
||||
if (!apiKey) {
|
||||
throw new Error('OPENAI_API_KEY not found in environment variables');
|
||||
}
|
||||
openAIInstance = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY
|
||||
});
|
||||
openAIInstance = new OpenAI(buildOpenAIClientOptions({
|
||||
apiKey
|
||||
}));
|
||||
await Logger.debug('OpenAI client initialized');
|
||||
}
|
||||
return openAIInstance;
|
||||
@@ -117,19 +115,49 @@ export type ResponseFormat =
|
||||
| { type: 'json_object' }
|
||||
| { type: 'json_schema'; json_schema: { name: string; schema: Record<string, any>; strict?: boolean } };
|
||||
|
||||
/**
|
||||
* Per-service credential/transport options. Mirrors Python's per-agent
|
||||
* `api_key` / `base_url` so callers can pass credentials without relying on
|
||||
* process env, and can inject a custom `fetch` for browser-like runtimes.
|
||||
*/
|
||||
export interface OpenAIServiceOptions {
|
||||
apiKey?: string;
|
||||
baseURL?: string;
|
||||
fetch?: typeof fetch;
|
||||
dangerouslyAllowBrowser?: boolean;
|
||||
}
|
||||
|
||||
export class OpenAIService {
|
||||
private model: string;
|
||||
private client: OpenAI | null = null;
|
||||
private options: OpenAIServiceOptions;
|
||||
|
||||
constructor(model: string = 'gpt-5-nano') {
|
||||
constructor(model: string = 'gpt-5-nano', options: OpenAIServiceOptions = {}) {
|
||||
this.model = model;
|
||||
this.options = options;
|
||||
Logger.debug(`OpenAIService initialized with model: ${model}`);
|
||||
}
|
||||
|
||||
// Lazy initialization of client
|
||||
private async getClient(): Promise<OpenAI> {
|
||||
if (!this.client) {
|
||||
this.client = await getOpenAIClient();
|
||||
// When explicit credentials/transport are supplied, build a
|
||||
// dedicated client so they are honoured instead of the shared
|
||||
// env-only singleton.
|
||||
if (this.options.apiKey || this.options.baseURL || this.options.fetch) {
|
||||
this.client = new OpenAI(buildOpenAIClientOptions(
|
||||
{
|
||||
apiKey: this.options.apiKey || getEnv('OPENAI_API_KEY'),
|
||||
...(this.options.baseURL ? { baseURL: this.options.baseURL } : {}),
|
||||
},
|
||||
{
|
||||
fetch: this.options.fetch,
|
||||
dangerouslyAllowBrowser: this.options.dangerouslyAllowBrowser,
|
||||
}
|
||||
));
|
||||
} else {
|
||||
this.client = await getOpenAIClient();
|
||||
}
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
@@ -386,6 +414,105 @@ export class OpenAIService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming chat that also surfaces tool calls. Text deltas are emitted
|
||||
* through `onToken` as they arrive; tool-call fragments are accumulated
|
||||
* across chunks and returned once the stream ends. This is the building
|
||||
* block that lets a streaming agent interleave reasoning text with tool
|
||||
* use in a single request (instead of streaming XOR tools).
|
||||
*
|
||||
* @returns The full streamed text plus any accumulated tool calls.
|
||||
*/
|
||||
async streamChatWithTools(
|
||||
messages: ChatMessage[],
|
||||
temperature: number = 0.7,
|
||||
tools?: ChatCompletionTool[],
|
||||
onToken?: (token: string) => void,
|
||||
tool_choice?: ChatCompletionToolChoiceOption,
|
||||
responseFormat?: ResponseFormat
|
||||
): Promise<LLMResponse> {
|
||||
await Logger.debug('Starting chat stream with tools...', {
|
||||
model: this.model,
|
||||
messageCount: messages.length
|
||||
});
|
||||
|
||||
try {
|
||||
const openAIMessages = messages.map(convertToOpenAIMessage);
|
||||
const openAITools = tools ? tools.map(convertToOpenAITool) : undefined;
|
||||
|
||||
const stream = await this.getClient().then(client =>
|
||||
client.chat.completions.create({
|
||||
model: this.model,
|
||||
...this.temperatureParam(temperature),
|
||||
messages: openAIMessages,
|
||||
stream: true,
|
||||
tools: openAITools,
|
||||
tool_choice,
|
||||
...(responseFormat ? { response_format: responseFormat } : {})
|
||||
})
|
||||
);
|
||||
|
||||
let fullResponse = '';
|
||||
const toolCalls: Record<number, any> = {};
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta;
|
||||
if (!delta) continue;
|
||||
|
||||
// Stream text deltas as they arrive.
|
||||
if (delta.content) {
|
||||
const token = delta.content;
|
||||
fullResponse += token;
|
||||
if (onToken) onToken(token);
|
||||
}
|
||||
|
||||
// Accumulate tool-call fragments across chunks. The id/name
|
||||
// arrive on the first fragment; arguments stream in pieces.
|
||||
if (delta.tool_calls && delta.tool_calls.length > 0) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
const index = toolCall.index ?? 0;
|
||||
if (!toolCalls[index]) {
|
||||
toolCalls[index] = {
|
||||
id: toolCall.id || '',
|
||||
type: toolCall.type || 'function',
|
||||
function: {
|
||||
name: toolCall.function?.name || '',
|
||||
arguments: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
if (toolCall.id) toolCalls[index].id = toolCall.id;
|
||||
if (toolCall.function?.name) {
|
||||
toolCalls[index].function.name = toolCall.function.name;
|
||||
}
|
||||
if (toolCall.function?.arguments) {
|
||||
toolCalls[index].function.arguments += toolCall.function.arguments;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: LLMResponse = {
|
||||
content: fullResponse,
|
||||
role: 'assistant'
|
||||
};
|
||||
const collected = Object.keys(toolCalls)
|
||||
.map(k => Number(k))
|
||||
.sort((a, b) => a - b)
|
||||
.map(i => toolCalls[i]);
|
||||
if (collected.length > 0) {
|
||||
result.tool_calls = collected;
|
||||
await Logger.debug('Tool calls detected in stream', { tool_calls: collected });
|
||||
}
|
||||
|
||||
await Logger.debug('Chat stream with tools completed');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await Logger.error('Error in chat stream with tools', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async chatCompletion(
|
||||
messages: ChatMessage[],
|
||||
temperature: number = 0.7,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Shared helper for constructing OpenAI client options that work across
|
||||
* Node and browser-like runtimes (webview, Electron renderer, React Native).
|
||||
*
|
||||
* The `openai` SDK refuses to construct in any browser-like environment unless
|
||||
* `dangerouslyAllowBrowser: true` is passed. Detecting the runtime here keeps
|
||||
* Node behaviour unchanged while unblocking browser-like hosts with zero config.
|
||||
*
|
||||
* Supplying a custom `fetch` lets a host route provider egress through native
|
||||
* code (e.g. a Tauri command), keeping the API key out of the JS heap and
|
||||
* avoiding CORS for embedded webviews; it also implies `dangerouslyAllowBrowser`.
|
||||
*/
|
||||
|
||||
export interface OpenAIRuntimeOptions {
|
||||
/** Force-enable (or disable) the SDK's browser guard. */
|
||||
dangerouslyAllowBrowser?: boolean;
|
||||
/** Custom fetch implementation; implies `dangerouslyAllowBrowser`. */
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an environment variable without assuming a Node `process` global, so the
|
||||
* OpenAI paths stay usable in webviews / React Native where `process` is
|
||||
* undefined (dereferencing it there throws `ReferenceError`).
|
||||
*
|
||||
* @param name - Environment variable name to read.
|
||||
* @returns The variable's value, or `undefined` when `process`/the var is absent.
|
||||
* @example
|
||||
* const key = getEnv('OPENAI_API_KEY');
|
||||
*/
|
||||
export function getEnv(name: string): string | undefined {
|
||||
return typeof process !== 'undefined' && process.env
|
||||
? process.env[name]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether we are running in a browser-like runtime: a real browser,
|
||||
* an embedded webview / Electron renderer, or React Native.
|
||||
*
|
||||
* @returns `true` for browser / webview / React-Native runtimes, else `false`.
|
||||
* @example
|
||||
* if (isBrowserLikeRuntime()) {
|
||||
* // dangerouslyAllowBrowser is required for the OpenAI SDK here
|
||||
* }
|
||||
*/
|
||||
export function isBrowserLikeRuntime(): boolean {
|
||||
const g = globalThis as any;
|
||||
const hasWindow =
|
||||
typeof g.window !== 'undefined' && typeof g.window.document !== 'undefined';
|
||||
const isReactNative =
|
||||
typeof g.navigator !== 'undefined' && g.navigator.product === 'ReactNative';
|
||||
return hasWindow || isReactNative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge caller-provided OpenAI client options with runtime-aware defaults so
|
||||
* that the SDK constructs successfully in browser-like environments.
|
||||
*
|
||||
* A custom `fetch` is injected and implies `dangerouslyAllowBrowser`. When
|
||||
* `runtime.dangerouslyAllowBrowser` is set it wins (an explicit `false` forces
|
||||
* the flag off even if `options` already carried `true`); otherwise the flag is
|
||||
* auto-enabled only in browser-like runtimes or when a custom `fetch` is given.
|
||||
*
|
||||
* @param options - Base OpenAI client options (e.g. `apiKey`, `baseURL`).
|
||||
* @param runtime - Runtime overrides: `dangerouslyAllowBrowser` and/or `fetch`.
|
||||
* @returns The merged options, browser-safe for the detected runtime.
|
||||
* @example
|
||||
* const client = new OpenAI(
|
||||
* buildOpenAIClientOptions({ apiKey }, { fetch: nativeFetch })
|
||||
* );
|
||||
*/
|
||||
export function buildOpenAIClientOptions<T extends Record<string, any>>(
|
||||
options: T = {} as T,
|
||||
runtime: OpenAIRuntimeOptions = {}
|
||||
): T & { dangerouslyAllowBrowser?: boolean; fetch?: typeof fetch } {
|
||||
const result: Record<string, any> = { ...options };
|
||||
if (runtime.fetch) {
|
||||
result.fetch = runtime.fetch;
|
||||
}
|
||||
const allowBrowser =
|
||||
runtime.dangerouslyAllowBrowser ??
|
||||
(isBrowserLikeRuntime() || !!runtime.fetch);
|
||||
if (allowBrowser) {
|
||||
result.dangerouslyAllowBrowser = true;
|
||||
} else {
|
||||
// An explicit disable (or a non-browser runtime) must win even if the
|
||||
// caller's base options already carried the flag.
|
||||
delete result.dangerouslyAllowBrowser;
|
||||
}
|
||||
return result as T & {
|
||||
dangerouslyAllowBrowser?: boolean;
|
||||
fetch?: typeof fetch;
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import OpenAI from 'openai';
|
||||
import type { ChatCompletionMessageParam, ChatCompletionTool } from 'openai/resources/chat/completions';
|
||||
import { buildOpenAIClientOptions, getEnv } from '../openaiClientOptions';
|
||||
import { BaseProvider } from './base';
|
||||
import type {
|
||||
ProviderConfig,
|
||||
@@ -24,12 +25,15 @@ export class OpenAIProvider extends BaseProvider {
|
||||
|
||||
constructor(modelId: string, config: ProviderConfig = {}) {
|
||||
super(modelId, config);
|
||||
this.client = new OpenAI({
|
||||
apiKey: config.apiKey || process.env.OPENAI_API_KEY,
|
||||
baseURL: config.baseUrl,
|
||||
timeout: config.timeout || 60000,
|
||||
maxRetries: 0, // We handle retries ourselves
|
||||
});
|
||||
this.client = new OpenAI(buildOpenAIClientOptions(
|
||||
{
|
||||
apiKey: config.apiKey || getEnv('OPENAI_API_KEY'),
|
||||
baseURL: config.baseUrl,
|
||||
timeout: config.timeout || 60000,
|
||||
maxRetries: 0, // We handle retries ourselves
|
||||
},
|
||||
{ fetch: config.fetch, dangerouslyAllowBrowser: config.dangerouslyAllowBrowser }
|
||||
));
|
||||
}
|
||||
|
||||
async generateText(options: GenerateTextOptions): Promise<GenerateTextResult> {
|
||||
|
||||
@@ -83,6 +83,17 @@ export interface ProviderConfig {
|
||||
maxRetries?: number;
|
||||
timeout?: number;
|
||||
defaultModel?: string;
|
||||
/**
|
||||
* Custom fetch implementation. Lets a host route provider egress through
|
||||
* native code (e.g. a Tauri command), keeping the API key out of the JS
|
||||
* heap and avoiding CORS for embedded webviews. Implies browser support.
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Force-enable the OpenAI SDK's browser guard. Auto-detected for
|
||||
* browser-like runtimes when omitted.
|
||||
*/
|
||||
dangerouslyAllowBrowser?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,10 +8,20 @@ export enum LogLevel {
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
private static level: LogLevel = process.env.LOGLEVEL === 'debug' ? LogLevel.DEBUG : LogLevel.INFO;
|
||||
private static _level?: LogLevel;
|
||||
private static verbose: boolean = true;
|
||||
private static pretty: boolean = false;
|
||||
|
||||
private static get level(): LogLevel {
|
||||
if (this._level === undefined) {
|
||||
const logLevel = typeof process !== 'undefined' && process.env
|
||||
? process.env.LOGLEVEL
|
||||
: undefined;
|
||||
this._level = logLevel === 'debug' ? LogLevel.DEBUG : LogLevel.INFO;
|
||||
}
|
||||
return this._level;
|
||||
}
|
||||
|
||||
private static getCircularReplacer() {
|
||||
const seen = new WeakSet();
|
||||
return (key: string, value: any) => {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Behavioral tests for Agent.stream() / Agent.streamEvents().
|
||||
*
|
||||
* These CALL the streaming API (not merely assert it is exported): they drive
|
||||
* a mocked streamChat that emits tokens through the onToken sink, then verify
|
||||
* the async iterable yields those tokens, that no tokens leak to
|
||||
* process.stdout, and that breaking the loop cancels cleanly.
|
||||
*/
|
||||
import { Agent } from '../../../src/agent/simple';
|
||||
import { OpenAIService } from '../../../src/llm/openai';
|
||||
|
||||
jest.mock('openai');
|
||||
|
||||
/** Mock streamChat: emit each token via onToken, return the joined string. */
|
||||
function mockStreamChat(tokens: string[]) {
|
||||
return jest
|
||||
.spyOn(OpenAIService.prototype, 'streamChat')
|
||||
.mockImplementation(async (_messages, _temp, onToken) => {
|
||||
for (const t of tokens) onToken(t);
|
||||
return tokens.join('');
|
||||
});
|
||||
}
|
||||
|
||||
function makeAgent() {
|
||||
return new Agent({
|
||||
instructions: 'be helpful',
|
||||
llm: 'gpt-4o-mini',
|
||||
stream: true,
|
||||
verbose: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe('Agent.stream()', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('yields text tokens as an async iterable', async () => {
|
||||
const tokens = ['Hello', ', ', 'world', '!'];
|
||||
const spy = mockStreamChat(tokens);
|
||||
|
||||
const received: string[] = [];
|
||||
for await (const token of makeAgent().stream('hi')) {
|
||||
received.push(token);
|
||||
}
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(received).toEqual(tokens);
|
||||
expect(received.join('')).toBe('Hello, world!');
|
||||
});
|
||||
|
||||
it('does NOT write tokens to process.stdout (no terminal leak)', async () => {
|
||||
mockStreamChat(['a', 'b', 'c']);
|
||||
const writeSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
|
||||
for await (const _ of makeAgent().stream('hi')) {
|
||||
// drain
|
||||
}
|
||||
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('streamEvents() emits text deltas then a finish with the full text', async () => {
|
||||
const tokens = ['foo', 'bar'];
|
||||
mockStreamChat(tokens);
|
||||
|
||||
const events: any[] = [];
|
||||
for await (const event of makeAgent().streamEvents('hi')) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const deltas = events.filter((e) => e.type === 'text').map((e) => e.delta);
|
||||
const finish = events.find((e) => e.type === 'finish');
|
||||
expect(deltas).toEqual(tokens);
|
||||
expect(finish).toEqual({ type: 'finish', text: 'foobar' });
|
||||
});
|
||||
|
||||
it('yields the finish text when the agent is non-streaming (stream: false)', async () => {
|
||||
const generateSpy = jest
|
||||
.spyOn(OpenAIService.prototype, 'generateText')
|
||||
.mockResolvedValue('final answer');
|
||||
|
||||
const agent = new Agent({
|
||||
instructions: 'be helpful',
|
||||
llm: 'gpt-4o-mini',
|
||||
stream: false,
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
const received: string[] = [];
|
||||
for await (const token of agent.stream('hi')) {
|
||||
received.push(token);
|
||||
}
|
||||
|
||||
expect(generateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(received.join('')).toBe('final answer');
|
||||
});
|
||||
|
||||
it('supports cancellation by breaking out of the loop', async () => {
|
||||
mockStreamChat(['1', '2', '3', '4', '5']);
|
||||
|
||||
const received: string[] = [];
|
||||
for await (const token of makeAgent().stream('hi')) {
|
||||
received.push(token);
|
||||
if (received.length === 2) break;
|
||||
}
|
||||
|
||||
expect(received).toEqual(['1', '2']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Tests for the unified streaming + tools loop (issue #4386).
|
||||
*
|
||||
* These call the real Agent code path with a mocked streaming OpenAI client so
|
||||
* they assert behaviour, not just that options exist: a streaming run with
|
||||
* tools must yield text AND execute the tool, then stream the final answer.
|
||||
*/
|
||||
import { Agent } from '../../../src/agent/simple';
|
||||
import { OpenAIService } from '../../../src/llm/openai';
|
||||
|
||||
jest.mock('openai');
|
||||
|
||||
/**
|
||||
* Build an async-iterable stream of OpenAI-style chunks from a list of deltas.
|
||||
*/
|
||||
function streamOf(deltas: any[]): any {
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const delta of deltas) {
|
||||
yield { choices: [{ delta }] };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Agent streaming + tools are no longer mutually exclusive', () => {
|
||||
const origWrite = process.stdout.write;
|
||||
|
||||
afterEach(() => {
|
||||
process.stdout.write = origWrite;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('a streaming run with tools yields text AND executes a tool', async () => {
|
||||
// Round 1: model streams reasoning text, then emits a tool call (fragmented).
|
||||
// Round 2: model streams the final answer text, no tool call.
|
||||
const round1 = streamOf([
|
||||
{ content: 'Let me check' },
|
||||
{ content: ' the weather.' },
|
||||
{ tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'getWeather', arguments: '' } }] },
|
||||
{ tool_calls: [{ index: 0, function: { arguments: '{"city":' } }] },
|
||||
{ tool_calls: [{ index: 0, function: { arguments: '"Paris"}' } }] },
|
||||
]);
|
||||
const round2 = streamOf([
|
||||
{ content: 'It is ' },
|
||||
{ content: '20C in Paris.' },
|
||||
]);
|
||||
|
||||
const create = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(round1)
|
||||
.mockResolvedValueOnce(round2);
|
||||
const client = { chat: { completions: { create } } };
|
||||
jest.spyOn(OpenAIService.prototype as any, 'getClient').mockResolvedValue(client);
|
||||
|
||||
// Capture streamed text.
|
||||
let streamed = '';
|
||||
(process.stdout.write as any) = (s: string) => { streamed += s; return true; };
|
||||
|
||||
let toolCalled = false;
|
||||
const getWeather = (city: string) => { toolCalled = true; return `Weather in ${city}: 20C`; };
|
||||
|
||||
const agent = new Agent({
|
||||
instructions: 'You provide weather info',
|
||||
llm: 'gpt-4o-mini',
|
||||
stream: true,
|
||||
verbose: false,
|
||||
tools: [getWeather],
|
||||
});
|
||||
|
||||
const result = await agent.start('Weather in Paris?');
|
||||
|
||||
// Streaming happened (text deltas were written) ...
|
||||
expect(streamed).toContain('Let me check the weather.');
|
||||
expect(streamed).toContain('It is 20C in Paris.');
|
||||
// ... AND the tool executed ...
|
||||
expect(toolCalled).toBe(true);
|
||||
// ... AND the final streamed answer is returned.
|
||||
expect(result).toBe('It is 20C in Paris.');
|
||||
// Both round-trips used the streaming API (stream: true).
|
||||
expect(create).toHaveBeenCalledTimes(2);
|
||||
expect(create.mock.calls[0][0].stream).toBe(true);
|
||||
expect(create.mock.calls[1][0].stream).toBe(true);
|
||||
// The tool-call round-trip sent the tool result back to the model.
|
||||
const secondCallMessages = create.mock.calls[1][0].messages;
|
||||
expect(secondCallMessages.some((m: any) => m.role === 'tool')).toBe(true);
|
||||
|
||||
expect(agent.lastStopReason).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxIterations / lastStopReason parity', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('defaults maxIterations to 20 and maxToolCallsPerTurn to 10', () => {
|
||||
const agent = new Agent({ instructions: 't', verbose: false });
|
||||
expect((agent as any).maxIterations).toBe(20);
|
||||
expect((agent as any).maxToolCallsPerTurn).toBe(10);
|
||||
});
|
||||
|
||||
it('respects configured maxIterations and maxToolCallsPerTurn', () => {
|
||||
const agent = new Agent({ instructions: 't', verbose: false, maxIterations: 3, maxToolCallsPerTurn: 2 });
|
||||
expect((agent as any).maxIterations).toBe(3);
|
||||
expect((agent as any).maxToolCallsPerTurn).toBe(2);
|
||||
});
|
||||
|
||||
it('sets lastStopReason to max_steps and throws when the tool loop never resolves', async () => {
|
||||
// Model always asks for a tool, never gives a final answer -> exhaustion.
|
||||
const alwaysToolCall = {
|
||||
content: '',
|
||||
role: 'assistant',
|
||||
tool_calls: [{ id: 'c', type: 'function', function: { name: 'noop', arguments: '{}' } }],
|
||||
};
|
||||
jest.spyOn(OpenAIService.prototype, 'generateChat').mockResolvedValue(alwaysToolCall as any);
|
||||
|
||||
const noop = function noop() { return 'ok'; };
|
||||
const agent = new Agent({
|
||||
instructions: 't',
|
||||
llm: 'gpt-4o-mini',
|
||||
stream: false,
|
||||
verbose: false,
|
||||
tools: [noop],
|
||||
maxIterations: 2,
|
||||
});
|
||||
|
||||
await expect(agent.start('go')).rejects.toThrow(/maximum tool-call iterations/);
|
||||
expect(agent.lastStopReason).toBe('max_steps');
|
||||
});
|
||||
|
||||
it('caps tool calls per turn and keeps assistant tool_calls paired with tool results', async () => {
|
||||
// Round 1: model returns THREE tool calls, but the cap is 2.
|
||||
// Round 2: model gives a final answer.
|
||||
const threeCalls = {
|
||||
content: '',
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{ id: 'c1', type: 'function', function: { name: 'noop', arguments: '{}' } },
|
||||
{ id: 'c2', type: 'function', function: { name: 'noop', arguments: '{}' } },
|
||||
{ id: 'c3', type: 'function', function: { name: 'noop', arguments: '{}' } },
|
||||
],
|
||||
};
|
||||
const finalAnswer = { content: 'done', role: 'assistant' };
|
||||
const generateChat = jest
|
||||
.spyOn(OpenAIService.prototype, 'generateChat')
|
||||
.mockResolvedValueOnce(threeCalls as any)
|
||||
.mockResolvedValueOnce(finalAnswer as any);
|
||||
|
||||
const noop = function noop() { return 'ok'; };
|
||||
const agent = new Agent({
|
||||
instructions: 't',
|
||||
llm: 'gpt-4o-mini',
|
||||
stream: false,
|
||||
verbose: false,
|
||||
tools: [noop],
|
||||
maxToolCallsPerTurn: 2,
|
||||
});
|
||||
|
||||
const result = await agent.start('go');
|
||||
expect(result).toBe('done');
|
||||
|
||||
// The second request's history: the assistant message must list only the 2
|
||||
// executed tool calls, matched 1:1 by exactly 2 tool result messages.
|
||||
const secondMessages = generateChat.mock.calls[1][0];
|
||||
const assistantMsg: any = secondMessages.find(
|
||||
(m: any) => m.role === 'assistant' && m.tool_calls
|
||||
);
|
||||
expect(assistantMsg).toBeDefined();
|
||||
expect(assistantMsg.tool_calls).toHaveLength(2);
|
||||
const toolMsgs = secondMessages.filter((m: any) => m.role === 'tool');
|
||||
expect(toolMsgs).toHaveLength(2);
|
||||
const assistantIds = assistantMsg.tool_calls.map((t: any) => t.id).sort();
|
||||
const toolIds = toolMsgs.map((m: any) => m.tool_call_id).sort();
|
||||
expect(assistantIds).toEqual(toolIds);
|
||||
});
|
||||
|
||||
it('sets lastStopReason to completed on a normal (non-tool) run', async () => {
|
||||
jest.spyOn(OpenAIService.prototype, 'generateText').mockResolvedValue('hi');
|
||||
const agent = new Agent({ instructions: 't', llm: 'gpt-4o-mini', stream: false, verbose: false });
|
||||
await agent.start('hello');
|
||||
expect(agent.lastStopReason).toBe('completed');
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { Agent } from '../../../src/agent/simple';
|
||||
import { OpenAIService } from '../../../src/llm/openai';
|
||||
import { ApprovalManager } from '../../../src/ai/tool-approval';
|
||||
|
||||
jest.mock('openai');
|
||||
|
||||
@@ -175,6 +176,86 @@ describe('Agent constructor tool processing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent tool approval gate', () => {
|
||||
const objTool = function lookup(id: string) {
|
||||
return { id, ok: true };
|
||||
};
|
||||
|
||||
it('runs the tool when no approval gate is configured', async () => {
|
||||
const agent = new Agent({ instructions: 't', tools: [objTool], verbose: false });
|
||||
const results = await (agent as any).processToolCalls([
|
||||
{ id: 'call_1', function: { name: 'lookup', arguments: '{"id":"x"}' } },
|
||||
]);
|
||||
expect(results[0].content).toBe('{"id":"x","ok":true}');
|
||||
});
|
||||
|
||||
it('blocks the tool and reports the denial back when approval is denied', async () => {
|
||||
const manager = new ApprovalManager();
|
||||
manager.onApprovalRequest(async () => false);
|
||||
const agent = new Agent({ instructions: 't', tools: [objTool], verbose: false, approval: manager });
|
||||
const spy = jest.fn(objTool);
|
||||
(agent as any).toolFunctions.lookup = spy;
|
||||
|
||||
const results = await (agent as any).processToolCalls([
|
||||
{ id: 'call_1', function: { name: 'lookup', arguments: '{"id":"x"}' } },
|
||||
]);
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(results[0].tool_call_id).toBe('call_1');
|
||||
expect(results[0].content).toContain('denied by the approval gate');
|
||||
});
|
||||
|
||||
it('runs the tool when approval is granted', async () => {
|
||||
const manager = new ApprovalManager();
|
||||
manager.onApprovalRequest(async () => true);
|
||||
const agent = new Agent({ instructions: 't', tools: [objTool], verbose: false, approval: manager });
|
||||
|
||||
const results = await (agent as any).processToolCalls([
|
||||
{ id: 'call_1', function: { name: 'lookup', arguments: '{"id":"x"}' } },
|
||||
]);
|
||||
expect(results[0].content).toBe('{"id":"x","ok":true}');
|
||||
});
|
||||
|
||||
it('approval: true creates a default ApprovalManager with an interactive handler (no 5-min stall)', () => {
|
||||
const agent = new Agent({ instructions: 't', tools: [objTool], verbose: false, approval: true });
|
||||
const manager = (agent as any).approvalManager as ApprovalManager;
|
||||
expect(manager).toBeInstanceOf(ApprovalManager);
|
||||
// A handler must be wired so requestApproval resolves via the handler
|
||||
// path instead of falling through to the respond()-awaiting Promise,
|
||||
// which would block for the full default timeout before denying.
|
||||
expect((manager as any).handlers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('approval: true resolves via its handler without waiting for the timeout', async () => {
|
||||
const agent = new Agent({ instructions: 't', tools: [objTool], verbose: false, approval: true });
|
||||
const manager = (agent as any).approvalManager as ApprovalManager;
|
||||
// Deny synchronously via the wired handler; must resolve immediately.
|
||||
(manager as any).handlers = [async () => false];
|
||||
const results = await Promise.race([
|
||||
(agent as any).processToolCalls([
|
||||
{ id: 'call_1', function: { name: 'lookup', arguments: '{"id":"x"}' } },
|
||||
]),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('stalled')), 1000)),
|
||||
]) as Array<{ tool_call_id: string; content: string }>;
|
||||
expect(results[0].content).toContain('denied by the approval gate');
|
||||
});
|
||||
|
||||
it('passes the tool call id as toolInvocationId (correlation, never positional)', async () => {
|
||||
const manager = new ApprovalManager();
|
||||
const spy = jest.spyOn(manager, 'requestApproval').mockResolvedValue(true);
|
||||
const agent = new Agent({ instructions: 't', tools: [objTool], verbose: false, approval: manager });
|
||||
|
||||
await (agent as any).processToolCalls([
|
||||
{ id: 'call_42', function: { name: 'lookup', arguments: '{"id":"x"}' } },
|
||||
]);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ toolInvocationId: 'call_42', toolName: 'lookup', input: { id: 'x' } }),
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent.chat single user message', () => {
|
||||
it('sends the user prompt to the LLM exactly once', async () => {
|
||||
const agent = new Agent({
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Cross-provider tool pass-through.
|
||||
*
|
||||
* Regression guard for the silent capability downgrade where non-OpenAI
|
||||
* providers dropped tools entirely (issue #4405). Python keeps tools on every
|
||||
* provider via litellm; the TS SDK must do the same by wiring them through the
|
||||
* AI SDK backend. A unit test on the OpenAI path alone stays green while every
|
||||
* other provider silently loses them, so we assert per-provider here.
|
||||
*/
|
||||
import { Agent } from '../../../src/agent/simple';
|
||||
|
||||
const getWeather = (city: string) => `Weather in ${city}: 20C`;
|
||||
|
||||
// Providers that route through the AI SDK backend (everything except openai).
|
||||
const PROVIDERS = [
|
||||
'anthropic/claude-3-5-sonnet-latest',
|
||||
'gemini/gemini-1.5-flash',
|
||||
'ollama/llama3.2',
|
||||
'groq/llama-3.3-70b-versatile',
|
||||
'mistral/mistral-large-latest',
|
||||
];
|
||||
|
||||
describe('tools survive on every non-OpenAI provider', () => {
|
||||
for (const llm of PROVIDERS) {
|
||||
it(`passes tools into generateText for ${llm}`, async () => {
|
||||
const agent = new Agent({ instructions: 'weather', tools: [getWeather], llm, verbose: false });
|
||||
|
||||
const generateText = jest.fn().mockResolvedValue({
|
||||
text: 'sunny',
|
||||
toolCalls: undefined,
|
||||
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
||||
finishReason: 'stop',
|
||||
});
|
||||
|
||||
// Stub the resolved AI SDK backend so no network/keys are needed.
|
||||
jest.spyOn(agent as any, 'getBackend').mockResolvedValue({
|
||||
generateText,
|
||||
streamText: jest.fn(),
|
||||
generateObject: jest.fn(),
|
||||
});
|
||||
|
||||
const out = await agent.start('weather in Paris?');
|
||||
expect(out).toBe('sunny');
|
||||
|
||||
expect(generateText).toHaveBeenCalled();
|
||||
const callArgs = generateText.mock.calls[0][0];
|
||||
expect(Array.isArray(callArgs.tools)).toBe(true);
|
||||
expect(callArgs.tools.length).toBeGreaterThan(0);
|
||||
expect(callArgs.tools[0].name).toBe('getWeather');
|
||||
});
|
||||
}
|
||||
|
||||
it('runs the tool-call loop on a non-OpenAI provider', async () => {
|
||||
const agent = new Agent({
|
||||
instructions: 'weather',
|
||||
tools: [getWeather],
|
||||
llm: 'anthropic/claude-3-5-sonnet-latest',
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
const generateText = jest
|
||||
.fn()
|
||||
// First turn: model asks for the tool.
|
||||
.mockResolvedValueOnce({
|
||||
text: '',
|
||||
toolCalls: [{ id: 'call_1', type: 'function', function: { name: 'getWeather', arguments: '{"city":"Paris"}' } }],
|
||||
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
||||
finishReason: 'tool_calls',
|
||||
})
|
||||
// Second turn: model produces the final answer.
|
||||
.mockResolvedValueOnce({
|
||||
text: 'It is 20C in Paris.',
|
||||
toolCalls: undefined,
|
||||
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
||||
finishReason: 'stop',
|
||||
});
|
||||
|
||||
jest.spyOn(agent as any, 'getBackend').mockResolvedValue({
|
||||
generateText,
|
||||
streamText: jest.fn(),
|
||||
generateObject: jest.fn(),
|
||||
});
|
||||
|
||||
const out = await agent.start('weather in Paris?');
|
||||
expect(out).toBe('It is 20C in Paris.');
|
||||
expect(generateText).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The tool result must be fed back into the follow-up request.
|
||||
const secondCallMessages = generateText.mock.calls[1][0].messages;
|
||||
const toolMsg = secondCallMessages.find((m: any) => m.role === 'tool');
|
||||
expect(toolMsg).toBeDefined();
|
||||
// The tool name must survive so the AI SDK adapter can set a non-empty
|
||||
// toolName on the tool-result part — otherwise non-OpenAI providers reject
|
||||
// the follow-up request.
|
||||
expect(toolMsg.name).toBe('getWeather');
|
||||
});
|
||||
|
||||
it('honors outputSchema alongside tools on a non-OpenAI provider', async () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: { temp: { type: 'number' } },
|
||||
required: ['temp'],
|
||||
};
|
||||
const agent = new Agent({
|
||||
instructions: 'weather',
|
||||
tools: [getWeather],
|
||||
outputSchema: schema,
|
||||
llm: 'anthropic/claude-3-5-sonnet-latest',
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
// Tool loop resolves in one turn (no tool calls), then the final answer
|
||||
// must go through generateObject rather than dropping the schema.
|
||||
const generateText = jest.fn().mockResolvedValue({
|
||||
text: 'ignored raw text',
|
||||
toolCalls: undefined,
|
||||
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
||||
finishReason: 'stop',
|
||||
});
|
||||
const generateObject = jest.fn().mockResolvedValue({
|
||||
object: { temp: 20 },
|
||||
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
||||
finishReason: 'stop',
|
||||
});
|
||||
|
||||
jest.spyOn(agent as any, 'getBackend').mockResolvedValue({
|
||||
generateText,
|
||||
streamText: jest.fn(),
|
||||
generateObject,
|
||||
});
|
||||
|
||||
const out = await agent.start('weather in Paris?');
|
||||
// Structured output wins — the raw text is not returned.
|
||||
expect(generateObject).toHaveBeenCalledTimes(1);
|
||||
expect(generateObject.mock.calls[0][0].schema).toBe(schema);
|
||||
expect(JSON.parse(out)).toEqual({ temp: 20 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
buildOpenAIClientOptions,
|
||||
isBrowserLikeRuntime,
|
||||
getEnv,
|
||||
} from '../../../src/llm/openaiClientOptions';
|
||||
|
||||
describe('buildOpenAIClientOptions', () => {
|
||||
const g = globalThis as any;
|
||||
const originalWindow = g.window;
|
||||
const originalNavigator = g.navigator;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWindow === undefined) {
|
||||
delete g.window;
|
||||
} else {
|
||||
g.window = originalWindow;
|
||||
}
|
||||
if (originalNavigator === undefined) {
|
||||
delete g.navigator;
|
||||
} else {
|
||||
g.navigator = originalNavigator;
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves Node behaviour unchanged (no browser flag)', () => {
|
||||
delete g.window;
|
||||
delete g.navigator;
|
||||
expect(isBrowserLikeRuntime()).toBe(false);
|
||||
const opts = buildOpenAIClientOptions({ apiKey: 'sk-test' });
|
||||
expect(opts.apiKey).toBe('sk-test');
|
||||
expect(opts.dangerouslyAllowBrowser).toBeUndefined();
|
||||
expect(opts.fetch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('enables dangerouslyAllowBrowser in a browser-like runtime', () => {
|
||||
g.window = { document: {} };
|
||||
expect(isBrowserLikeRuntime()).toBe(true);
|
||||
const opts = buildOpenAIClientOptions({ apiKey: 'sk-test' });
|
||||
expect(opts.dangerouslyAllowBrowser).toBe(true);
|
||||
});
|
||||
|
||||
it('detects React Native as browser-like', () => {
|
||||
delete g.window;
|
||||
g.navigator = { product: 'ReactNative' };
|
||||
expect(isBrowserLikeRuntime()).toBe(true);
|
||||
const opts = buildOpenAIClientOptions();
|
||||
expect(opts.dangerouslyAllowBrowser).toBe(true);
|
||||
});
|
||||
|
||||
it('injects a custom fetch and implies browser support', () => {
|
||||
delete g.window;
|
||||
delete g.navigator;
|
||||
const customFetch = (async () => new Response('')) as unknown as typeof fetch;
|
||||
const opts = buildOpenAIClientOptions(
|
||||
{ apiKey: 'sk-test' },
|
||||
{ fetch: customFetch }
|
||||
);
|
||||
expect(opts.fetch).toBe(customFetch);
|
||||
expect(opts.dangerouslyAllowBrowser).toBe(true);
|
||||
});
|
||||
|
||||
it('honours an explicit dangerouslyAllowBrowser override', () => {
|
||||
g.window = { document: {} };
|
||||
const opts = buildOpenAIClientOptions(
|
||||
{ apiKey: 'sk-test' },
|
||||
{ dangerouslyAllowBrowser: false }
|
||||
);
|
||||
expect(opts.dangerouslyAllowBrowser).toBeUndefined();
|
||||
});
|
||||
|
||||
it('explicit false override strips a pre-set flag on the base options', () => {
|
||||
g.window = { document: {} };
|
||||
const opts = buildOpenAIClientOptions(
|
||||
{ apiKey: 'sk-test', dangerouslyAllowBrowser: true },
|
||||
{ dangerouslyAllowBrowser: false }
|
||||
);
|
||||
expect(opts.dangerouslyAllowBrowser).toBeUndefined();
|
||||
});
|
||||
|
||||
it('actually constructs the real OpenAI SDK with browser-safe options', () => {
|
||||
// Exercise the real SDK (not the jest manual mock) to prove the options
|
||||
// the helper produces are the ones the browser guard requires. This calls
|
||||
// real code end-to-end rather than merely asserting a symbol is exported.
|
||||
jest.isolateModules(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const mod = require('openai');
|
||||
const OpenAI = mod.default ?? mod;
|
||||
g.window = { document: {} };
|
||||
|
||||
const opts = buildOpenAIClientOptions({ apiKey: 'sk-test' });
|
||||
expect(opts.dangerouslyAllowBrowser).toBe(true);
|
||||
|
||||
// Constructing with the helper's browser-safe options must not throw.
|
||||
expect(() => new OpenAI(opts)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEnv', () => {
|
||||
it('reads an existing environment variable in Node', () => {
|
||||
process.env.PRAISON_TEST_ENV = 'present';
|
||||
expect(getEnv('PRAISON_TEST_ENV')).toBe('present');
|
||||
delete process.env.PRAISON_TEST_ENV;
|
||||
});
|
||||
|
||||
it('returns undefined without throwing when process is absent (browser)', () => {
|
||||
const savedProcess = (globalThis as any).process;
|
||||
// Simulate a browser/webview where `process` is not defined.
|
||||
delete (globalThis as any).process;
|
||||
try {
|
||||
expect(() => getEnv('OPENAI_API_KEY')).not.toThrow();
|
||||
expect(getEnv('OPENAI_API_KEY')).toBeUndefined();
|
||||
} finally {
|
||||
(globalThis as any).process = savedProcess;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Dual ESM+CJS build — behavioural packaging tests.
|
||||
*
|
||||
* These tests exercise the *emitted* build output, not just declarations, to
|
||||
* guard the concrete regression in the issue: a CJS-only build downlevels every
|
||||
* `await import()` into a `require()`, which browser/React-Native bundlers cannot
|
||||
* resolve. The dual ESM build must instead preserve dynamic `import()` so it
|
||||
* survives as a lazy chunk.
|
||||
*
|
||||
* The tests build the ESM output on demand (idempotent) and then:
|
||||
* 1. Assert emitted ESM modules keep `import(` (not `require(`) for the lazy
|
||||
* boundaries their source used — this FAILS on the old CJS-only build.
|
||||
* 2. Actually load the ESM entry point via native dynamic import() and
|
||||
* construct an Agent — proving the entry works, not merely that it exists.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
const PKG_ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||
const ESM_DIR = path.join(PKG_ROOT, 'dist', 'esm');
|
||||
const ESM_ENTRY = path.join(ESM_DIR, 'index.js');
|
||||
|
||||
function buildDist(): void {
|
||||
// Always rebuild both CJS and ESM so the suite validates the CURRENT sources
|
||||
// and shim — never stale artifacts from an earlier revision. The ESM shim also
|
||||
// repoints internal relative require() calls at the mirrored CJS dist/, so the
|
||||
// CJS build must exist first (npm run build runs build:cjs then build:esm).
|
||||
execFileSync('npm', ['run', 'build'], {
|
||||
cwd: PKG_ROOT,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...walk(full));
|
||||
else if (entry.isFile() && full.endsWith('.js')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('praisonai dual ESM+CJS packaging', () => {
|
||||
beforeAll(() => {
|
||||
buildDist();
|
||||
}, 300000);
|
||||
|
||||
it('emits an ESM entry point and marks dist/esm as a module', () => {
|
||||
expect(fs.existsSync(ESM_ENTRY)).toBe(true);
|
||||
const esmPkg = JSON.parse(
|
||||
fs.readFileSync(path.join(ESM_DIR, 'package.json'), 'utf8')
|
||||
);
|
||||
expect(esmPkg.type).toBe('module');
|
||||
});
|
||||
|
||||
it('preserves dynamic import() in emitted ESM instead of downleveling to require()', () => {
|
||||
// Pick a representative module whose source uses `await import(`.
|
||||
// simple.ts (the Agent) uses lazy dynamic imports; its ESM output must keep them.
|
||||
const simpleEsm = path.join(ESM_DIR, 'agent', 'simple.js');
|
||||
expect(fs.existsSync(simpleEsm)).toBe(true);
|
||||
|
||||
const code = fs.readFileSync(simpleEsm, 'utf8');
|
||||
|
||||
// The behavioural guarantee: dynamic import() survives (lazy chunk for bundlers).
|
||||
expect(code).toMatch(/\bimport\s*\(/);
|
||||
|
||||
// And it was NOT downleveled into a synchronous require() at those sites,
|
||||
// which is exactly what the CJS-only build produced and what browser/RN
|
||||
// bundlers choke on. Any require() present must come only from the shim
|
||||
// banner (createRequire), never as `Promise.resolve().then(() => require(`.
|
||||
expect(code).not.toMatch(/Promise\.resolve\(\)\.then\([^)]*require\(/);
|
||||
expect(code).not.toMatch(/=\s*require\(/);
|
||||
});
|
||||
|
||||
it('rewrites relative specifiers to explicit .js paths for native ESM resolution', () => {
|
||||
const files = walk(ESM_DIR).filter((f) => !f.endsWith('package.json'));
|
||||
// Scan a bounded sample for speed; assert no bare extensionless relative
|
||||
// import survives (Node's ESM resolver requires explicit extensions).
|
||||
const offenders: string[] = [];
|
||||
for (const file of files) {
|
||||
const code = fs.readFileSync(file, 'utf8');
|
||||
const badFrom = /(?:import|export)[\s\S]*?from\s*['"](\.[^'"]*?)(?<!\.js)(?<!\.json)(?<!\/index)['"]/.test(
|
||||
code
|
||||
);
|
||||
// Only flag if it's a relative path with no extension at all.
|
||||
const m = code.match(/from\s*['"](\.[^'"]+)['"]/g) || [];
|
||||
for (const spec of m) {
|
||||
const inner = spec.replace(/from\s*['"]/, '').replace(/['"]$/, '');
|
||||
if (
|
||||
(inner.startsWith('./') || inner.startsWith('../')) &&
|
||||
!/\.[a-zA-Z0-9]+$/.test(inner)
|
||||
) {
|
||||
offenders.push(`${path.relative(ESM_DIR, file)}: ${inner}`);
|
||||
}
|
||||
}
|
||||
void badFrom;
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('loads the ESM entry in native Node ESM and constructs an Agent', () => {
|
||||
// Run in a real child Node process so ts-jest cannot rewrite the dynamic
|
||||
// import() into a require(). This proves the *published ESM* actually works
|
||||
// under native ESM resolution, not merely that a symbol is declared.
|
||||
const entryUrl = pathToFileURL(ESM_ENTRY).href;
|
||||
const script = [
|
||||
`const { Agent } = await import(${JSON.stringify(entryUrl)});`,
|
||||
`if (typeof Agent !== 'function') { console.error('NO_AGENT'); process.exit(2); }`,
|
||||
`const a = new Agent({ instructions: 'hi', sessionId: 'packaging-esm-test' });`,
|
||||
`if (a.getSessionId() !== 'packaging-esm-test') { console.error('BAD_SESSION'); process.exit(3); }`,
|
||||
`console.log('ESM_OK');`,
|
||||
].join('\n');
|
||||
|
||||
const scriptFile = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'praison-esm-')),
|
||||
'load.mjs'
|
||||
);
|
||||
fs.writeFileSync(scriptFile, script);
|
||||
|
||||
const out = execFileSync(process.execPath, [scriptFile], {
|
||||
cwd: PKG_ROOT,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(out).toContain('ESM_OK');
|
||||
}, 120000);
|
||||
|
||||
it('repoints internal relative require() at the CommonJS dist copy (not ESM)', () => {
|
||||
// tsc leaves explicit require('./x') in ESM output. Under native ESM those
|
||||
// would resolve to type:module siblings and throw ERR_REQUIRE_ESM on Node <22.
|
||||
// The shim must repoint every internal relative require at the mirrored CJS
|
||||
// file under dist/, i.e. the resolved target must live OUTSIDE dist/esm.
|
||||
const files = walk(ESM_DIR);
|
||||
const offenders: string[] = [];
|
||||
for (const file of files) {
|
||||
const code = fs.readFileSync(file, 'utf8');
|
||||
const requires = code.match(/require\(\s*['"](\.[^'"]+)['"]\s*\)/g) || [];
|
||||
for (const req of requires) {
|
||||
const spec = req.replace(/^require\(\s*['"]/, '').replace(/['"]\s*\)$/, '');
|
||||
const target = path.resolve(path.dirname(file), spec);
|
||||
const rel = path.relative(ESM_DIR, target);
|
||||
if (!rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
offenders.push(`${path.relative(ESM_DIR, file)}: ${spec}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves documented deep-import subpaths under native ESM', () => {
|
||||
// The exports map must not hide public subpaths documented in JSDoc
|
||||
// (praisonai/ai, praisonai/tools, praisonai/integrations/*). Resolve them via
|
||||
// the built ESM files directly (the published paths those subpaths map to).
|
||||
const subpaths = [
|
||||
path.join(ESM_DIR, 'ai', 'index.js'),
|
||||
path.join(ESM_DIR, 'tools', 'index.js'),
|
||||
path.join(ESM_DIR, 'integrations', 'slack.js'),
|
||||
];
|
||||
const script = subpaths
|
||||
.map((p) => `await import(${JSON.stringify(pathToFileURL(p).href)});`)
|
||||
.concat([`console.log('SUBPATHS_OK');`])
|
||||
.join('\n');
|
||||
|
||||
const scriptFile = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'praison-subpath-')),
|
||||
'load.mjs'
|
||||
);
|
||||
fs.writeFileSync(scriptFile, script);
|
||||
|
||||
const out = execFileSync(process.execPath, [scriptFile], {
|
||||
cwd: PKG_ROOT,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(out).toContain('SUBPATHS_OK');
|
||||
}, 120000);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Regression tests for issue #4390: importing the package must not run
|
||||
* `process.env` reads or `dotenv.config()` at module-initialisation time,
|
||||
* so it does not throw in runtimes where `process` is undefined.
|
||||
*
|
||||
* These call the code (not just assert exports) to prove behaviour.
|
||||
*/
|
||||
|
||||
describe('module-scope side effects (issue #4390)', () => {
|
||||
describe('Logger.level is lazy and process-guarded', () => {
|
||||
let savedProcess: any;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedProcess !== undefined) {
|
||||
(globalThis as any).process = savedProcess;
|
||||
savedProcess = undefined;
|
||||
}
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('does not throw when process is undefined', () => {
|
||||
jest.resetModules();
|
||||
savedProcess = (globalThis as any).process;
|
||||
// Simulate a webview / non-Node runtime with no `process`.
|
||||
delete (globalThis as any).process;
|
||||
|
||||
const { Logger } = require('../../../src/utils/logger');
|
||||
|
||||
// Reading the level would previously have thrown at import time via
|
||||
// `process.env.LOGLEVEL`. Now it must be safe to call.
|
||||
expect(() => Logger.info('hello')).not.toThrow();
|
||||
});
|
||||
|
||||
it('defaults to INFO level when LOGLEVEL is unset', () => {
|
||||
jest.resetModules();
|
||||
const prev = process.env.LOGLEVEL;
|
||||
delete process.env.LOGLEVEL;
|
||||
|
||||
const { Logger, LogLevel } = require('../../../src/utils/logger');
|
||||
const level = (Logger as any).level as number;
|
||||
expect(level).toBe(LogLevel.INFO);
|
||||
|
||||
if (prev !== undefined) {
|
||||
process.env.LOGLEVEL = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('llm/openai has no module-scope dotenv.config()', () => {
|
||||
it('imports without any OPENAI_API_KEY and without throwing', () => {
|
||||
jest.resetModules();
|
||||
const prevKey = process.env.OPENAI_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
|
||||
// dotenv was removed as a dependency; requiring the OpenAI module
|
||||
// must not attempt to load it or read a .env, and must not throw
|
||||
// for consumers of non-OpenAI providers.
|
||||
let mod: any;
|
||||
expect(() => {
|
||||
mod = require('../../../src/llm/openai');
|
||||
}).not.toThrow();
|
||||
expect(mod.OpenAIService).toBeDefined();
|
||||
|
||||
if (prevKey !== undefined) {
|
||||
process.env.OPENAI_API_KEY = prevKey;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not statically import the dotenv package', () => {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '../../../src/llm/openai.ts'),
|
||||
'utf8'
|
||||
);
|
||||
expect(src).not.toContain("from 'dotenv'");
|
||||
expect(src).not.toContain('dotenv.config(');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"outDir": "./dist/esm",
|
||||
"declaration": true,
|
||||
"declarationDir": "./dist/esm"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -453,6 +453,16 @@ class ParityTrackerGenerator:
|
||||
lines.append(f"> **Version:** {tracker['version']} | **Last Updated:** {tracker['lastUpdated']}")
|
||||
lines.append(f"> **Source of Truth:** {tracker['sourceOfTruth']}")
|
||||
lines.append("")
|
||||
lines.append("> [!IMPORTANT]")
|
||||
lines.append("> **What this measures:** whether a matching *exported symbol name* exists in")
|
||||
lines.append("> the TypeScript SDK's public surface (parsed from `src/index.ts`). It does **not**")
|
||||
lines.append("> verify that the capability is reachable, wired up, or behaves like its Python")
|
||||
lines.append("> counterpart. A `✅ exported` cell means the name is exported — not that it works.")
|
||||
lines.append("> Counts include barrel re-exports, so `TypeScript Features` reflects module")
|
||||
lines.append("> structure, not distinct capabilities, and is not directly comparable to the")
|
||||
lines.append("> Python count. For a capability with a testable contract, rely on its conformance")
|
||||
lines.append("> suite rather than this table.")
|
||||
lines.append("")
|
||||
|
||||
# Summary
|
||||
summary = tracker['summary']
|
||||
@@ -482,7 +492,7 @@ class ParityTrackerGenerator:
|
||||
done_count = sum(1 for g in gaps if g['status'] == 'DONE')
|
||||
todo_count = len(gaps) - done_count
|
||||
|
||||
lines.append(f"### {priority_key} ({done_count} done, {todo_count} todo)")
|
||||
lines.append(f"### {priority_key} ({done_count} exported, {todo_count} missing)")
|
||||
lines.append("")
|
||||
lines.append("| Feature | Python | TypeScript | Effort | Status |")
|
||||
lines.append("|---------|--------|------------|--------|--------|")
|
||||
@@ -490,7 +500,7 @@ class ParityTrackerGenerator:
|
||||
for gap in sorted(gaps, key=lambda x: (x['status'] != 'TODO', x['feature'])):
|
||||
py = "✅" if gap['python'] else "❌"
|
||||
ts = "✅" if gap['typescript'] else "❌"
|
||||
status = "✅ DONE" if gap['status'] == 'DONE' else "⏳ TODO"
|
||||
status = "✅ exported" if gap['status'] == 'DONE' else "⏳ missing"
|
||||
feature = _escape_md(gap['feature'])
|
||||
lines.append(f"| `{feature}` | {py} | {ts} | {gap['effort']} | {status} |")
|
||||
|
||||
@@ -617,6 +627,14 @@ class ParityTrackerGenerator:
|
||||
lines.append("")
|
||||
lines.append(f"> **Python Features:** {python_count} | **Rust Features:** {rust_count} | **Parity:** {parity_pct}%")
|
||||
lines.append("")
|
||||
lines.append("> [!IMPORTANT]")
|
||||
lines.append("> **What this measures:** whether a matching *exported symbol name* exists in the")
|
||||
lines.append("> Rust crate's public surface. It does **not** verify that the capability is")
|
||||
lines.append("> reachable or behaves like its Python counterpart — an exported item whose body")
|
||||
lines.append("> returns `Err(\"not yet implemented\")` still counts here. Treat `✅` as \"a symbol")
|
||||
lines.append("> of this name is exported\", not \"this works\". For a capability with a testable")
|
||||
lines.append("> contract, rely on its conformance suite rather than this table.")
|
||||
lines.append("")
|
||||
lines.append("## Summary")
|
||||
lines.append("")
|
||||
lines.append("| Metric | Count |")
|
||||
|
||||
@@ -55,8 +55,14 @@ class TypeScriptFeatureExtractor:
|
||||
- export { Name } from './path' statements
|
||||
- export type { Name } from './path' statements
|
||||
- export * from './path' statements
|
||||
|
||||
|
||||
It categorizes exports into modules based on their source paths.
|
||||
|
||||
IMPORTANT — this measures symbol *names*, not behaviour. Presence of an
|
||||
exported name here does NOT mean the capability is reachable, wired up, or
|
||||
behaviourally equivalent to its Python counterpart. A name that is exported
|
||||
but never called (e.g. an approval manager that no code path invokes) still
|
||||
shows up. Downstream reports must label these as "exported", not "done".
|
||||
"""
|
||||
|
||||
# Module category mapping based on source paths
|
||||
|
||||
@@ -311,9 +311,17 @@ def resolve_session_agent(agent_id: str, session_id: Optional[str]) -> Any:
|
||||
return agent
|
||||
|
||||
|
||||
# Fields whose swap requires side-effects the core SDK owns and the wrapper
|
||||
# must not open-code: a bare ``setattr(agent, 'llm', value)`` leaves the cached
|
||||
# ``_llm_instance`` / ``_unified_dispatcher`` / ``_llm_init_params`` bound to the
|
||||
# previous model, so the request keeps running on the OLD LLM while ``agent.llm``
|
||||
# (and the response metadata) reports the new one. Route through the SDK's own
|
||||
# invalidator instead (praisonaiagents.Agent._apply_default_llm).
|
||||
_SIDE_EFFECT_OVERRIDES = frozenset({"llm"})
|
||||
|
||||
_APPLYABLE_OVERRIDES = frozenset({
|
||||
"instructions", "goal", "role", "backstory",
|
||||
"llm", "max_iter",
|
||||
"max_iter",
|
||||
"verbose", "markdown",
|
||||
})
|
||||
|
||||
@@ -322,6 +330,46 @@ class AgentConfigError(ValueError):
|
||||
"""Raised when an agent_config override is unsupported or cannot be applied."""
|
||||
|
||||
|
||||
def _swap_llm(agent: Any, model: Any) -> None:
|
||||
"""Swap the agent's model, invalidating the SDK's cached LLM state.
|
||||
|
||||
Prefers the SDK's own ``_apply_default_llm`` (which drops ``_llm_instance``,
|
||||
``_unified_dispatcher`` and re-seeds ``_llm_init_params`` atomically). That
|
||||
method self-guards when the agent explicitly chose a model *or* was built
|
||||
with a panel descriptor, so both guards are temporarily cleared — an API
|
||||
override IS an explicit choice that supersedes a panel selection. Without
|
||||
clearing ``_panel_descriptor`` the SDK returns early and the request would
|
||||
silently continue on the panel model. Falls back to mirroring the SDK's
|
||||
invalidation contract when the method is absent.
|
||||
"""
|
||||
apply = getattr(agent, "_apply_default_llm", None)
|
||||
if callable(apply):
|
||||
had_explicit = getattr(agent, "_llm_explicit", None)
|
||||
had_panel = getattr(agent, "_panel_descriptor", None)
|
||||
agent._llm_explicit = False
|
||||
if had_panel is not None:
|
||||
agent._panel_descriptor = None
|
||||
try:
|
||||
apply(model)
|
||||
finally:
|
||||
if had_explicit is not None:
|
||||
agent._llm_explicit = had_explicit
|
||||
# ``_panel_descriptor`` is intentionally NOT restored: the override
|
||||
# replaces the panel selection for the lifetime of this isolated
|
||||
# clone. Restoring it would let ``_ensure_llm_instance`` rebuild the
|
||||
# panel LLM and drift back to the old model.
|
||||
return
|
||||
# Fallback: mirror the SDK invalidation contract manually.
|
||||
agent.llm = model
|
||||
if getattr(agent, "_panel_descriptor", None) is not None:
|
||||
agent._panel_descriptor = None
|
||||
for attr in ("_llm_instance", "_unified_dispatcher"):
|
||||
if hasattr(agent, attr):
|
||||
setattr(agent, attr, None)
|
||||
if getattr(agent, "_llm_init_params", None):
|
||||
agent._llm_init_params = {**agent._llm_init_params, "model": model}
|
||||
|
||||
|
||||
def _apply_agent_config(agent: Any, agent_config: Optional[Dict[str, Any]]) -> None:
|
||||
"""Apply per-request overrides to an isolated agent clone.
|
||||
|
||||
@@ -333,6 +381,10 @@ def _apply_agent_config(agent: Any, agent_config: Optional[Dict[str, Any]]) -> N
|
||||
the shared-instance fallback): writing overrides onto a registry-shared
|
||||
object would leak this request's config into subsequent/concurrent requests.
|
||||
In that case the override is rejected rather than silently applied globally.
|
||||
|
||||
``llm`` is a side-effecting swap routed through :func:`_swap_llm` so the
|
||||
cached LLM is invalidated — a plain ``setattr`` would leave the request
|
||||
running on the previously cached model.
|
||||
"""
|
||||
if not agent_config:
|
||||
return
|
||||
@@ -342,13 +394,18 @@ def _apply_agent_config(agent: Any, agent_config: Optional[Dict[str, Any]]) -> N
|
||||
"agent is shared across requests and cannot be safely overridden "
|
||||
"per request."
|
||||
)
|
||||
unknown = set(agent_config) - _APPLYABLE_OVERRIDES
|
||||
allowed = _APPLYABLE_OVERRIDES | _SIDE_EFFECT_OVERRIDES
|
||||
unknown = set(agent_config) - allowed
|
||||
if unknown:
|
||||
raise AgentConfigError(
|
||||
f"Unsupported agent_config override(s): {sorted(unknown)}. "
|
||||
f"Allowed: {sorted(_APPLYABLE_OVERRIDES)}"
|
||||
f"Allowed: {sorted(allowed)}"
|
||||
)
|
||||
if "llm" in agent_config:
|
||||
_swap_llm(agent, agent_config["llm"])
|
||||
for key, value in agent_config.items():
|
||||
if key in _SIDE_EFFECT_OVERRIDES:
|
||||
continue
|
||||
if not hasattr(agent, key):
|
||||
raise AgentConfigError(
|
||||
f"Agent has no attribute {key!r}; cannot override."
|
||||
|
||||
@@ -7,6 +7,7 @@ import asyncio
|
||||
import argparse
|
||||
import logging
|
||||
import importlib.util
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
from collections import defaultdict
|
||||
@@ -103,35 +104,96 @@ _ips_lock = asyncio.Lock()
|
||||
|
||||
# One-shot, short-lived session tokens handed to the Twilio media-stream client
|
||||
# so the shared server secret is never embedded in a URL (which leaks into
|
||||
# access logs / referrers / history). Maps token -> expiry timestamp.
|
||||
# access logs / referrers / history).
|
||||
_STREAM_SESSION_TTL = 60 # seconds
|
||||
_pending_stream_sessions: "dict[str, float]" = {}
|
||||
|
||||
|
||||
def _mint_stream_session_token() -> str:
|
||||
"""Create a single-use, TTL-bound token for the media-stream handshake."""
|
||||
now = time.time()
|
||||
# Opportunistically drop expired tokens so the map can't grow unbounded.
|
||||
for tok in [t for t, exp in _pending_stream_sessions.items() if exp < now]:
|
||||
_pending_stream_sessions.pop(tok, None)
|
||||
token = secrets.token_urlsafe(32)
|
||||
_pending_stream_sessions[token] = now + _STREAM_SESSION_TTL
|
||||
return token
|
||||
class CallAppState:
|
||||
"""Per-app tools registry + one-shot session-token store.
|
||||
|
||||
Replaces the previous module-level ``tools`` / ``_pending_stream_sessions``
|
||||
globals so co-hosted / multi-tenant ``build_call_app()`` instances in one
|
||||
process never share a tool schema or cross-consume each other's stream
|
||||
tokens. FastAPI hands this to handlers via ``request.app.state`` /
|
||||
``websocket.app.state``.
|
||||
"""
|
||||
|
||||
def _consume_stream_session_token(token: str) -> bool:
|
||||
"""Validate and consume a one-shot stream-session token (constant-time)."""
|
||||
if not token:
|
||||
__slots__ = ("tools", "pending_sessions", "_lock")
|
||||
|
||||
def __init__(self):
|
||||
self.tools: list = []
|
||||
self.pending_sessions: "dict[str, float]" = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def mint_stream_token(self) -> str:
|
||||
"""Create a single-use, TTL-bound token for the media-stream handshake."""
|
||||
now = time.time()
|
||||
token = secrets.token_urlsafe(32)
|
||||
with self._lock:
|
||||
# Opportunistically drop expired tokens so the map can't grow unbounded.
|
||||
self.pending_sessions = {
|
||||
t: exp for t, exp in self.pending_sessions.items() if exp >= now
|
||||
}
|
||||
self.pending_sessions[token] = now + _STREAM_SESSION_TTL
|
||||
return token
|
||||
|
||||
def consume_stream_token(self, token: str) -> bool:
|
||||
"""Validate and consume a one-shot stream-session token (constant-time)."""
|
||||
if not token:
|
||||
return False
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
for candidate, expiry in list(self.pending_sessions.items()):
|
||||
if expiry < now:
|
||||
self.pending_sessions.pop(candidate, None)
|
||||
continue
|
||||
if hmac.compare_digest(candidate, token):
|
||||
self.pending_sessions.pop(candidate, None)
|
||||
return True
|
||||
return False
|
||||
now = time.time()
|
||||
for candidate, expiry in list(_pending_stream_sessions.items()):
|
||||
if expiry < now:
|
||||
_pending_stream_sessions.pop(candidate, None)
|
||||
continue
|
||||
if hmac.compare_digest(candidate, token):
|
||||
_pending_stream_sessions.pop(candidate, None)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "[::1]"})
|
||||
|
||||
|
||||
def _configured_public_base() -> Optional[str]:
|
||||
"""``ws(s)://`` base for outbound Twilio media-stream URLs.
|
||||
|
||||
Set via ``PRAISONAI_CALL_PUBLIC_BASE`` (e.g. ``wss://praison.example``).
|
||||
Never fall back to ``request.url.hostname`` — that is derived from the
|
||||
client-supplied ``Host`` header and would let an authenticated caller
|
||||
redirect Twilio's live media leg to an arbitrary host (SSRF / call-audio
|
||||
exfiltration). Mirrors ``_configured_bind_host`` in ``agent_invoke.py``:
|
||||
externally-visible URLs come from config, never a request header.
|
||||
"""
|
||||
return os.getenv("PRAISONAI_CALL_PUBLIC_BASE")
|
||||
|
||||
|
||||
def _validate_public_base(base: Optional[str]) -> Optional[str]:
|
||||
"""Return an error string if ``base`` is not a safe media-stream base URL.
|
||||
|
||||
A prefix check alone is insufficient: a value like ``ws://`` has no host
|
||||
(Twilio cannot connect) and a cleartext ``ws://`` to a non-local host sends
|
||||
the live audio *and* the one-shot session token over an unencrypted
|
||||
transport. Require a structurally valid ``wss://`` URL with a host; permit
|
||||
cleartext ``ws://`` only for localhost (dev / tunnelled setups).
|
||||
|
||||
Returns ``None`` when the base is acceptable.
|
||||
"""
|
||||
if not base:
|
||||
return "unset"
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(base)
|
||||
if parsed.scheme not in ("ws", "wss"):
|
||||
return "PRAISONAI_CALL_PUBLIC_BASE must be a ws:// or wss:// URL"
|
||||
if not parsed.hostname:
|
||||
return "PRAISONAI_CALL_PUBLIC_BASE must include a host"
|
||||
if parsed.scheme == "ws" and parsed.hostname not in _LOCAL_HOSTS:
|
||||
return (
|
||||
"PRAISONAI_CALL_PUBLIC_BASE must use wss:// for non-local hosts; "
|
||||
"cleartext ws:// would expose live audio and the session token"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _tokens_match(provided: Optional[str], expected: Optional[str]) -> bool:
|
||||
@@ -173,12 +235,6 @@ logger = logging.getLogger(__name__)
|
||||
log_level = os.getenv("LOGLEVEL", "INFO").upper()
|
||||
logger.handlers.clear()
|
||||
|
||||
# Tool registry populated only when a server verb explicitly loads local tools
|
||||
# (see ``_load_local_tools`` / ``build_call_app``). It is NEVER populated as an
|
||||
# import-time side effect.
|
||||
tools = []
|
||||
|
||||
|
||||
def import_tools_from_file(file_path):
|
||||
"""Import tools from file with PRAISONAI_ALLOW_LOCAL_TOOLS opt-in.
|
||||
|
||||
@@ -201,20 +257,19 @@ def import_tools_from_file(file_path):
|
||||
raise ValueError(f"Failed to import tools from {file_path}") from e
|
||||
|
||||
|
||||
def _load_local_tools():
|
||||
"""Load ``./tools.py`` into the module ``tools`` registry, if opted in.
|
||||
def _load_local_tools_into(state):
|
||||
"""Load ``./tools.py`` into ``state.tools``, if opted in.
|
||||
|
||||
Runs user code, so it is gated behind ``PRAISONAI_ALLOW_LOCAL_TOOLS=true``
|
||||
and only invoked by a server entry point (``build_call_app``/``main``) — never
|
||||
at import time. Importing ``praisonai.api.call`` therefore has no filesystem
|
||||
scan and never executes a neighbouring ``tools.py``.
|
||||
|
||||
Idempotent: the shared ``tools`` registry is rebuilt from scratch on each
|
||||
call. Building the app more than once in a process (e.g. two
|
||||
``build_call_app(load_local_tools=True)`` calls) therefore does not
|
||||
accumulate duplicate tool definitions — which would otherwise send the same
|
||||
tool multiple times in every realtime ``session.update`` and grow the
|
||||
registry unbounded across builds.
|
||||
Idempotent: ``state.tools`` is rebuilt from scratch on each call. Building
|
||||
the app more than once in a process (e.g. two
|
||||
``build_call_app(load_local_tools=True)`` calls) does not accumulate
|
||||
duplicate tool definitions, and — because each app owns its own ``state`` —
|
||||
a second build no longer wipes an already-running app's tool schema.
|
||||
"""
|
||||
tools_path = os.path.join(os.getcwd(), 'tools.py')
|
||||
logger.debug(f"Tools path: {tools_path}")
|
||||
@@ -230,17 +285,17 @@ def _load_local_tools():
|
||||
custom_tools_module = None
|
||||
|
||||
if custom_tools_module:
|
||||
# Reset in place (keep the same list object other modules may hold a
|
||||
# reference to) so a re-load replaces rather than appends.
|
||||
tools.clear()
|
||||
# Rebuild this app's registry from scratch so a re-load replaces
|
||||
# rather than appends.
|
||||
state.tools.clear()
|
||||
if hasattr(custom_tools_module, 'tools') and isinstance(custom_tools_module.tools, list):
|
||||
tools.extend(custom_tools_module.tools)
|
||||
state.tools.extend(custom_tools_module.tools)
|
||||
else:
|
||||
for name, obj in custom_tools_module.__dict__.items():
|
||||
if callable(obj) and not name.startswith("__"):
|
||||
tool_definition = getattr(obj, 'definition', None)
|
||||
if tool_definition:
|
||||
tools.append(tool_definition)
|
||||
state.tools.append(tool_definition)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error importing custom tools: {str(e)}. Continuing without custom tools.")
|
||||
|
||||
@@ -249,12 +304,17 @@ def build_call_app(*, load_local_tools: bool = False):
|
||||
"""Build a fresh FastAPI app with the call routes registered.
|
||||
|
||||
Heavy deps (FastAPI, the agent-invoke router) are imported here, not at
|
||||
module import time. When ``load_local_tools`` is true and the env opt-in is
|
||||
set, ``./tools.py`` is loaded into the shared ``tools`` registry.
|
||||
module import time. Each app owns its own :class:`CallAppState` (tools +
|
||||
pending stream tokens) hung off ``app.state.call_state`` so co-hosted apps
|
||||
never share tool schemas or cross-consume each other's session tokens. When
|
||||
``load_local_tools`` is true and the env opt-in is set, ``./tools.py`` is
|
||||
loaded into this app's registry.
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
state = CallAppState()
|
||||
app.state.call_state = state
|
||||
|
||||
try:
|
||||
from .agent_invoke import router as agent_invoke_router
|
||||
@@ -266,7 +326,7 @@ def build_call_app(*, load_local_tools: bool = False):
|
||||
_register_routes(app)
|
||||
|
||||
if load_local_tools:
|
||||
_load_local_tools()
|
||||
_load_local_tools_into(state)
|
||||
|
||||
return app
|
||||
|
||||
@@ -358,20 +418,38 @@ async def handle_incoming_call(request):
|
||||
if not _tokens_match(token, CALL_SERVER_TOKEN):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized")
|
||||
|
||||
# Build the outbound stream URL from server config, NEVER from
|
||||
# ``request.url.hostname`` (the client-controlled Host header). Otherwise an
|
||||
# authenticated caller could point Twilio's live media leg at an arbitrary
|
||||
# host and exfiltrate/inject call audio.
|
||||
base = _configured_public_base()
|
||||
if not base:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=(
|
||||
"PRAISONAI_CALL_PUBLIC_BASE is not configured; refusing to "
|
||||
"derive the media-stream URL from the client Host header."
|
||||
),
|
||||
)
|
||||
validation_error = _validate_public_base(base)
|
||||
if validation_error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=validation_error,
|
||||
)
|
||||
|
||||
response = VoiceResponse()
|
||||
response.say("")
|
||||
response.pause(length=1)
|
||||
# response.say("")
|
||||
host = request.url.hostname
|
||||
connect = Connect()
|
||||
|
||||
# Never embed the shared server secret in the stream URL — a query-string
|
||||
# token leaks into intermediary access logs, referrers and history. Mint a
|
||||
# one-shot, TTL-bound session token the media-stream handshake validates and
|
||||
# consumes exactly once.
|
||||
stream_url = f'wss://{host}/media-stream'
|
||||
session_token = _mint_stream_session_token()
|
||||
stream_url += f'?session={session_token}'
|
||||
# consumes exactly once, scoped to this app's state.
|
||||
session_token = request.app.state.call_state.mint_stream_token()
|
||||
stream_url = f"{base.rstrip('/')}/media-stream?session={session_token}"
|
||||
|
||||
connect.stream(url=stream_url)
|
||||
response.append(connect)
|
||||
@@ -393,9 +471,10 @@ async def handle_media_stream(websocket):
|
||||
if not CALL_SERVER_TOKEN:
|
||||
await websocket.close(code=4003, reason="CALL_SERVER_TOKEN not configured")
|
||||
return
|
||||
state = websocket.app.state.call_state
|
||||
session_token = websocket.query_params.get("session")
|
||||
header_token = websocket.headers.get("x-call-token")
|
||||
authorized = _consume_stream_session_token(session_token or "") or _tokens_match(
|
||||
authorized = state.consume_stream_token(session_token or "") or _tokens_match(
|
||||
header_token, CALL_SERVER_TOKEN
|
||||
)
|
||||
if not authorized:
|
||||
@@ -438,7 +517,7 @@ async def handle_media_stream(websocket):
|
||||
close_timeout=5,
|
||||
max_size=2 ** 20, # 1 MiB frame cap
|
||||
) as openai_ws:
|
||||
await send_session_update(openai_ws)
|
||||
await send_session_update(state, openai_ws)
|
||||
stream_sid = None
|
||||
|
||||
async def receive_from_twilio():
|
||||
@@ -477,7 +556,7 @@ async def handle_media_stream(websocket):
|
||||
print("Session updated successfully:", response)
|
||||
|
||||
if response['type'] == 'response.done':
|
||||
await handle_response_done(response, openai_ws)
|
||||
await handle_response_done(state, response, openai_ws)
|
||||
|
||||
if response['type'] == 'response.audio.delta' and response.get('delta'):
|
||||
# Audio from OpenAI
|
||||
@@ -498,15 +577,15 @@ async def handle_media_stream(websocket):
|
||||
async with _conn_lock:
|
||||
active_connections -= 1
|
||||
|
||||
async def handle_response_done(response, openai_ws):
|
||||
async def handle_response_done(state, response, openai_ws):
|
||||
"""Handle the response.done event and process any function calls."""
|
||||
print("Handling response.done:", response)
|
||||
output_items = response.get('response', {}).get('output', [])
|
||||
for item in output_items:
|
||||
if item.get('type') == 'function_call':
|
||||
await process_function_call(item, openai_ws)
|
||||
await process_function_call(state, item, openai_ws)
|
||||
|
||||
async def process_function_call(item, openai_ws):
|
||||
async def process_function_call(state, item, openai_ws):
|
||||
"""Process a function call item and send the result back to OpenAI."""
|
||||
function_name = item.get('name')
|
||||
arguments = json.loads(item.get('arguments', '{}'))
|
||||
@@ -515,7 +594,7 @@ async def process_function_call(item, openai_ws):
|
||||
print(f"Processing function call: {function_name}")
|
||||
print(f"Arguments: {arguments}")
|
||||
|
||||
result = await call_tool(function_name, arguments)
|
||||
result = await call_tool(state, function_name, arguments)
|
||||
|
||||
# Send the function call result back to OpenAI
|
||||
await openai_ws.send(json.dumps({
|
||||
@@ -532,9 +611,9 @@ async def process_function_call(item, openai_ws):
|
||||
"type": "response.create"
|
||||
}))
|
||||
|
||||
async def call_tool(function_name, arguments):
|
||||
async def call_tool(state, function_name, arguments):
|
||||
"""Call the appropriate tool function and return the result."""
|
||||
tool = next((t for t in tools if t[0]['name'] == function_name), None)
|
||||
tool = next((t for t in state.tools if t[0]['name'] == function_name), None)
|
||||
if not tool:
|
||||
return {"error": f"Function {function_name} not found"}
|
||||
|
||||
@@ -545,14 +624,13 @@ async def call_tool(function_name, arguments):
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
async def send_session_update(openai_ws):
|
||||
async def send_session_update(state, openai_ws):
|
||||
"""Send session update to OpenAI WebSocket."""
|
||||
global tools
|
||||
print(f"Formatted tools: {tools}")
|
||||
print(f"Formatted tools: {state.tools}")
|
||||
|
||||
use_tools = [
|
||||
{**tool[0], "type": "function"}
|
||||
for tool in tools
|
||||
for tool in state.tools
|
||||
if isinstance(tool, tuple) and len(tool) > 0 and isinstance(tool[0], dict)
|
||||
]
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"""
|
||||
PraisonAIDB adapter - implements the DbAdapter protocol from praisonaiagents.
|
||||
|
||||
This module provides the bridge between the core Agent's db interface
|
||||
and the wrapper's persistence layer (PersistenceOrchestrator).
|
||||
This is the single owner of the "agent lifecycle -> conversation/state/knowledge
|
||||
store" hooks for the wrapper. It is the live path core routes to via
|
||||
``MemoryConfig(db=PraisonAIDB(...))`` (see ``praisonaiagents/db/__init__.py``).
|
||||
|
||||
``PersistenceOrchestrator`` (``persistence/orchestrator.py``) is a thin,
|
||||
higher-level façade that delegates its message-write and session-end hooks to an
|
||||
instance of this adapter (built through :meth:`PraisonAIDB._from_stores`) so the
|
||||
store-write logic lives here in one place instead of being duplicated.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -87,6 +93,36 @@ class PraisonAIDB:
|
||||
# Seconds to suppress retries after an init failure before re-attempting.
|
||||
self._init_retry_cooldown: float = float(init_retry_cooldown)
|
||||
|
||||
@classmethod
|
||||
def _from_stores(
|
||||
cls,
|
||||
conversation_store: Any = None,
|
||||
state_store: Any = None,
|
||||
knowledge_store: Any = None,
|
||||
) -> "PraisonAIDB":
|
||||
"""Build an adapter around already-constructed store instances.
|
||||
|
||||
Used by :class:`~praisonai.persistence.orchestrator.PersistenceOrchestrator`
|
||||
so it can reuse this adapter's lifecycle-hook + dispatch logic without
|
||||
re-parsing URLs or re-building stores. The stores are treated as fully
|
||||
initialised, so the lazy URL-based construction path is bypassed.
|
||||
"""
|
||||
self = cls.__new__(cls)
|
||||
self._database_url = None
|
||||
self._state_url = None
|
||||
self._knowledge_url = None
|
||||
self._options = {}
|
||||
self._conversation_store = conversation_store
|
||||
self._state_store = state_store
|
||||
self._knowledge_store = knowledge_store
|
||||
self._initialized = True
|
||||
self._init_lock = threading.Lock()
|
||||
self._ainit_lock = None
|
||||
self._init_failed = None
|
||||
self._init_failed_at = 0.0
|
||||
self._init_retry_cooldown = 30.0
|
||||
return self
|
||||
|
||||
def _build_stores(self):
|
||||
"""Construct the backing stores (blocking I/O). Caller handles locking."""
|
||||
# Import persistence layer
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"""
|
||||
PersistenceOrchestrator - Central coordinator for all persistence operations.
|
||||
PersistenceOrchestrator - higher-level façade over the persistence layer.
|
||||
|
||||
Hooks into agent lifecycle to provide automatic conversation persistence,
|
||||
knowledge retrieval, and state management.
|
||||
|
||||
This class is a thin façade: the store-write side of the lifecycle hooks
|
||||
(persisting messages, updating session metadata at agent end) is delegated to
|
||||
:class:`praisonai.db.adapter.PraisonAIDB` — the single owner of that logic and
|
||||
the live path core routes to via ``MemoryConfig(db=...)``. The orchestrator adds
|
||||
its own resume-aware ``on_agent_start`` return contract, an LRU session cache,
|
||||
and the knowledge/state/context helpers on top.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -83,6 +90,10 @@ class PersistenceOrchestrator:
|
||||
self._config = None
|
||||
|
||||
self._current_session: Optional[ConversationSession] = None
|
||||
# Delegate target: the live adapter owns the store-write lifecycle hooks.
|
||||
# Built lazily from the resolved stores so import stays cheap and a
|
||||
# store-less orchestrator never constructs one.
|
||||
self._db = None
|
||||
# Bounded LRU cache: prevents unbounded memory growth in long-running
|
||||
# servers/bots where each request may carry a fresh session_id.
|
||||
self._session_cache: "OrderedDict[str, ConversationSession]" = OrderedDict()
|
||||
@@ -124,6 +135,22 @@ class PersistenceOrchestrator:
|
||||
config = PersistenceConfig.from_env()
|
||||
return cls(config=config)
|
||||
|
||||
def _adapter(self):
|
||||
"""Lazily build the delegate adapter over this orchestrator's stores.
|
||||
|
||||
The adapter (:class:`praisonai.db.adapter.PraisonAIDB`) is the single
|
||||
owner of the message-write / session-end store logic; the orchestrator
|
||||
reuses it instead of keeping a second copy.
|
||||
"""
|
||||
if self._db is None:
|
||||
from ..db.adapter import PraisonAIDB
|
||||
self._db = PraisonAIDB._from_stores(
|
||||
conversation_store=self.conversation,
|
||||
state_store=self.state,
|
||||
knowledge_store=self.knowledge,
|
||||
)
|
||||
return self._db
|
||||
|
||||
# =========================================================================
|
||||
# Thread-Safe Cache Operations
|
||||
# =========================================================================
|
||||
@@ -269,7 +296,11 @@ class PersistenceOrchestrator:
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
self._sync(self.conversation.add_message(session_id, message))
|
||||
# Delegate the store write to the single-owner adapter dispatch so the
|
||||
# sync/async-store handling is not duplicated here.
|
||||
self._adapter()._call_store(
|
||||
self.conversation, "add_message", "async_add_message", session_id, message
|
||||
)
|
||||
logger.debug(f"Persisted {role} message to session {session_id}")
|
||||
return message
|
||||
|
||||
@@ -290,12 +321,17 @@ class PersistenceOrchestrator:
|
||||
if not self.conversation:
|
||||
return
|
||||
|
||||
session = self._cache_get(session_id) or self._sync(self.conversation.get_session(session_id))
|
||||
adapter = self._adapter()
|
||||
session = self._cache_get(session_id) or adapter._call_store(
|
||||
self.conversation, "get_session", "async_get_session", session_id
|
||||
)
|
||||
if session:
|
||||
session.updated_at = time.time()
|
||||
if metadata:
|
||||
session.metadata = {**(session.metadata or {}), **metadata}
|
||||
self._sync(self.conversation.update_session(session))
|
||||
adapter._call_store(
|
||||
self.conversation, "update_session", "async_update_session", session
|
||||
)
|
||||
# Update cache with the modified session
|
||||
self._cache_put(session)
|
||||
logger.debug(f"Updated session metadata: {session_id}")
|
||||
@@ -433,11 +469,11 @@ class PersistenceOrchestrator:
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
if isinstance(self.conversation, AsyncConversationStore):
|
||||
await self.conversation.add_message(session_id, message)
|
||||
else:
|
||||
await asyncio.to_thread(self.conversation.add_message, session_id, message)
|
||||
|
||||
# Delegate the async store write to the single-owner adapter dispatch so
|
||||
# the async/sync-store branching is not duplicated here.
|
||||
await self._adapter()._dispatch_async(
|
||||
self.conversation, "add_message", "async_add_message", session_id, message
|
||||
)
|
||||
logger.debug(f"Persisted {role} message to session {session_id}")
|
||||
return message
|
||||
|
||||
@@ -458,23 +494,21 @@ class PersistenceOrchestrator:
|
||||
if not self.conversation:
|
||||
return
|
||||
|
||||
adapter = self._adapter()
|
||||
session = self._cache_get(session_id)
|
||||
if not session:
|
||||
if isinstance(self.conversation, AsyncConversationStore):
|
||||
session = await self.conversation.get_session(session_id)
|
||||
else:
|
||||
session = await asyncio.to_thread(self.conversation.get_session, session_id)
|
||||
|
||||
session = await adapter._dispatch_async(
|
||||
self.conversation, "get_session", "async_get_session", session_id
|
||||
)
|
||||
|
||||
if session:
|
||||
session.updated_at = time.time()
|
||||
if metadata:
|
||||
session.metadata = {**(session.metadata or {}), **metadata}
|
||||
|
||||
if isinstance(self.conversation, AsyncConversationStore):
|
||||
await self.conversation.update_session(session)
|
||||
else:
|
||||
await asyncio.to_thread(self.conversation.update_session, session)
|
||||
|
||||
|
||||
await adapter._dispatch_async(
|
||||
self.conversation, "update_session", "async_update_session", session
|
||||
)
|
||||
# Update cache with the modified session
|
||||
self._cache_put(session)
|
||||
logger.debug(f"Updated session metadata: {session_id}")
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.7.2"
|
||||
__version__ = "4.7.3"
|
||||
@@ -298,6 +298,41 @@ _LAZY_IMPORTS = {
|
||||
assert tool_gap['status'] == 'TODO'
|
||||
assert tool_gap['typescript'] is False
|
||||
|
||||
def test_markdown_states_it_measures_names_not_behaviour(self):
|
||||
"""Markdown must disclose it measures exported names, not behaviour.
|
||||
|
||||
Guards against the false-confidence bug where an exported-but-unreachable
|
||||
symbol (e.g. ApprovalCallback) is read as a working capability.
|
||||
"""
|
||||
from praisonai._dev.parity.generator import ParityTrackerGenerator
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
pkg_dir = Path(tmpdir) / "src" / "praisonai-agents" / "praisonaiagents"
|
||||
pkg_dir.mkdir(parents=True)
|
||||
(pkg_dir / "__init__.py").write_text('''
|
||||
_LAZY_IMPORTS = {
|
||||
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
|
||||
'Tool': ('praisonaiagents.tools.base', 'Tool'),
|
||||
}
|
||||
''')
|
||||
|
||||
ts_dir = Path(tmpdir) / "src" / "praisonai-ts" / "src"
|
||||
ts_dir.mkdir(parents=True)
|
||||
(ts_dir / "index.ts").write_text("export { Agent } from './agent';")
|
||||
|
||||
wrapper_dir = Path(tmpdir) / "src" / "praisonai" / "praisonai" / "cli" / "features"
|
||||
wrapper_dir.mkdir(parents=True)
|
||||
|
||||
generator = ParityTrackerGenerator(Path(tmpdir))
|
||||
md = generator.generate_markdown()
|
||||
|
||||
# Header discloses the methodology limitation
|
||||
assert "What this measures" in md
|
||||
assert "exported symbol name" in md
|
||||
# An exported name is labelled "exported", not "DONE"/"works"
|
||||
assert "✅ exported" in md
|
||||
assert "✅ DONE" not in md
|
||||
|
||||
def test_write_typescript_creates_file(self):
|
||||
"""Test that write_typescript creates the output file."""
|
||||
from praisonai._dev.parity.generator import ParityTrackerGenerator
|
||||
|
||||
Reference in new issue
Block a user