Compare commits

...
71 Commits
Author SHA1 Message Date
MervinPraison 0955acc46f docs: auto-update feature parity trackers [skip ci] 2026-08-26 20:47:46 +00:00
Mervin Praison b121b4e42e Merge pull request #4420 from MervinPraison/claude/issue-4386-20260826-1908
fix: unify streaming + tools loop in praisonai-ts (#4386)
2026-08-26 21:47:18 +01:00
praisonai-triage-agent[bot]andMervin Praison db2c38f151 fix: keep tool_calls paired and separate schema from tool rounds (#4386)
Two correctness fixes flagged by Greptile (P1) and CodeRabbit (Major) in the
unified streaming+tools loop:

1. Assistant/tool message pairing: when a response exceeded
   maxToolCallsPerTurn, the assistant message stored every declared tool_call
   but only the capped subset got tool result messages, causing the next
   OpenAI request to 400 on unresolved tool_call_ids. Now the assistant
   message lists only the executed subset.

2. response_format + tools mutual exclusivity: tool rounds no longer send the
   json_schema response_format (OpenAI treats them as exclusive). The schema is
   applied on a final tools-omitted request once the model stops calling tools.

Adds a behavioral test asserting the assistant tool_calls stay 1:1 with tool
results when the per-turn cap truncates.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:45:22 +00:00
praisonai-triage-agent[bot]andMervin Praison 0efedc0d35 fix: unify streaming + tools loop in praisonai-ts (fixes #4386)
Streaming and tools are no longer mutually exclusive. A streaming agent
can now call tools: text deltas, tool calls and tool results interleave
on one request per round-trip via a new OpenAIService.streamChatWithTools.

- Remove the `this.stream && !this.tools` gates; the tool loop streams
  when `stream` is set and blocks otherwise.
- Default maxIterations 5 -> 20 (Python ExecutionConfig.max_iter parity).
- Add maxToolCallsPerTurn (default 10, Python parity).
- Add Agent.lastStopReason ('completed' | 'max_steps' | 'cancelled' |
  'error') so a truncated run is distinguishable from a completed one.
- Tests call the code path: a streaming run with tools yields text AND
  executes a tool, plus exhaustion sets lastStopReason='max_steps'.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:45:05 +00:00
MervinPraison e426c993ed docs: auto-update api.md [skip ci] 2026-08-26 20:42:27 +00:00
Mervin Praison 6120b0d295 Merge pull request #4416 from MervinPraison/claude/issue-4390-20260826-1906
fix: remove module-scope side effects in praisonai-ts
2026-08-26 21:42:10 +01:00
Mervin Praison 94bad4452f Merge pull request #4414 from MervinPraison/claude/issue-4385-20260826-1904
fix: add Agent streaming API to praisonai-ts (fixes #4385)
2026-08-26 21:42:00 +01:00
praisonai-triage-agent[bot]andMervin Praison c362be1b82 fix: restore build after rebase — add tool name to approval-denial result
Resolved rebase onto main: kept main's new AI SDK backend branches
(tools + outputSchema via generateObject) and applied this PR's
stream->streamEnabled rename + emitToken sink. Added the required `name`
field to the approval-denial tool result so processToolCalls's return
type is satisfied (tsc clean; agent + llm suites green).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:40:59 +00:00
MervinPraisonandClaude Opus 5 2c731ab13c chore: praisonai 4.7.3
The version the macOS signing fix ships in. v4.7.2's two DMGs cannot be opened
on any Mac -- the bundle was never signed, so the only signature on it was the
linker's, which declares a resource seal an unsigned bundle does not have, and
macOS reports that as "damaged" rather than as untrusted.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 21:38:27 +01:00
praisonai-triage-agent[bot]andMervin Praison 449640b36b fix: stream() yields finish text for non-streaming agents
Agent.stream() ignored the finish event, so agents running with
stream:false, tools, or an outputSchema (which emit only a finish event,
never token deltas) yielded no output. Now stream() surfaces finish.text
when no deltas were emitted. Adds a regression test and completes JSDoc
@param/@returns for the streaming APIs.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:37:26 +00:00
praisonai-triage-agent[bot]andMervin Praison 28f86c8eea fix: add Agent streaming API to praisonai-ts (fixes #4385)
Add Agent.stream() yielding AsyncIterable<string> (mirrors Python's
iter_stream, which yields plain str) plus Agent.streamEvents() for
structured AgentEvent deltas/finish/error. Remove both
process.stdout.write calls in start(); tokens now route through an
internal onToken sink that defaults to stdout only for CLI use, so
non-terminal hosts (browser, webview, React Native, server) can render
answers as they arrive. Cancellation is a single path: breaking the loop
detaches the sink.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:37:26 +00:00
praisonai-triage-agent[bot]andMervin Praison e9a8266514 fix(praisonai-ts): sync pnpm-lock.yaml after removing dotenv/node-fetch
Regenerate the lockfile importer to match package.json so
`pnpm install --frozen-lockfile` (CI) no longer fails with an
outdated-lockfile error. Removes the dotenv/node-fetch direct-dependency
entries and the now-unused dotenv package/snapshot; node-fetch remains as
a transitive dependency. Addresses Greptile P1 review feedback.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:37:25 +00:00
praisonai-triage-agent[bot]andMervin Praison 36c29e172a fix: remove module-scope side effects in praisonai-ts (fixes #4390)
Importing the package no longer reads process.env or runs dotenv.config()
at module-initialisation time, so it does not throw in runtimes where
`process` is undefined (webviews/bundles).

- logger.ts: lazy, `typeof process`-guarded `level` getter
- openai.ts: drop module-scope `dotenv.config()` and the dotenv import
- package.json: remove unused `dotenv` and `node-fetch` dependencies
- add behavioral tests that call the code (guarded level read; import
  without throwing / no dotenv)

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 20:37:25 +00:00
Mervin Praison 7604e29f47 Merge pull request #4410 from MervinPraison/fix-macos-damaged-signature
fix(desktop): the macOS build shipped an invalid signature, so every Mac refused it
2026-08-26 21:36:23 +01:00
Mervin Praison c6a4d49df7 Merge pull request #4396 from MervinPraison/claude/issue-4384-20260826-1808
fix: merge duplicate _sanitize_metadata in ChromaDB memory adapters
2026-08-26 21:34:44 +01:00
praisonai-triage-agent[bot] f98a0caec1 Merge pull request #4419 from MervinPraison/claude/issue-4389-20260826-1905
fix: dual ESM+CJS build with exports map for praisonai-ts
2026-08-26 20:33:59 +00:00
praisonai-triage-agent[bot] bb93447f37 Merge pull request #4415 from MervinPraison/claude/issue-4387-20260826-1904
fix: make OpenAI SDK construct in browser-like runtimes
2026-08-26 20:21:41 +00:00
praisonai-triage-agent[bot] eb94f9cb11 Merge pull request #4413 from MervinPraison/claude/issue-4407-20260826-1901
fix: wire ApprovalManager into Agent tool path
2026-08-26 20:18:33 +00:00
praisonai-triage-agent[bot] 9b1711b4ca Merge pull request #4412 from MervinPraison/claude/issue-4405-20260826-1902
fix: wire tools through AI SDK backend on non-OpenAI providers (praisonai-ts)
2026-08-26 20:15:08 +00:00
praisonai-triage-agent[bot] 7746520df9 Merge pull request #4418 from MervinPraison/claude/issue-4409-20260826-1909
docs: record inverted TS routing — src/praisonai-ts is canonical, praisonai-js is npm mirror
2026-08-26 20:11:16 +00:00
praisonai-triage-agent[bot] 963c2adde6 Merge pull request #4411 from MervinPraison/claude/issue-4408-20260826-1901
fix: parity tracker discloses it measures exported names, not behaviour
2026-08-26 20:00:45 +00:00
praisonai-triage-agent[bot] 37c3c8ce00 Merge pull request #4402 from MervinPraison/claude/issue-4383-20260826-1812
fix: consolidate duplicate persistence coordinators
2026-08-26 19:58:58 +00:00
praisonai-triage-agent[bot] 7fb5e556e2 Merge pull request #4397 from MervinPraison/claude/issue-4391-20260826-1808
fix: document all 11 stream protocol v2 events and guard against drift
2026-08-26 19:58:55 +00:00
praisonai-triage-agent[bot]andMervin Praison ec1e67af10 fix: restore ESM subpath exports and repoint internal requires to CJS twin
Address reviewer findings on the dual ESM+CJS build:

- package.json: the restrictive exports map (only "." + "./package.json")
  broke documented deep imports (praisonai/ai, praisonai/tools,
  praisonai/integrations/*, praisonai/db/*) with ERR_PACKAGE_PATH_NOT_EXPORTED.
  Add "./*" file subpath, "./*/index" and explicit "./ai"/"./tools" dir
  entries mapping to both esm and cjs outputs.
- esm-shim.js: tsc leaves explicit relative require('./x') calls in the ESM
  output; under dist/esm (type:module) those throw ERR_REQUIRE_ESM on Node <22
  (engines: node >=18). Repoint every internal relative require at its mirrored
  CommonJS twin under dist/ (resolved from the twin dir so package.json and
  directory-index targets also resolve), so createRequire loads real CJS/JSON.
- dual-build.test.ts: always rebuild (npm run build) so tests never validate
  stale artifacts; add behavioural tests asserting no relative require resolves
  inside dist/esm and that documented deep-import subpaths load under native ESM.

npm test: 1008 passed, 87 skipped, 0 failed. verify:dist: cjs + esm OK.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 19:43:32 +00:00
praisonai-triage-agent[bot]andMervin Praison 7f968abd3b fix: guard OpenAI paths against missing process in browser runtimes
- Add getEnv() helper reading process.env only when defined, and route all
  OpenAI client apiKey lookups through it (openai.ts, providers/openai.ts) so
  browser/webview/React-Native hosts no longer throw ReferenceError.
- Guard top-level dotenv.config() behind a process check.
- Honour an explicit dangerouslyAllowBrowser:false even when the base options
  already carried the flag (strip it).
- Add JSDoc (@param/@returns/@example) to exported helpers.
- Add regression tests for getEnv and the explicit-false override.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 19:35:08 +00:00
praisonai-triage-agent[bot]andMervin Praison 5920ce9cc8 docs: add explicit --repo to Sync workflow command in AGENTS.md
Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 19:34:40 +00:00
praisonai-triage-agent[bot]andMervin Praison 1ce45a01d0 fix: make approval:true usable by default instead of stalling 5 min
The `approval: true` shorthand built a bare ApprovalManager with no handler,
so requestApproval() fell through to a respond()-awaiting Promise that nobody
resolves — blocking every tool call for the full 5-minute timeout before
denying, compounding across calls and exhausting maxIterations.

Wire the existing createCLIApprovalPrompt() as the default handler so the
shorthand prompts interactively (safe by default, hard to misuse). No new
deps or exports. Adds tests asserting a handler is wired and that denial
resolves without hitting the timeout.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 19:33:04 +00:00
praisonai-triage-agent[bot]andMervin Praison 850b8e9b75 fix: preserve tool names and honor outputSchema in AI SDK tool loop (#4405)
- processToolCalls now sets `name` on tool-role results so the AI SDK
  adapter emits a non-empty toolName; non-OpenAI providers previously
  rejected the follow-up request.
- When both tools and outputSchema are configured, the resolved final
  answer is produced via generateObject instead of dropping the schema,
  matching the OpenAI native path.
- Extend cross-provider tests: assert tool name propagation and the
  tools+outputSchema combination.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 19:29:50 +00:00
praisonai-triage-agent[bot]andMervin Praison edd142b46d test(desktop): pin the ad-hoc identity and the macOS scope of the signature gate
Two regression gaps flagged in review, both places where the release-signing
tests would keep passing after a future change that reintroduces the exact
failure they exist to catch:

- The signingIdentity assertion accepted any truthy value; only "-" is the
  ad-hoc identity that makes the shipped "damaged" bundle's signature valid.
  Any other value is a Developer ID with no keychain to satisfy it (codesign
  fails) or a typo. Pinned to "-".

- The verify-before-upload test checked command presence and ordering but not
  that the step is scoped to macOS. Flipping its `if` to skip macOS -- or
  dropping it so it runs on Linux where codesign is absent and the step is
  silently skipped -- passed every assertion. Now pins `if: runner.os == 'macOS'`
  on the codesign step.

Mutation-checked: a bogus identity and a Linux-scoped gate each fail exactly
one test; 15/15 green otherwise.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 19:17:44 +00:00
praisonai-triage-agent[bot]andMervin Praison 74ec3766e2 fix: dual ESM+CJS build with exports map for praisonai-ts (fixes #4389)
The published package was CommonJS-only with no exports map or module field,
so tsc downleveled every `await import()` into a `require()` that browser/RN
bundlers cannot resolve statically. Add a parallel ESM build plus an exports
map so dynamic import() survives as a lazy chunk.

- package.json: add `module` + `exports` (types/import/require/default);
  split build into build:cjs + build:esm; add verify:dist smoke check.
- tsconfig.esm.json: emit esnext modules + declarations to dist/esm.
- scripts/esm-shim.js: post-process dist/esm — rewrite extensionless relative
  specifiers to .js / /index.js, prepend a createRequire(import.meta.url)
  banner to files using CJS globals, write dist/esm/package.json {type:module}.
  Source files and the CJS build are untouched (backward-compatible require()).
- scripts/verify-dist.mjs: assert both entries load Agent (require + import).
- tests/unit/packaging/dual-build.test.ts: behavioural tests — assert emitted
  ESM preserves import() (not require()), specifiers are extensioned, and the
  ESM entry loads in a native Node child process and constructs an Agent.

Deferred (matches the issue; avoids green-lying): sideEffects:false,
mobile/node subpath allowlists, and a browser-platform CI gate all require the
separately-filed root-barrel split first.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 19:10:58 +00:00
praisonai-triage-agent[bot]andMervinPraison 8d93faa651 docs: record canonical TS source is src/praisonai-ts, praisonai-js is npm mirror (fixes #4409)
Co-authored-by: MervinPraison <[email protected]>
2026-08-26 19:10:56 +00:00
praisonai-triage-agent[bot]andMervin Praison 37fc11f582 fix: make OpenAI SDK construct in browser-like runtimes (fixes #4387)
The openai SDK throws in any browser-like runtime (webview, Electron
renderer, React Native, browser) unless dangerouslyAllowBrowser is
passed. praisonai-ts passed it at 0 of 7 construction sites.

Add a shared buildOpenAIClientOptions helper that auto-detects
browser-like runtimes (leaving Node behaviour unchanged) and supports
an injectable fetch so hosts can route egress through native code,
keeping the API key out of the JS heap. Wire all 7 sites through it.

Extend ProviderConfig with fetch/dangerouslyAllowBrowser and add
per-agent apiKey/baseURL to SimpleAgentConfig to mirror Python's
per-agent api_key/base_url. Includes a behavioral test that constructs
the real SDK.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 19:09:25 +00:00
praisonai-triage-agent[bot]andMervinPraison 779ac480e5 fix: wire ApprovalManager into Agent tool path (fixes #4407)
Adds an optional `approval?: boolean | ApprovalManager` to SimpleAgentConfig
and gates every tool call in Agent.processToolCalls before invocation. Denials
are fed back to the model as the tool result (course correction) instead of
aborting the run. Reuses the existing ai/tool-approval.ts machinery; binds the
decision to the tool_call id (correlation, never positional).

Co-authored-by: MervinPraison <[email protected]>
2026-08-26 19:06:34 +00:00
praisonai-triage-agent[bot]andMervinPraison 40710fb911 fix: wire tools and structured output through AI SDK backend on non-OpenAI providers (fixes #4405)
Non-OpenAI providers silently dropped tools/outputSchema and answered
without them. The AI SDK backend already supports generateText({ tools }),
streamText and generateObject; simple.ts just never passed them through.
Format tools once in a provider-agnostic shape and run the tool-call loop
through the backend, matching what litellm gives the Python SDK. Adds a
cross-provider regression test asserting tools survive on every provider.

Co-authored-by: MervinPraison <[email protected]>
2026-08-26 19:05:50 +00:00
praisonai-triage-agent[bot]andMervinPraison 36f7547d5a fix: parity tracker discloses it measures exported names, not behaviour (fixes #4408)
The parity tracker decides a capability exists by matching an exported
symbol name, so an exported-but-unreachable symbol (e.g. ApprovalCallback)
was reported as "DONE" while unusable. Rather than build a heavy
reachability/conformance subsystem, make the reports honest about what
they measure:

- Add a "What this measures / does NOT" note to generated PARITY.md
  (TypeScript and Rust): counts are exported names, include barrel
  re-exports, and are not a behaviour or reachability check.
- Relabel the markdown status from "DONE"/"TODO" to "exported"/"missing"
  so a symbol-presence check is not mistaken for "works".
- Document the same limitation in the TypeScript extractor docstring.
- Add a regression test asserting the disclaimer + relabel are present.

Co-authored-by: MervinPraison <[email protected]>
2026-08-26 19:04:36 +00:00
MervinPraisonandClaude Opus 5 55b9dd486d fix(desktop): the macOS build shipped an invalid signature, so every Mac refused it
v4.7.2's DMG fails to open on every Mac with *"PraisonAI is damaged and can't
be opened"*. This is not the ordinary unsigned-app warning, and the
instructions shipped next to it could not have worked.

Nothing signs the bundle. The only signature present is the one rustc's linker
puts on the bare executable:

    CodeDirectory flags=0x20002(adhoc,linker-signed)
    Sealed Resources=none

Its CodeDirectory declares a resource seal, and an unsigned bundle has none, so
verification fails outright:

    code has no resources but signature indicates they must be present

macOS reports an *invalid* signature as "damaged" and an *untrusted* one as
"unidentified developer". Only the second can be bypassed with right-click →
Open, which is what INSTALL.md told people to do.

`signingIdentity: "-"` makes Tauri codesign the bundle with an ad-hoc identity.
Verified against the shipped artifact: signing it turns

    code has no resources but signature indicates they must be present

into

    valid on disk
    satisfies its Designated Requirement

An ad-hoc identity is still not a Developer ID and Gatekeeper still refuses the
app on a double-click -- but it refuses it as untrusted rather than broken,
which is a door with a handle on it.

The release build now runs `codesign --verify --deep --strict` on the .app
before the artifact is attached, so this cannot ship again silently. Tests pin
the identity and the gate, including that the gate runs before the upload
rather than after.

INSTALL.md now distinguishes the two messages instead of conflating them, and
says plainly that v4.7.2 is affected.

Two notes on the tests. One assertion matched the phrase "codesign --verify"
inside the comment that explains the gate, so deleting the command changed
nothing -- comments are stripped now. And a second check for unsealed resources
was dropped rather than pinned: `--verify` already fails on the exact bundle
that shipped, so it was only a second way of saying the same thing, which is
why removing it broke no test.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 19:53:44 +01:00
praisonai-triage-agent[bot]andMervin Praison 7a930cd3a2 fix: consolidate duplicate persistence coordinators (fixes #4383)
Make PraisonAIDB the single owner of the agent lifecycle store write hooks
and have PersistenceOrchestrator delegate its store dispatch to it, removing
the duplicated sync/async store plumbing.

- db/adapter.py: add PraisonAIDB._from_stores() to build an adapter over
  already-constructed stores (no URL parsing); fix the stale docstring that
  falsely claimed it bridged to the orchestrator.
- persistence/orchestrator.py: lazily build an internal PraisonAIDB and
  delegate on_message/aon_message + aon_agent_end store dispatch to it, while
  keeping the orchestrator-only surface (ConversationMessage return contract,
  resume-aware on_agent_start, LRU session cache). Docstring updated.

Public exports, all backends, and the MemoryConfig(db=...) live path unchanged.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:53:42 +00:00
praisonai-triage-agent[bot]andMervin Praison 6beb52d807 fix: document all 11 stream protocol v2 events and guard against drift (fixes #4391)
The desktop engine's "stream protocol v2" comment listed 9 events but the
engine emits 11: approval_request (the human-in-the-loop gate) and
tool_drafting were live on the wire but absent from the spec. The start and
end payloads had also drifted.

- Rewrite the comment to list all 11 events with their real payloads.
- Add StreamProtocolVocabulary tests that parse the comment and every
  emit()/_emit_now() call site and assert the sets match both ways, so the
  vocabulary can no longer drift without failing CI.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:53:14 +00:00
praisonai-triage-agent[bot]andMervin Praison eca0aca6b1 fix: merge byte-identical _sanitize_metadata in ChromaDB memory adapters (fixes #4384)
Extract the duplicated ChromaDB metadata sanitisation into a single
module-level helper `sanitize_chroma_metadata` in memory/adapters/factories.py.
Both `Memory._sanitize_metadata` and `ChromaMemoryAdapter._sanitize_metadata`
now delegate to it. Public method names/signatures unchanged, behaviour
byte-for-byte preserved. The distinct core.py JSON-based variant is untouched.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:52:01 +00:00
MervinPraison b95d8564ba docs: auto-update documentation parity trackers [skip ci] 2026-08-26 18:46:38 +00:00
MervinPraison ed8c04f3df docs: auto-update feature parity trackers [skip ci] 2026-08-26 18:44:44 +00:00
Mervin Praison 51a87a6088 Merge pull request #4406 from MervinPraison/docs/ts-agents-issue-routing
Document TS issue routing in src/praisonai-ts/AGENTS.md
2026-08-26 19:42:24 +01:00
MervinPraisonandCursor 2d014f3522 Document TS issue routing and praisonai-js mirror sync in AGENTS.md.
Aligns src/praisonai-ts/AGENTS.md with CONTRIBUTING and GitHub Actions:
issues on PraisonAI, fixes here, monorepo → praisonai-js sync after merge.

Co-authored-by: Cursor <[email protected]>
2026-08-26 19:42:11 +01:00
Mervin Praison 0a47f1e34c Merge pull request #4404 from MervinPraison/fix/ts-monorepo-routing-cleanup
Sync to praisonai-js: monorepo → npm mirror
2026-08-26 19:40:44 +01:00
MervinPraisonandCursor 67d69d649e Add Sync to praisonai-js workflow (monorepo → npm mirror).
Canonical TS path stays src/praisonai-ts/; mirror sync direction is
monorepo to praisonai-js, not the reverse.

Co-authored-by: Cursor <[email protected]>
2026-08-26 19:40:12 +01:00
Mervin Praison fe8af71582 Merge pull request #4403 from MervinPraison/claude/issue-4377-20260826-1807
fix: scope skill auto-approval per-agent, detect routed Claude/Gemini providers, offload blocking knowledge/memory in async paths
2026-08-26 19:36:45 +01:00
Mervin Praison ccd0e6b5ae Merge pull request #4398 from MervinPraison/claude/issue-4381-20260826-1806
fix: add --image attachment to one-shot CLI run
2026-08-26 19:36:35 +01:00
Mervin Praison a3ee693ad9 Merge pull request #4401 from MervinPraison/claude/issue-4380-20260826-1806
fix: show rendered diff in approval prompt for file-editing tools
2026-08-26 19:35:26 +01:00
Mervin Praison 0d3958d13d Merge pull request #4400 from MervinPraison/claude/issue-4378-20260826-1806
fix: harden praisonai call/agent-invoke API surface (§1 llm override, §2 per-app state, §3 Twilio SSRF)
2026-08-26 19:35:06 +01:00
Mervin Praison 781bb9ce36 Merge pull request #4399 from MervinPraison/claude/issue-4386-20260826-1805
fix: make praisonai-ts tool-loop maxIterations configurable and exhaustion observable
2026-08-26 19:34:39 +01:00
praisonai-triage-agent[bot]andMervin Praison b12f7fd6d8 fix: make InMemoryAdapter thread-safe for concurrent async memory writes
Finding 3 offloads task/knowledge memory writes via asyncio.to_thread, so
parallel task callbacks can now hit the shared in-memory adapter on different
worker threads concurrently. The non-atomic _next_id increment + append +
eviction sequence could produce duplicate memory IDs or lost entries. Guard all
adapter mutations/reads with a re-entrant lock (negligible cost single-threaded).

Addresses Greptile P1 review on PR #4403.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:34:37 +00:00
Mervin Praison 451032964f Merge pull request #4395 from MervinPraison/claude/issue-4379-20260826-1806
fix: deep-merge global and project config instead of first-file-wins
2026-08-26 19:33:29 +01:00
Mervin Praison 3d353df3fa Merge pull request #4394 from MervinPraison/claude/issue-4383-20260826-1805
fix: consolidate duplicate persistence coordinators
2026-08-26 19:33:00 +01:00
Mervin Praison 515c083ea0 Merge pull request #4393 from MervinPraison/claude/issue-4391-20260826-1805
fix: document all 11 stream-protocol v2 events and guard against drift
2026-08-26 19:32:41 +01:00
Mervin Praison 8ef7fa9e71 Merge pull request #4392 from MervinPraison/claude/issue-4384-20260826-1805
fix: merge duplicate _sanitize_metadata in ChromaDB memory adapters
2026-08-26 19:32:30 +01:00
MervinPraisonandCursor 70c8665919 Fix TS routing: implement in monorepo src/praisonai-ts, not praisonai-js.
Remove praisonai-js from merge-gate FINAL prompt, Claude STEP 3-ALT, and
tsMirrorPathReasons block. Hub issues and fixes stay in the same repo.

Co-authored-by: Cursor <[email protected]>
2026-08-26 19:31:35 +01:00
praisonai-triage-agent[bot]andMervin Praison 3d5c92931e fix(desktop): make training-run liveness probe Windows-portable
The engine (windows-latest) CI job failed in test_training.py -- a file
outside this PR's stream-protocol scope -- because _alive() and one inline
assertion used os.kill(pid, 0). That POSIX liveness idiom is broken on
Windows: signal 0 maps to TerminateProcess (it kills rather than queries),
and a dead pid raises OSError(WinError 87) instead of ProcessLookupError.

Route the Windows check through OpenProcess + GetExitCodeProcess, which
only observes, and replace the os.kill-raises assertion with the same
portable _alive helper. POSIX behaviour is unchanged; no production code
touched.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:31:15 +00:00
praisonai-triage-agent[bot]andMervin Praison 886d17f808 fix: guard --image against silent-drop and comma-corruption paths
Reject --image up front on execution paths that cannot carry a per-invocation
attachment (--agent, --profile/--profile-deep, YAML file, --output actions)
instead of silently discarding it, and reject single --image values containing
a comma (which ImageHandler treats as the multi-image separator) so a valid
path/URL is never fragmented. Adds 5 CLI guard tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:31:10 +00:00
praisonai-triage-agent[bot]andMervin Praison 4f36861646 fix: align approval diff preview with edit_file/acp_edit_file semantics
- edit_file: honour replace_all (default False = first occurrence only) so
  the previewed diff matches what edit_file actually applies
- acp_edit_file: use its real whole-file contract (filepath + new_content)
  instead of requiring old_string/new_string, which never matched and fell
  back to the truncated argument summary
- add regression tests for first-only vs replace_all and ACP whole-file diff

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:29:53 +00:00
praisonai-triage-agent[bot]andMervin Praison ede4f711a3 fix: clear panel descriptor on llm override + structural media-URL validation
Address two P1 reviewer findings on PR #4400:

- agent_invoke._swap_llm: also clear _panel_descriptor (not just
  _llm_explicit) so Agent._apply_default_llm no longer early-returns for
  panel-configured agents. Previously an accepted agent_config.llm override
  silently kept running on the panel model. Mirrored in the manual fallback.
- call._validate_public_base: replace prefix-only check with urlparse-based
  validation. Reject malformed values with no host and cleartext ws:// to
  non-local hosts (which would expose live audio + the one-shot session token
  over an unencrypted transport). Localhost ws:// still permitted for dev.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:29:45 +00:00
praisonai-triage-agent[bot]andMervin Praison 2b86b20872 fix: align CLI config write target with merged precedence
_config_write_target() used _find_config_file() which only sees cwd +
global, so with a nearer ancestor config the CLI wrote a lower-precedence
global file that the ancestor overrode on reload. Use the highest-precedence
discovered file instead, and seed set_plugin_enabled() from the target file
only (not the merged view) to avoid persisting inherited global keys.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:22:34 +00:00
praisonai-triage-agent[bot]andMervin Praison 798b8280d5 fix(desktop): close drift-guard gap and Windows secret-delete flake
Strengthen the stream-protocol drift guard to also read the event names
returned by _classify_stream_item, the source of the variable forwarded
through emit(event, frame). A new classifier return value now fails the
guard instead of drifting onto the wire unseen (Greptile finding).

Skip test_a_delete_the_fallback_could_not_perform_is_not_reported_as_success
on Windows: its precondition (an unwritable store via chmod(0o500)) is
unreachable there because Windows ignores POSIX mode bits for directory
writability, matching the existing os.name == "nt" skips in this file.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:21:21 +00:00
praisonai-triage-agent[bot]andMervin Praison 58346d5318 fix: scope skill auto-approval per-agent, detect routed Claude/Gemini providers, offload blocking knowledge/memory in async paths (fixes #4377)
- Finding 1: key skill auto-approval by a unique per-instance
  _approval_scope_id instead of the display name (which defaults to
  "Agent" for every unnamed agent), preventing one agent's skill grant
  from leaking to unrelated agents in the same process.
- Finding 2: add a substring fallback in _detect_provider so routed
  models (bedrock/anthropic.*, vertex_ai/claude-*, openrouter/anthropic/*,
  vertex_ai/gemini-*) get the correct adapter and its streaming guard
  instead of falling through to the OpenAI adapter and crashing async
  streaming.
- Finding 3: offload the synchronous, network-bound knowledge.search and
  store_in_memory calls via asyncio.to_thread so they no longer block the
  shared event loop for concurrently running agents/tasks.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Mervin Praison <[email protected]>
2026-08-26 18:17:08 +00:00
praisonai-triage-agent[bot]andMervin Praison 057394940f fix: show rendered diff in approval prompt for file-editing tools (fixes #4380)
Attach a unified diff to the ApprovalRequest context for edit_file,
write_file and apply_patch so the console approval prompt renders the
actual +/- change instead of a truncated argument dump. Non-edit tools
keep the existing argument summary. The diff also travels on the request
context so non-console backends can render or attach it.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:11:45 +00:00
praisonai-triage-agent[bot]andMervin Praison 7609339532 fix: harden praisonai call/agent-invoke API surface (fixes #4378)
- call.py §3: build Twilio media-stream URL from PRAISONAI_CALL_PUBLIC_BASE
  instead of the client-controlled request.url.hostname (Host header), closing
  the SSRF/call-audio-exfiltration path when CALL_SERVER_TOKEN leaks.
- call.py §2: move module-global `tools` + `_pending_stream_sessions` into a
  per-app, thread-locked CallAppState on app.state so co-hosted/multi-tenant
  build_call_app() instances never share tool schemas or cross-consume tokens.
- agent_invoke.py §1: route agent_config {"llm": ...} overrides through the
  SDK's Agent._apply_default_llm invalidator (with a manual-fallback) so the
  cached _llm_instance/_unified_dispatcher/_llm_init_params are dropped and the
  request actually runs on the requested model.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:11:30 +00:00
praisonai-triage-agent[bot]andMervin Praison ad37918324 fix: add --image attachment to one-shot CLI run (fixes #4381)
Add a repeatable --image option to `praisonai run` and plumb it to the
existing vision path instead of the hardcoded args.image = None. The core
Agent and the wrapper's handle_direct_prompt/ImageHandler already support
image input (local paths, http(s):// URLs, MIME detection, base64, multi-
image and clear validation errors); this closes the missing CLI surface.

Image runs bypass the warm-runtime fast path and the bare-Agent "actions"
path (neither can carry an attachment) so the vision-capable path handles
them. Text-only runs are unchanged.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:11:21 +00:00
praisonai-triage-agent[bot]andMervinPraison 8fae3f8708 fix: make tool-loop maxIterations configurable and exhaustion observable (fixes #4386)
The praisonai-ts tool-call loop had a hardcoded 5-iteration cap and, on
exhaustion, logged a warning and returned finalResponse — which is ''
when tool calls were still pending. That empty string is
indistinguishable from the model saying nothing.

- Add optional maxIterations config field (default 5).
- Throw on genuine exhaustion (loop still needed another round-trip)
  so callers can observe it instead of receiving ''.

Unifying streaming with the tool loop (interleaving text deltas and
tool calls on one stream) depends on the streaming Agent API in #4385
and is intentionally deferred to that work.

Co-authored-by: MervinPraison <[email protected]>
2026-08-26 18:11:12 +00:00
praisonai-triage-agent[bot]andMervin Praison 09e52d69ca fix: deep-merge global and project config instead of first-file-wins (fixes #4379)
Config resolution previously returned the first config file found, so any
project-local .praisonai/config.* silently shadowed the user-global config,
discarding every global default. Introduce _discover_config_files() (global
first, ancestor project dirs, nearest project last) and a recursive
_deep_merge() (dicts recurse, lists/scalars replace); _load_config() folds
them lowest->highest so a project config selectively overrides globals while
unset keys fall through. A single-file fast path preserves prior behaviour.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-26 18:09:47 +00:00
praisonai-triage-agent[bot]andMervinPraison a35190b013 fix: consolidate duplicate persistence coordinators (fixes #4383)
Make PraisonAIDB the single owner of the agent-lifecycle store-write
hooks and have PersistenceOrchestrator delegate its message-write and
session-end store dispatch to an internal adapter built via the new
PraisonAIDB._from_stores(), removing the duplicated sync/async store
plumbing while preserving the orchestrator's own return contract and
LRU session cache. Fix the drifted cross-referencing docstrings in both
modules. Public exports (PersistenceOrchestrator, wrap_agent_with_
persistence, PersistentAgent, create_persistent_session) and the
MemoryConfig(db=...) live path are unchanged.

Co-authored-by: MervinPraison <[email protected]>
2026-08-26 18:09:45 +00:00
praisonai-triage-agent[bot]andMervinPraison 92514f5d33 fix: merge duplicate _sanitize_metadata in ChromaDB memory adapters (fixes #4384)
Extract the byte-identical ChromaDB metadata sanitisation logic into a
shared module-level helper `sanitize_chroma_metadata` in
memory/adapters/factories.py. Both Memory._sanitize_metadata and
ChromaMemoryAdapter._sanitize_metadata now delegate to it, preserving
their public method names and behaviour. The distinct core.py JSON-based
variant is left untouched.

Co-authored-by: MervinPraison <[email protected]>
2026-08-26 18:07:14 +00:00
praisonai-triage-agent[bot]andMervinPraison bd3130bb97 fix: document all 11 stream-protocol v2 events and guard against drift (fixes #4391)
The engine emitted eleven stream events but the comment block listed nine,
omitting approval_request (the human-in-the-loop tool gate) and tool_drafting.
Documented payloads had also drifted from what start/end actually carry.

- Update the comment to list all eleven events with their real payloads.
- Add StreamProtocolVocabulary tests that parse the comment and every emit()
  call site, failing on drift in either direction -- so the spec is now a
  checked artifact rather than a comment nothing verifies.

Co-authored-by: MervinPraison <[email protected]>
2026-08-26 18:07:11 +00:00
77 changed files with 4095 additions and 935 deletions

No files matched your search

+23 -68
View File
@@ -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`.
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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
-1
View File
@@ -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.',
+1 -21
View File
@@ -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,
+1 -1
View File
@@ -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'
});
+1 -1
View File
@@ -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.
+9 -21
View File
@@ -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
+33
View File
@@ -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
+106
View File
@@ -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})."
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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";
+7 -7
View File
@@ -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
+16 -3
View File
@@ -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
+20 -9
View File
@@ -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)
+44 -3
View File
@@ -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",
+8
View File
@@ -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 |
+16
View File
@@ -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
```
+9 -6
View File
@@ -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
View File
@@ -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>
+38 -3
View File
@@ -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": {
-12
View File
@@ -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
+161
View File
@@ -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();
+34
View File
@@ -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');
+2 -1
View File
@@ -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,
+1 -1
View File
@@ -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';
+412 -51
View File
@@ -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();
+4 -2
View File
@@ -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({
+1 -1
View File
@@ -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
+4 -2
View File
@@ -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,
+137 -10
View File
@@ -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;
};
}
+10 -6
View File
@@ -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;
}
/**
+11 -1
View File
@@ -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(');
});
});
});
+11
View File
@@ -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
+60 -3
View File
@@ -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."
+141 -63
View File
@@ -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)
]
+38 -2
View File
@@ -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
View File
@@ -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