Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude Opus 5.5 cb3c7fae87 fix: only treat CI-fix requests from ignored bots as stranded
Another write-access collaborator's request does reach Claude, so the
pending label must still block it (greptile P2).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:27:39 +00:00
MervinPraisonandClaude Opus 5.5 0402645f09 fix(ci): require write access to invoke @claude; stop App-token CI-fix requests stranding PRs
Security: claude-response accepted @claude from any non-bot GitHub user and
the vendored claude-code-action performs no actor permission check.
- Job if: comment/review triggers require author_association OWNER, MEMBER or
  COLLABORATOR (github-actions[bot] and the triage-bot label path unchanged).
- New first step, before any App/OAuth token is created, re-checks the
  requester's permission via the collaborators API and fails unless write or
  admin.

CI-fix loop: pipeline-status label sync (run by the merge gate under the App
token) posted 'CI failed on HEAD' as praisonai-triage-agent[bot]. claude.yml
ignores that account, so Claude never ran, and the claude-ci-fix-pending label
it added made every later real request skip ('ci fix pending'). 0 of 6 bot
requests since 2026-10-07 ever ran (#5800 stuck since 2026-10-13 21:16).
- Label sync no longer posts CI-fix requests; ci-failure-claude.yml (maintainer
  token) remains the only poster.
- The pending label no longer blocks when the newest request came from an
  account claude.yml ignores, so stranded PRs recover on the next scan.

Selftests: ci-failure-claude 18, pipeline-status 15, merge-gate 124.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 08:18:48 +01:00
MervinPraison c981d2335c docs: auto-update documentation parity trackers [skip ci] 2026-10-15 02:39:53 +00:00
praisonai-triage-agent[bot] 72cea2f237 Merge pull request #5832 from MervinPraison/claude/issue-5822-20261015
fix: first-class NVIDIA NIM routing (nvidia/…, NVIDIA_API_KEY, tool_choice, Nemotron 3 reasoning) and stop sending OPENAI_API_KEY to NIM
2026-10-15 02:39:19 +00:00
MervinPraisonandClaude Opus 5.5 172bec00e2 fix: drop inherited base_url/api_base when a model switch crosses the NVIDIA NIM boundary
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:27:56 +00:00
praisonai-triage-agent[bot] 620586a5a8 Merge pull request #5833 from MervinPraison/claude/issue-5826-20261015
fix: honour --metrics, output_file and non-zero exit for headless YAML runs
2026-10-15 02:26:07 +00:00
MervinPraisonandClaude Opus 5.5 3cf3423a9a fix: never carry an API key across the NVIDIA NIM boundary on per-call or failover model switches
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:54:06 +00:00
MervinPraisonandClaude Opus 5.5 082b95c785 fix: route nvidia/ model from a failover profile to NIM with an NVIDIA key
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:48:51 +00:00
MervinPraisonandClaude Opus 5.5 08bf0f08b0 fix: only track output_file writes for --trust runs that check them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:48:26 +00:00
MervinPraisonandClaude Opus 5.5 031ba5cbac fix: count CRLF output_file writes as written and keep metrics counts for overlapping runs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:43:51 +00:00
MervinPraison 5c732d6c9d docs: auto-update documentation parity trackers [skip ci] 2026-10-15 01:11:44 +00:00
praisonai-triage-agent[bot] 64aa61c20c Merge pull request #5831 from MervinPraison/claude/issue-5823-20261015
fix(ts): nvidia-nim provider works with NVIDIA_API_KEY (base URL, factory, publisher id, reasoning)
2026-10-15 01:11:11 +00:00
MervinPraison d4deb44779 docs: auto-update documentation parity trackers [skip ci] 2026-10-15 00:48:11 +00:00
praisonai-triage-agent[bot] 8982083093 Merge pull request #5834 from MervinPraison/claude/issue-5819-20261015
fix: skip per-agent git rev-parse subprocess outside git repos
2026-10-15 00:46:34 +00:00
praisonai-triage-agent[bot] 6233a87d03 Merge pull request #5829 from MervinPraison/claude/issue-5820-20261015
fix: discover .env from the working directory, not the package location
2026-10-15 00:43:10 +00:00
MervinPraisonandClaude Opus 5.5 d007e016d7 fix(ts): keep NIM import computed, map aliases under defaultProvider, accept ai@5+ tool-call fields
- nvidia-nim.ts: import the openai-compatible package via the registry name,
  so it is not bundled into the mobile webview (fixes the mobile CI failures)
- provider-map.ts: bare aliases under defaultProvider 'nvidia' map to the NIM id
- adapter.ts/backend.ts: send input/output and read input next to args/result,
  so the follow-up request after a tool call works on ai@6 (all AI SDK providers)
- tests: offline tool-call (stream and non-stream) and streaming tests for NIM

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:36:08 +00:00
MervinPraisonandClaude Opus 5.5 0662f29e54 fix: detect failed tasks behind text responses, verify output_file writes, scope metrics call counts and failed-task timing
Addresses Greptile review on #5833:
- Check each task's final status so an earlier failed task is not hidden
  when astart returns the last task's text.
- Record output_file writes by wrapping save_output_to_file and checking
  the file holds this run's output, so a stale file never passes; only
  completed tasks are checked.
- Count LLM calls per run by wrapping the collector's track_tokens (scoped
  by agent id / id-less name), not the capped recent-interactions list.
- Close outstanding task timers at run end so failed tasks report time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:30:31 +00:00
MervinPraisonandClaude Opus 5.5 b939de0660 fix: route nvidia/ model given per call or by a failover profile to NIM
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:28:20 +00:00
MervinPraisonandClaude Opus 5.5 b816ff5b93 fix: cover GIT_DIR/GIT_WORK_TREE fallback and assert RulesManager construction in #5819 tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:26:11 +00:00
MervinPraisonandClaude Opus 5.5 395c48d256 fix: skip per-agent git rev-parse subprocess outside git repos (fixes #5819)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:21:15 +00:00
MervinPraisonandClaude Opus 5.5 0499e57a59 fix: honour --metrics, task output_file and non-zero exit for headless YAML team runs (fixes #5826)
- Forward --metrics / --metrics-json to YAML runs; print a per-agent table
  (tokens, calls, wall time, TOTAL, model) or JSON from the team's existing
  token summary. Report module is lazy-imported only when a flag is set.
- Forward task output_file in the PraisonAI adapter, creating parent dirs.
- Raise when AgentTeam returns its failed status dict (and, under --trust,
  when a declared output_file was not written) so the CLI exits 1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:20:47 +00:00
MervinPraisonandClaude Opus 5.5 e93004e402 fix(ts): make the nvidia-nim provider work (fixes #5823)
- createOpenAICompatible with NIM base URL (NVIDIA_NIM_API_BASE override)
- key order: apiKey > NVIDIA_NIM_API_KEY > NVIDIA_API_KEY; placeholder for
  local NIM; never OPENAI_API_KEY
- keep the NIM publisher in the model id; nemotron-super/nano/ultra aliases
- reasoningEffort -> chat_template_kwargs for Nemotron 3 on NIM
- key per-agent credentials by canonical provider id in resolveBackend
- @ai-sdk/openai-compatible as optional peer dependency

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:20:00 +00:00
MervinPraisonandClaude Opus 5.5 662700aa60 fix: route nvidia/ models to NVIDIA NIM, use NVIDIA_API_KEY and never send OPENAI_API_KEY to NIM (fixes #5822)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:14:37 +00:00
MervinPraisonandClaude Opus 5.5 40fbc802d2 fix: discover .env from the working directory, not the package location (fixes #5820)
Bare load_dotenv() searches upward from the calling module's directory,
so a .env in a source checkout or above site-packages was loaded no
matter where the CLI ran, and the user's CWD .env could be missed. Use
find_dotenv(usecwd=True) at every library call site; override stays False.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:13:08 +00:00
MervinPraison a84e42aa95 docs: auto-update feature parity trackers [skip ci] 2026-10-14 10:41:13 +00:00
praisonai-triage-agent[bot] 944848a255 Merge pull request #5817 from MervinPraison/claude/issue-5810-20261014
fix: auto-continue answers truncated by the output-token limit
2026-10-14 10:40:31 +00:00
praisonai-triage-agent[bot] 3fdfcf7289 Merge pull request #5813 from MervinPraison/claude/issue-5812-20261014
fix: hot-reload file-based definitions in interactive and warm-runtime sessions
2026-10-14 10:34:37 +00:00
MervinPraisonandClaude Opus 5.5 40e9b8e058 test: async regression for truncated tool call not continuing finished answer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 10:29:26 +00:00
MervinPraisonandClaude Opus 5.5 4c4684701e fix: continue only when the current response is length-truncated; allow text response_format
A truncated (repaired) tool call left a sticky length_truncated stop reason,
so a later complete answer was asked to continue. Snapshot the stop reason at
each loop iteration and continue only on a fresh truncation. Also treat
response_format={'type': 'text'} as unstructured.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 10:24:07 +00:00
MervinPraisonandClaude Opus 5.5 562ec36742 fix: re-attach edited nested instructions in praisonai_bot PathContextAttacher
Port the signature/mtime cache invalidation to the praisonai_bot copy, which
run.py and the interactive core prefer on a full install.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 10:22:52 +00:00
praisonai-triage-agent[bot] f3f2a52e77 Merge pull request #5816 from MervinPraison/claude/issue-5815-20261014
fix: reconcile service-manager stop deadline with gateway drain budget
2026-10-14 09:35:16 +00:00
MervinPraison 070950d134 docs: auto-update documentation parity trackers [skip ci] 2026-10-14 09:34:36 +00:00
MervinPraisonandClaude Opus 5.5 8757047f2e fix: skip continuation for structured output, record streamed finish_reason, keep pieces on cancel
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:34:23 +00:00
praisonai-triage-agent[bot] 034e45a9ff Merge pull request #5814 from MervinPraison/claude/issue-5811-20261014
fix: compact and retry on provider context-overflow instead of hard-failing
2026-10-14 09:34:07 +00:00
MervinPraisonandClaude Opus 5.5 e08d108601 fix: resolve ${VAR} drain settings from ~/.praisonai/.env when sizing service stop deadline
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:22:38 +00:00
MervinPraisonandClaude Opus 5.5 b08b700c8d fix: keep turn task across tool-loop user msgs, avoid tool replay on native overflow restart, classify overflow by type, never grow trimmed tool output
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:21:38 +00:00
MervinPraisonandClaude Opus 5.5 112c118dab fix: auto-continue answers truncated by the output-token limit (fixes #5810)
When a final (no tool calls) response stops with finish_reason=length,
re-prompt the model to continue and stitch the pieces into one message,
bounded at 3 continuations; past the cap the existing length_truncated
outcome is surfaced. Applies to the LiteLLM (sync/async, chat + Responses)
and OpenAI-native tool loops via a tiny shared helper. Opt out with
ExecutionConfig(output_continuation=False) or set an int cap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:18:20 +00:00
MervinPraisonandClaude Opus 5.5 0284c0564a fix: size service stop deadline from gateway drain budget; KillMode=mixed (fixes #5815)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:16:52 +00:00
MervinPraison 14cb03cfe0 docs: auto-update feature parity trackers [skip ci] 2026-10-14 09:16:16 +00:00
praisonai-triage-agent[bot] 74f2f797bd Merge pull request #5807 from MervinPraison/claude/issue-5806-20261014
fix: ContextVars for hooked async tools, dead stdio MCP respawn, cancel-aware LLM backoff
2026-10-14 09:15:43 +00:00
MervinPraisonandClaude Opus 5.5 dc59f03bd6 fix: hot-reload watches workspace, all skill dirs, and edited nested instructions
- definitions_fingerprint(root): scan the session workspace (TUI --workspace)
  as well as cwd; watch core skill dirs (PRAISONAI_HOME, remote-skills,
  system) and lowercase skill.md
- PathContextAttacher: re-attach a nested AGENTS.md/CLAUDE.md after it is
  edited (stat-only check; search dirs cached so no git per touch)
- tests: workspace root, skill.md/PRAISONAI_HOME, nested edit, and warm
  session rebuild restoring history through the real session store

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:15:08 +00:00
MervinPraisonandClaude Opus 5.5 1ea27e1c45 fix: compact and retry on provider context-overflow instead of hard-failing (fixes #5811)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:04:56 +00:00
MervinPraisonandClaude Opus 5.5 a77e84127f fix: hot-reload custom commands/skills/rules/tools in interactive and warm sessions (fixes #5812)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:03:57 +00:00
praisonai-triage-agent[bot] 2cca1b5f89 Merge pull request #5809 from MervinPraison/claude/issue-5808-20261014
fix: keep Python-API tools, honour tool_timeout across retry, close async-bridge cancel race
2026-10-14 08:55:41 +00:00
MervinPraisonandClaude Opus 5.5 d52e76bd98 fix: cancel a stdio MCP runner hung in initialize() on stop() so failed replacements don't leak
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 08:52:28 +00:00
MervinPraisonandClaude Opus 5.5 677305097e fix: charge pool-setup time to tool_timeout budget; test async-bridge cancel race
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 08:44:30 +00:00
8723b68bef fix: async prefetch scope + browser doctor (#5804)
* fix(agents): prefetch parity, structured output; fix(browser): doctor extension detection

* fix: drop redundant async prefetch scope; restrict doctor extension match to chrome-extension targets

asearch_memory already injects user_id/session_id/metadata_filter, so passing
the scope from _aprefetch_memory was a no-op that broke
test_async_prefetch_uses_async_memory_search.

check_extension_loaded now only considers chrome-extension:// targets, so
unrelated pages/workers mentioning 'praisonai'/'browser agent' no longer
report PASS. Drop the redundant 'egjhiolaf' prefix and add regression tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Dhivya-Bharathy <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 09:40:46 +01:00
MervinPraisonandClaude Opus 5.5 21790e090e fix: keep Python tools, honour tool_timeout across retry, close async-bridge cancel race (fixes #5808)
- agents_generator._build_tools_dict: register plain callables and framework
  tool instances passed via tools=[...] instead of silently dropping them;
  warn on unrecognised entries.
- _wrap_with_timeout sync path: one monotonic deadline across the retry so a
  pool-recycle retry cannot double the declared tool_timeout.
- run_sync_or_offload: submit on the caller thread so a Future always exists
  to cancel when the worker join times out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 08:36:58 +00:00
MervinPraisonandClaude Opus 5.5 b07e66af0b fix: make stdio MCP restart shutdown-safe, retryable on failed start, and shared with resource/prompt helpers
Addresses Greptile review on #5807:
- shutdown() sets _closing so an in-flight restart never installs a live replacement
- a replacement that fails to start is marked dead and stays eligible for a bounded retry
- resource/prompt helpers resolve the current runner via _live_stdio_runner
- re-check _dead after enqueue so a late request is answered instead of timing out

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 07:48:10 +00:00
MervinPraisonandClaude Opus 5.5 eda4ee4ee5 fix: keep ContextVars for hooked async tools, respawn dead stdio MCP runners, make LLM recovery backoff cancel-aware (fixes #5806)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 07:33:40 +00:00
praisonai-triage-agent[bot] d7e771dd00 Merge pull request #5769 from MervinPraison/claude/issue-5768-20261011
fix: run multi-agent rooms over the live gateway via room:<name> routes
2026-10-13 15:28:25 +00:00
praisonai-triage-agent[bot] ce051babfc Merge pull request #5766 from MervinPraison/claude/issue-5765-20261011
fix: push-client/A2U task GC and sync AgentScheduler budget bypass
2026-10-13 15:28:04 +00:00
praisonai-triage-agent[bot] a3a9bac8d9 Merge pull request #5774 from MervinPraison/claude/issue-5773-20261012
fix: bridge shutdown masking errors, managed stream leak, dropped execute kwargs
2026-10-13 15:04:35 +00:00
MervinPraisonandClaude Opus 5.5 a2a880d497 fix: parse persisted room speakers by roster id so ids containing ': ' survive reload
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 14:55:12 +00:00
MervinPraisonandClaude Opus 5.5 9c85efa1b5 fix: isolate room members and restore narrowed tools after mid-turn policy reset
- Capture the per-turn tool narrowing decision locally so a session
  timeout/cancel restore cannot strand a member with filtered tools.
- Never share a mutable member instance: members without a working
  clone_for_channel() pass instead of having history/tools swapped.
- Regression tests for both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 14:53:08 +00:00
MervinPraisonandClaude Opus 5.5 4dd1a3390f fix: cancel in-flight PushClient tasks when disconnect() is cancelled mid-drain; add budget-guard and A2U task-ref tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 14:51:30 +00:00
MervinPraisonandClaude Opus 5.5 56388faa92 fix: don't start managed run if stream closed during session setup; interrupt from producer (#5773)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 14:51:17 +00:00
praisonai-triage-agent[bot] d251adab57 Merge pull request #5803 from MervinPraison/claude/issue-5802-20261013
fix: structured turn-failure error in gateway final frame
2026-10-13 14:50:57 +00:00
ae338052b9 fix(ci): stop merge-gate starvation — hold out PRs BLOCKed on unchanged HEAD, ask Claude to fix (#5805)
* fix(ci): stop merge-gate starvation — hold out PRs BLOCKed on unchanged HEAD, ask Claude to fix

Candidates are scanned oldest-first and capped at 3. #5766, #5769 and #5774
are BLOCKed for real P1s on HEADs unchanged since 2026-10-11, yet every scan
re-assessed them (170 identical Opus BLOCK verdicts), so newer ready PRs such
as #5803 were never looked at again.

- evaluatePipelineQuiescent: a PR whose newest verdict on HEAD is BLOCK is
  held out (new reason + pipeline/blocked:merge-gate label) until a commit
  lands, a check completes after the verdict, or 24h pass.
- The scan asks Claude, once per HEAD and at most 3 times per PR, to fix the
  BLOCK findings; posted with GH_TOKEN like FINAL triggers so claude.yml
  accepts it, and worded so it is neither a FINAL trigger nor verdict noise.
- Assess prompt: CI was verified from check runs by the scan;
  get_pull_request_status only returns legacy commit statuses, and pending
  smoke / main (3.11) are not blockers once test-core is green. Two verdicts
  had blocked on "no green CI" while every check run had passed.

Replay on open PRs: 5766/5769/5774 held out with a fix request; 5803 (blocked
only because smoke was still running) becomes eligible again.
Selftests: merge-gate 116, pipeline-status 15, pr-review-chain 20, others green.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(ci): trust only gate-authored BLOCK/fix-request comments, keep held-out PRs reachable

- latestMergeGateVerdictComment: ignore verdicts not posted by gate automation
  (anyone could forge MERGE_GATE_VERDICT: BLOCK and hold a PR out for 24h).
- blockFixRequestDue: count only maintainer/github-actions comments carrying
  the exact HTML-comment marker, so forged markers cannot burn the cap.
- listPrNumbersForMergeGateScan: also scan pipeline/blocked:merge-gate PRs;
  label sync swaps merge-ready for that label, which hid them from fix requests.
- Targeted dispatch path now carries head_sha/block_fix_due.
- Fix-request step re-evaluates the PR (HEAD unchanged, still blocked) before
  posting and skips with a warning when GH_TOKEN is missing instead of posting
  an inert request that consumes the per-HEAD slot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(ci): exact gate-author logins, serialize fix requests, keep fallback scan reachable

Addresses review comments posted after e65d1793:
- latestMergeGateVerdictComment trusted any login containing
  'praisonai-triage'; only praisonai-triage-agent[bot] / github-actions[bot]
  may now post a verdict that holds a PR out (CodeRabbit).
- Fix requests are posted only from scheduled scans, which share one
  non-cancelling concurrency group, so parallel runs cannot double-post
  (CodeRabbit).
- listPrNumbersForMergeGateScan returned early when only held-out PRs were
  labelled, hiding unlabelled ready PRs from the oldest-open fallback
  (Greptile).

Selftests: merge-gate 123 ok (look-alike login, both listing cases).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(ci): await the async listing selftests before exit

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-10-13 15:37:19 +01:00
MervinPraisonandClaude Opus 5.5 cb073d81d4 fix: uncapped retry_after and typed error for wedged-worker turns (#5802)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 10:02:44 +00:00
MervinPraisonandClaude Opus 5.5 35bfa39d08 fix: emit typed turn-failure error in gateway final frame (fixes #5802)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 09:56:01 +00:00
praisonai-triage-agent[bot] 15c3ee24f7 Release v4.7.14 2026-10-13 09:46:35 +00:00
praisonai-triage-agent[bot] a9a767a977 Merge pull request #5798 from MervinPraison/claude/issue-5797-20261013
fix: adapter capability for core tool timeout; lazy CLI import side effects
2026-10-13 09:35:36 +00:00
MervinPraisonandClaude Opus 5.5 feefc815ad fix(tests): drop contradictory best_available override in auto-generator default-model tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 08:50:34 +00:00
MervinPraisonandClaude Opus 5.5 3a459a2306 fix: cover capability-based timeout callers and live ps provider discovery (#5797)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 08:48:15 +00:00
MervinPraisonandClaude Opus 5.5 798d254d44 fix: adapter capability for core tool timeout; lazy CLI import side effects (fixes #5797)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 08:29:18 +00:00
praisonai-triage-agent[bot] 6dd94ca76b Merge pull request #5793 from MervinPraison/claude/issue-5790-20261012
test: cover agent_hooks persistence facade (keep, documented entry point)
2026-10-12 20:59:49 +00:00
praisonai-triage-agent[bot] 0500399517 Merge pull request #5730 from MervinPraison/claude/routing-rules-tools-connectors
docs(rules): route new third-party API connectors to PraisonAI-Tools
2026-10-12 20:25:47 +00:00
praisonai-triage-agent[bot] a59d7a7603 Merge pull request #5792 from MervinPraison/claude/issue-5791-20261012
fix: remove dead fw_validators_module helper (wrapper legacy CLI)
2026-10-12 20:01:16 +00:00
praisonai-triage-agent[bot] 7d98e6e118 Merge pull request #5777 from MervinPraison/claude/issue-5776-20261012
fix: gateway status distinguishes refused/under-scoped credential from unreachable
2026-10-12 20:00:57 +00:00
praisonai-triage-agent[bot] cec6484550 Merge pull request #5789 from MervinPraison/claude/issue-5782-20261012
docs: publish SDK behavior matrix (stream, prefetch, structured output)
2026-10-12 20:00:42 +00:00
praisonai-triage-agent[bot] 9f31235077 Merge pull request #5778 from MervinPraison/claude/issue-5775-20261012
fix: named access_groups for gateway allow/block lists
2026-10-12 18:57:48 +00:00
MervinPraison 0f9f688ad4 docs: auto-update feature parity trackers [skip ci] 2026-10-12 18:54:34 +00:00
praisonai-triage-agent[bot] dce35e458e Merge pull request #5772 from MervinPraison/claude/issue-5771-20261012
fix: context hot path, bounded growth in long-lived hosts, MCP/handoff/observer failure semantics
2026-10-12 18:53:59 +00:00
MervinPraison 5ff13fd845 docs: auto-update documentation parity trackers [skip ci] 2026-10-12 18:52:17 +00:00
praisonai-triage-agent[bot] cc7a269bca Merge pull request #5764 from MervinPraison/claude/issue-5763-20261011
fix: reset loop guard per turn, report LLM failures as error, per-turn cancel token
2026-10-12 18:51:44 +00:00
praisonai-triage-agent[bot] 04ef366453 Merge pull request #5759 from MervinPraison/claude/issue-5758-20261010
fix: redact URL-borne secrets in redact_string and MCP transport logs
2026-10-12 18:51:19 +00:00
praisonai-triage-agent[bot] fd36a9e2b5 Merge pull request #5750 from MervinPraison/claude/issue-5749-20261009
fix: keep Responses-only/dated models out of zero-config default ranking; make Core Tests hermetic
2026-10-12 18:50:54 +00:00
MervinPraisonandClaude Opus 5.5 6cb7433530 fix: apply shadow-git maintenance overrides per command so existing checkpoint stores are covered
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 18:48:35 +00:00
MervinPraisonandClaude Opus 5.5 84178891d0 docs(rules): clarify §6.1.1 decision test (keep-in-core is conjunctive, route-out is disjunctive)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 18:48:05 +00:00
17c378e5fd fix(ci): unblock auto-merge — recognise the FINAL review reply, expire the recovery debounce (#5794)
* fix(ci): unblock auto-merge — recognise the FINAL review reply, expire the recovery debounce

Nothing has auto-merged since 2026-10-08. Two defects combined into a
permanent deadlock:

1. On 2026-10-09 (0ab9f91f6a) claude.yml moved from
   anthropics/claude-code-action, which posted a "Claude finished" tracking
   comment, to the local composite action, which posts the review itself.
   isClaudeFinalReplyComment only matched "Claude finished", so every FINAL
   since then looked incomplete and every PR "stale FINAL". It now also
   accepts the review reply's mandatory "Phase 1 — AGENTS.md review" table
   and "Final architecture review" title, still only from automation logins.

2. shouldSkipStaleFinalRecovery skipped re-posting the FINAL when the head was
   pushed within 15 min of the FINAL trigger. That relation never changes, so
   the skip was permanent. It now holds only while the push is settling
   (30 min), after which recovery re-posts the FINAL.

The stale/missing-FINAL sweep moves from 4x/day to hourly; at push time the
debounce correctly holds back, and the sweep is what re-posts once the push
has settled. Replaying the fixed logic on the 18 open PRs: FINAL current on
HEAD goes from 2/18 to 13/18, and the 5 genuinely stale ones now get a
re-posted FINAL instead of a permanent skip. merge-gate-selftest: 101 ok.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix: FINAL trigger is not a completion; stale-FINAL recovery can re-post

- isClaudeFinalReplyComment ignores @claude requests: the FINAL trigger
  embeds the Phase 1 heading and is posted by an automation login, so it
  counted as its own completion (CodeRabbit).
- claudeFinalReady accepts staleRecovery to bypass the already-triggered
  check; both stale-recovery call sites pass it, otherwise recovery never
  posted (Greptile).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-10-12 19:46:35 +01:00
MervinPraisonandClaude Opus 5.5 bd9df86dd4 fix: assert history restored before chat and session end on exit in agent_hooks tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:21:16 +00:00
MervinPraisonandClaude Opus 5.5 10dfa7f2d7 test: cover agent_hooks persistence facade (fixes #5790)
The orchestrator-based entry points (wrap_agent_with_persistence,
PersistentAgent, create_persistent_session) are documented in
PraisonAIDocs as a supported alternative to MemoryConfig(db=...), so
keep them and add an end-to-end SQLite test to guard against drift.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:16:24 +00:00
MervinPraisonandClaude Opus 5.5 41025e7b14 fix: remove dead fw_validators_module helper from legacy framework_run (fixes #5791)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:15:40 +00:00
MervinPraisonandClaude Opus 5.5 73dbfcf16c fix: correct behavior-matrix test selectors, chat() error semantics, AG-UI test coverage note
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 10:53:48 +00:00
MervinPraisonandClaude Opus 5.5 74ee5dc79a docs: add SDK behavior matrix for stream, prefetch, structured output (fixes #5782)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 10:45:37 +00:00
MervinPraisonandClaude Opus 5.5 52c0fdb1e4 fix: stop shadow-git background maintenance racing checkpoint delete_all
Each commit in the shadow repo spawned a detached 'git maintenance run
--auto' that holds maintenance.lock in the checkpoint dir, so rmtree in
delete_all() intermittently failed with ENOENT (flaky
test_service_delete_all on CI). _maybe_gc already drives housekeeping,
so disable maintenance.auto and run gc in the foreground.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 09:47:31 +00:00
Mervin Praison b6b01d2fa3 Merge branch 'main' into claude/routing-rules-tools-connectors 2026-10-12 10:32:51 +01:00
MervinPraisonandClaude Opus 5.5 72b5e334d6 fix: split env-backed group lists; lazy-import expand_access_groups only when groups are used
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 09:30:35 +00:00
MervinPraisonandClaude Opus 5.5 bb0e96ad3c fix: gateway status keeps access unknown when /info gives no auth decision
Only 401/403 is reported as a refused credential; timeouts and 404/5xx now
yield authorized: null instead of blaming the token. Adds urlopen-level
tests for the HTTPError/URLError branches and loopback-only bearer token.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 09:30:04 +00:00
MervinPraisonandClaude Opus 5.5 5e252a50b1 fix: named access_groups for gateway allow/block lists (fixes #5775)
Add a pure, cycle-safe expand_access_groups() helper to core admission
and expand group:<name> references against top-level access_groups in
GatewayServer.load_gateway_config (the single load point for start and
hot-reload). Unknown groups and cycles fail closed at config load.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 09:23:08 +00:00
MervinPraisonandClaude Opus 5.5 e9e611033f fix: gateway status distinguishes refused/under-scoped credential from unreachable (fixes #5776)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 09:22:58 +00:00
MervinPraison e53e237740 docs: auto-update documentation parity trackers [skip ci] 2026-10-12 09:20:58 +00:00
e3a5b885b5 fix: finalizers never block; bound per-context approval cache; deflake FileLock test (#5762)
* fix: finalizers never block; bound the per-context approval cache; deflake FileLock test

Follow-ups found while validating #5757:

- Agent.__del__ reached the registry via get_approval_registry(), which
  takes a non-reentrant module lock. The finalizer now reads the singleton
  from sys.modules without any lock or import (no registry means no grants).
- mark_approved's copy-on-write cache let agents that were never close()d
  leave keys in a long-lived context: 4,000 such agents left 20,000 keys and
  made each mark_approved ~300 us. The cache is now an insertion-ordered
  {key: scope} map capped at 1,024 entries; release_scope records dead scope
  ids in a lock-free deque and the next ordinary write prunes their keys.
  Same workload: 345 keys, ~7 us.
- MCP.__del__ called shutdown(), which blocks on the class-level
  non-reentrant _active_server_names_lock and joins the stdio runner for up
  to the operation timeout (60 s). The finalizer path now only signals the
  runner and releases the server names without blocking, deferring to the
  next lock holder when the lock is busy. Explicit shutdown() is unchanged.
- test_live_lock_survives_beyond_timeout sampled one instant's mtime age
  against a 0.1 s budget and failed when CI descheduled the heartbeat
  thread. It now asserts the heartbeat advanced the mtime and that a polling
  waiter sees the lock live; it still fails with the heartbeat disabled.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix: MCP finalizer no longer waits on WebSocket close; keep full scope id for legacy approval keys

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(mcp): WebSocket close() schedules cleanup on the session's own loop

get_event_loop() is thread-local, so close() (and __del__) on a thread other
than the one that connected looked up a different, idle loop; the scheduled
aclose() never ran and the session and socket stayed open. Remember the loop
in _initialize() and use it in close(); if the client never connected there
is nothing to close and no loop is created.

Addresses CodeRabbit's review comment on #5762.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(mcp): finalizer WebSocket test sets the session loop again

After close() switched to the session's own loop, this test no longer set
_loop, so close() took the never-connected branch and the test checked
nothing. It now sets _loop to an idle loop (a waiting close() would hang),
asserts __del__ returns promptly, and asserts aclose() was still scheduled.
It fails if close() is forced to wait.

Addresses Greptile's review comment on #5762.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-10-12 10:20:11 +01:00
MervinPraisonandClaude Opus 5.5 8bca17334d fix: interrupt managed runs on stream close and execute timeout (review feedback, #5773)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 08:40:41 +00:00
MervinPraisonandClaude Opus 5.5 87f73edcc3 fix: bridge shutdown masking errors, managed stream leak, dropped execute kwargs (fixes #5773)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 08:33:47 +00:00
MervinPraisonandClaude Opus 5.5 0c2123fc22 fix: address #5772 review — keep in-memory SQLite alive, no hang on evicted completions, store fallback for get_result, bound token/compression caches
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 07:56:13 +00:00
MervinPraisonandClaude Opus 5.5 6153d9b63c fix: context hot path, bounded growth for long-lived hosts, MCP/handoff/observer failure semantics (fixes #5771)
- optimizer: call compress() with valid kwargs via run_coroutine_from_any_context; no bare except
- tokens: negative-cache tiktoken load failures, C-speed heuristic, memoise accurate counts
- compactor: tokeniser probe reports unavailable when encoder load failed
- job_manager: opportunistic eviction of old terminal jobs in start_job
- AgentTeam: bound _completion_events with a deque
- memory: reclaim SQLite connections owned by dead threads
- async_bridge: daemon worker thread so timed-out calls don't block exit
- mcp: skip queued calls the caller already timed out on
- handoff: return structured {error, handoff_failed} on failure
- middleware: observers never raise into the run; async errors are logged

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 07:30:45 +00:00
MervinPraisonandClaude Opus 5.5 4201ae9cfe fix: harden gateway rooms (persisted speakers, private members, tool policy, media/cancel, reload, validation)
Addresses review on #5769:
- persist member turns as role/content '<id>: <text>' so real session
  stores keep speakers; feed compaction summaries as context
- clone members (clone_for_channel) so shared agents are never mutated
- expose member tools via GatewayRoom.tools so route tool_policy applies
- forward attachments/cancel_token; stop between turns when cancelled
- human/summary speaker ids can't collide with roster ids
- re-register room:<name> after agent reloads and rollback
- validate rooms.<name> with RoomConfig at config load

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 10:07:45 +00:00
MervinPraisonandClaude Opus 5.5 878f654d2b fix: run multi-agent rooms over the live gateway via room:<name> routes (fixes #5768)
Adds GatewayRoom, an agent-shaped adapter over core praisonaiagents.rooms.Room,
so a rostered room binds exactly where a single agent does (routes, Bot/BotOS
agent=). It reuses the session manager for the durable per-chat transcript
(chat_history), per-room serialisation and the existing delivery path.
Gateway YAML gains a top-level rooms: block registered as room:<name>.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 09:55:30 +00:00
MervinPraisonandClaude Opus 5.5 ab3416547a fix: bound PushClient disconnect drain and skip self-await from callbacks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 08:34:20 +00:00
MervinPraisonandClaude Opus 5.5 10425a0a79 fix: retain push/A2U background task refs and enforce sync scheduler budget guard (fixes #5765)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 08:28:41 +00:00
MervinPraisonandClaude Opus 5.5 91f80d9fe7 fix: don't reset shared loop guard while another turn is in flight
Addresses review on #5764: an overlapping chat/achat turn no longer clears
an in-flight turn's loop-guard counts/clock. Adds worker-thread achat test
for per-turn cancel tokens.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 07:46:44 +00:00
MervinPraisonandClaude Opus 5.5 061150bc1c fix: reset loop guard per turn, report LLM failures as "error", per-turn cancel token (fixes #5763)
- chat/achat now reset the agent's loop guard at turn start, so per-turn
  time/count/no-progress limits no longer accumulate for the agent's lifetime
  (a long-lived agent no longer hard-HALTs 120s after its first tool call).
- Terminal LLM failures set last_stop_reason = "error" (the already-documented
  value) instead of leaving "completed"; the reason is reset at turn start.
- The effective per-turn cancel token is also held in a ContextVar so
  concurrent turns' tools/approvals observe their own token.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 07:28:37 +00:00
MervinPraison eb72a3f634 docs: auto-update feature parity trackers [skip ci] 2026-10-10 15:15:57 +00:00
fcbed20249 fix(approval): stop agents-core CI segfault/hang; copy-on-write approval cache (#5757)
* ci(diag): thread-method timeouts and faulthandler dumps in test-core

Temporary diagnostic to identify the agents-core hang. Will be replaced by
the real fix before merge.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(approval): copy-on-write approval cache; never write a ContextVar from Agent.__del__

mark_approved added to the set held by the _approved_context ContextVar in
place. copy_context() copies the mapping, not the set, so an approval granted
in a child context (worker thread, task) leaked into the parent and siblings.

Agent.__del__ called release_scope, which rewrote that ContextVar from inside
the garbage collector: in whichever thread and context GC fired, possibly in
the middle of another ContextVar update. CI xdist workers segfaulted at
ContextVar operations (mark_approved, tool_progress_channel,
with_injection_context, release_scope) and the agents-core shard then hung
until the 45-minute timeout on most PRs and on main.

The finalizer now releases only the process-global grants; explicit
close()/aclose() still evict the caller's context cache.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Revert "ci(diag): thread-method timeouts and faulthandler dumps in test-core"

The diagnostic identified the crash; the workflow is back to its original form.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(approval): never block on _scope_lock from the Agent GC finalizer

release_scope(from_finalizer=True) acquires the non-reentrant lock
non-blockingly; if it is held (possibly by the frame the collector
interrupted) the scope id is queued and drained by the next release_scope.
Replaces the evict_context_cache flag added earlier in this PR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-10-10 16:15:23 +01:00
MervinPraisonandClaude Opus 5.5 578129aca3 fix: mask URL userinfo through the last @ so passwords containing @ are fully redacted
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 09:24:39 +00:00
MervinPraisonandClaude Opus 5.5 7727a4891d fix: redact URL-borne secrets (userinfo, bot-token paths) in redact_string and MCP logs (fixes #5758)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 09:19:01 +00:00
Mervin PraisonandCursor f8bf09210d fix(ci): set Claude backup Sonnet alias to claude-sonnet-5-5 (#5752)
Primary GitHub Claude jobs stay on Opus 5.5; ANTHROPIC_DEFAULT_SONNET_MODEL
now resolves the sonnet alias and automatic fallbacks to Sonnet 5.5.

Co-authored-by: Cursor <[email protected]>
2026-10-09 13:47:12 +01:00
MervinPraison beb6888368 docs: auto-update documentation parity trackers [skip ci] 2026-10-09 12:27:45 +00:00
Mervin Praison a2b82a54af Merge branch 'main' into claude/routing-rules-tools-connectors 2026-10-09 13:27:29 +01:00
1278f9bb35 fix(agents): output_pydantic coercion, chat(stream=True) fallback, memory prefetch, AgentTeam tasks guard (#5742)
* fix(agents): structured output, stream fallback, memory prefetch, AgentTeam tasks

* fix: address review feedback on structured output, stream fallback, prefetch, tasks guard

- Prefer guardrail-approved text over message.parsed when the guardrail changed it
- Serialize parsed dict/list as JSON; extract only the first complete embedded JSON object
- Gate both streaming fallbacks with is_replay_unsafe_chain on tool turns (#3860)
- Apply prefetch query fallback in async _aprefetch_memory; cap fallback searches at 5
- Validate AgentTeam.tasks container before the handler-only scan

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Dhivya-Bharathy <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-09 13:27:12 +01:00
Mervin Praison 7b5af34cb7 fix(tests): align wrapper default-model tests with capability-ranked OpenAI default (#5749)
Fixes #5749
2026-10-09 12:54:24 +01:00
MervinPraisonandClaude Opus 5.5 ad2d32645e fix: never fall back to non-chat-mode models in default ranking
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 11:39:08 +00:00
MervinPraisonandClaude Opus 5.5 ffcb59ea31 fix: keep Responses-only and dated-snapshot models out of zero-config default ranking (fixes #5749)
- catalogue: record litellm `mode` on ModelInfo and exclude non-chat modes
  (e.g. Responses-API-only gpt-5-pro) from the chat-default ranking
- catalogue: actually de-prioritise dated snapshot ids (-YYYY-MM-DD) as the
  ranking docstring already promised
- tests: pin the fixed representative in the four wrapper unit tests so they
  no longer depend on the installed litellm catalogue; add ranking regressions

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 11:32:41 +00:00
MervinPraison 72056baac6 docs: auto-update feature parity trackers [skip ci] 2026-10-09 10:59:00 +00:00
Mervin Praison 1ee362e623 Merge pull request #5748 from MervinPraison/claude/issue-5747-20261009
fix: gateway start --safe-mode / --pure to boot core-only for incident triage
2026-10-09 11:58:31 +01:00
Mervin Praison 38a368ef49 Merge pull request #5734 from MervinPraison/claude/issue-5733-20261008-1715
fix: remove dead fully-shadowed SearchMixin in memory/search.py
2026-10-09 11:58:27 +01:00
Mervin Praison a372f85a04 Merge pull request #5739 from MervinPraison/claude/issue-5737-20261009-0426
fix: Agent.chat(stream=True) returns text instead of None on sync OpenAI adapter
2026-10-09 11:58:23 +01:00
Mervin Praison c977c3f779 Merge pull request #5738 from MervinPraison/claude/issue-5736-20261009-0426
fix: reject non-Task tasks in AgentTeam with TypeError
2026-10-09 11:58:19 +01:00
MervinPraisonandClaude Opus 5.5 364dfacd90 fix: stream filter test asserts every dispatch point is guarded, not a fixed count
Core added a third (follow-up turn) dispatch point in get_response_stream,
which is correctly filtered; the hard-coded == 2 went stale on main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 10:07:49 +00:00
MervinPraisonandClaude Opus 5.5 1896613497 fix: persist gateway safe mode so direct restart keeps it (#5747)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 10:04:11 +00:00
MervinPraisonandClaude Opus 5.5 58b42c4efc fix: add gateway start --safe-mode/--pure to boot without plugins or inbound hooks (fixes #5747)
Reuses the existing PRAISONAI_NO_PLUGINS / --pure / --no-plugins convention
instead of a new config knob: the flag sets the env var for the gateway
process (core PluginManager already honours it) and the gateway skips
YAML inbound hooks with a loud SAFE MODE warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:56:58 +00:00
Mervin Praison 7da77c4fc3 Merge pull request #5745 from MervinPraison/fix/claude-code-cli-2.1.280
fix(ci): Claude Code CLI >=2.1.280 for Opus 5.5
2026-10-09 10:44:17 +01:00
MervinPraisonandCursor 0ab9f91f6a fix(ci): install Claude Code >=2.1.280 for Opus 5.5
Replace anthropics/claude-code-action@beta (bundled 1.0.88) with the repo composite action and npm @latest with a minimum version gate.

Co-authored-by: Cursor <[email protected]>
2026-10-09 10:44:00 +01:00
Mervin Praison 6ce06fbcc9 Merge pull request #5740 from MervinPraison/fix/pipeline-wrong-repo-vendor-tool-labels
chore(ci): Claude GitHub jobs use claude-opus-5-5
2026-10-09 08:00:06 +01:00
MervinPraisonandCursor a9fa800c6c chore(ci): default GitHub Claude jobs to claude-opus-5-5
Co-authored-by: Cursor <[email protected]>
2026-10-09 07:59:47 +01:00
praisonai-triage-agent[bot]andMervin Praison da6c529392 test: use monkeypatch.setenv for OPENAI_API_KEY to avoid env leak
Addresses Greptile finding: the public Agent.chat(stream=True) test set a
fake OPENAI_API_KEY via os.environ.setdefault, which outlived the test and
could influence later tests' provider/default-model selection in the same
process. Switching to monkeypatch.setenv lets pytest restore the original
environment automatically.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-09 04:48:48 +00:00
praisonai-triage-agent[bot]andMervin Praison a44f44f683 test: add public Agent.chat(stream=True) fallback test exercising real return path
Addresses greptile finding on PR #5739: existing unit tests mocked
_chat_completion_with_retry to return a bare string, so chat()'s real
response.choices[0].message.content extraction was never exercised. New
test builds a real Agent, mocks only _execute_unified_chat_completion to
raise the sync-adapter ValueError on streaming and return a realistic
completion object on fallback, asserting the public return is the text
(never None).

Co-authored-by: Mervin Praison <[email protected]>
2026-10-09 04:38:32 +00:00
praisonai-triage-agent[bot]andMervin Praison a8dbb8ed41 test: add AgentTeam task validation regression tests
Covers str/bytes/non-sequence TypeError, non-Task element index reporting,
empty-list ValueError, tuple-of-Task acceptance, and tasks=None auto-generate.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-09 04:34:32 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy a0deee6f05 fix: Agent.chat(stream=True) falls back to non-streaming instead of returning None (fixes #5737)
When chat(stream=True) is explicitly requested against the sync OpenAI
adapter, the adapter raises ValueError('Streaming is not supported in sync
OpenAIAdapter'). That error bubbled up to chat()'s outer handler, which
logged an error and returned None, so callers iterating the result got no
tokens and no typed error.

_chat_completion now catches the streaming-unsupported ValueError on the
explicit stream=True path and transparently retries non-streaming, mirroring
the existing stream=None auto-detect behaviour. Unrelated ValueErrors still
surface. Adds a unit test driving _chat_completion with a mocked adapter.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-10-09 04:31:52 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy d9b33cff15 fix: reject non-Task tasks in AgentTeam with TypeError (fixes #5736)
AgentTeam previously iterated the tasks argument without a type check, so a
string value was walked character-by-character and crashed with a cryptic
AttributeError on task.id. Validate that tasks is a sequence of Task
instances and raise a clear TypeError for str/bytes/non-sequence inputs and
non-Task elements.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-10-09 04:28:34 +00:00
Mervin Praison bd34ec7426 Merge pull request #5735 from MervinPraison/fix/pipeline-wrong-repo-vendor-tool-labels
fix(pipeline): wrong-repo labels for core vendor tools
2026-10-08 18:28:36 +01:00
praisonai-triage-agent[bot]andMervin Praison bafd4404ec docs(rules): condense §6.1.1 tool routing — agent-focused
Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 17:28:23 +00:00
MervinPraisonandCursor e41bb37943 fix(pipeline): surface wrong-repo blocks for core vendor tools
Add pipeline/core-vendor-tool tag and pipeline/blocked:wrong-repo blocker (mirrors external-example pattern) so PRs like #5661 show explicit PraisonAI-Tools routing in the label list.

Co-authored-by: Cursor <[email protected]>
2026-10-08 18:28:02 +01:00
MervinPraison ddc970b976 docs: auto-update documentation parity trackers [skip ci] 2026-10-08 17:26:54 +00:00
Mervin Praison 3b06ce9409 Merge pull request #5732 from D41910/fix/array-url-scheme-guard
fix(memory): raise on unsupported URL scheme in array form instead of silently using the file backend
2026-10-08 18:26:07 +01:00
Mervin Praison 520a37192d Merge pull request #5731 from D41910/fix/array-preset-normalisation
fix: normalise preset name lookup for array-form parameters
2026-10-08 18:25:45 +01:00
praisonai-triage-agent[bot]andMervin Praison eb1cb9b7fd fix: remove dead fully-shadowed SearchMixin in memory/search.py (fixes #5733)
Both public methods (search_short_term, search_long_term) are re-defined
in the Memory class body and win MRO, so SearchMixin and its 12 private
helpers were unreachable dead code. Drops the import, base-class entry,
and deletes memory/search.py. Behaviour is unchanged.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 17:19:57 +00:00
D41910 9008445f24 fix(memory): raise on unsupported URL scheme in array form
memory=["redis://..."] silently resolved to MemoryConfig(), i.e. the local
file backend, while memory="redis://..." raised "Unsupported URL scheme".
Mirror the string branch's guard in the SINGLE_OR_LIST array branch.
2026-10-09 00:28:59 +08:00
D41910 a7da732f57 fix(memory): raise on unsupported URL scheme in array form
memory=["redis://..."] silently resolved to MemoryConfig(), i.e. the local
file backend, while memory="redis://..." raised "Unsupported URL scheme".
Mirror the string branch's guard in the SINGLE_OR_LIST array branch.
2026-10-09 00:28:51 +08:00
praisonai-triage-agent[bot]andMervin Praison c55332088f test: assert array preset_override rejects unknown presets
Addresses CodeRabbit review: the array tests only covered valid
presets; add a negative case so a future regression that silently
accepts an unknown array preset is caught.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 15:56:28 +00:00
D41910 2070495156 fix: normalise preset lookup for array-form params 2026-10-08 23:50:04 +08:00
D41910 1fa6dae4a0 fix: normalise preset lookup for array-form params 2026-10-08 23:49:28 +08:00
praisonai-triage-agent[bot]andMervin Praison b18ff4c5d5 docs(rules): route new third-party API connectors to PraisonAI-Tools
Close the routing gap that let FXMacroData (PR #5661) be approved into
praisonaiagents/tools/ on "same shape as Tavily/Exa/You.com" precedent.

- AGENTS.md §6.1.1: new third-party API/SaaS/DB connectors MUST go to
  PraisonAI-Tools; existing in-repo connectors are legacy precedent, not a
  licence to add more. Adds an explicit keep-in-core vs route-out decision test.
- merge-gate.js FINAL review body rule 6c: a new *_tools.py that calls a remote
  service or needs service-specific credentials fails Repo routing; matching an
  existing connector pattern must FAIL the check, not pass it.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 15:09:36 +00:00
Mervin Praison 24132f068f Merge pull request #5729 from MervinPraison/fix/claude-vendor-tool-routing-rules
fix(ci): block vendor tools in core; tighten Claude FINAL routing
2026-10-08 15:59:22 +01:00
MervinPraisonandCursor 1fda108cd8 fix(ci): block new vendor tools in core and tighten Claude FINAL routing
FINAL/merge-gate prompts and AGENTS.md now treat legacy bundled *_tools.py as non-precedent; gate blocks added praisonaiagents/tools/*_tools.py and stale wrong Approve verdicts (e.g. #5661).

Co-authored-by: Cursor <[email protected]>
2026-10-08 15:59:04 +01:00
MervinPraison 44a036a36e docs: auto-update feature parity trackers [skip ci] 2026-10-08 14:04:57 +00:00
MervinPraison a87a9d0644 docs: auto-update api.md [skip ci] 2026-10-08 14:03:01 +00:00
Mervin Praison 59bc415668 Merge pull request #5354 from dajiaohuang/repostew/followup-4460
fix(desktop): restore window on macOS reopen
2026-10-08 14:58:39 +01:00
Mervin Praison 2feb4c62b1 Merge pull request #5602 from MervinPraison/claude/issue-5599-20261002-0831
fix: accept retrieval_config kwarg on Agent.__init__
2026-10-08 14:57:58 +01:00
Mervin Praison a1e46cd56d Merge pull request #5629 from MervinPraison/claude/issue-5622-20261002-1137
feat(cli): add praisonai eval last-trace to grade last traced run
2026-10-08 14:57:41 +01:00
Mervin Praison 3196f4890b Merge pull request #5678 from MervinPraison/claude/issue-5662-20261007-0940
fix: serialize studio project readers against concurrent replacement
2026-10-08 14:27:58 +01:00
Mervin Praison 8c0486fd8a Merge pull request #5256 from MervinPraison/claude/issue-5254-20260923-1711
refactor: adopt create_lazy_getattr helper in thinking/background/output __init__
2026-10-08 14:25:23 +01:00
Mervin Praison b10cc2b706 Merge pull request #5292 from MervinPraison/claude/issue-5273-20260924-1028
test: guard ConsoleBackend root export in praisonai-ts
2026-10-08 14:25:08 +01:00
Mervin Praison dbe725250c Merge pull request #5302 from MervinPraison/claude/issue-5298-20260924-1052
fix: export collapse in praisonai-ts (#5298)
2026-10-08 14:24:48 +01:00
Mervin Praison 4c7cccce23 Merge pull request #5365 from MervinPraison/claude/issue-5361-20260929-0941
fix: remove stale praisonai_task import from langflow test
2026-10-08 14:24:10 +01:00
Mervin Praison f8c363bcce Merge pull request #5649 from pei711/fix/ts-file-memory-startup-compaction
fix(ts): finish FileMemory initialization before compaction
2026-10-08 14:23:46 +01:00
MervinPraisonandCursor 063c9d473b merge main into claude/issue-5298 (resolve parity doc conflicts)
Keep main parity tracker/docs; retain TS export collapse fix from branch.

Co-authored-by: Cursor <[email protected]>
2026-10-08 14:03:13 +01:00
praisonai-triage-agent[bot]andMervin Praison 1b76638878 test: strengthen studio reader-race test to exercise real os.replace window
Addresses greptile review: the race test now (1) pauses a real _save_project
at os.replace (the exact WinError-32 window) instead of a dummy lock hold, and
(2) coordinates the reader with events keyed to the replacement window rather
than a fixed sleep. Verified it fails when either the reader lock or the write
lock is removed, and passes with both — closing the coverage gap.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 14:02:16 +01:00
praisonai-triage-agent[bot]anddajiaohuang 96845bb603 fix: serialize studio project readers against concurrent replacement (fixes #5662)
Writers (_append_assets, set_timeline) held _project_lock while readers
(_load_project, list_projects, get_project, asset_file, export_timeline)
and create_project ran outside it. On Windows os.replace in _save_project
could collide with a concurrent unlocked read of project.json, raising
PermissionError and silently dropping assets.

Make _project_lock reentrant and acquire it inside _load_project and
_save_project so every project.json read/write is serialized, while the
existing read-modify-write in _append_assets/set_timeline stays atomic
via the RLock. Adds a regression test modeling the Windows PermissionError
for a concurrent unlocked reader.

Co-authored-by: dajiaohuang <[email protected]>
2026-10-08 14:02:16 +01:00
praisonai-triage-agent[bot]andMervin Praison 4b4e757be3 fix(eval): correct last-trace tool-call counting and default gate
- Count one tool call per tool_start (not start+end), preserving repeated
  calls to the same tool instead of deduping by name (greptile #2).
- Default --min-tool-calls to 1 so a readable trace with no tool calls no
  longer passes with score 1.0; 0 still disables the gate (greptile #3).
- Use a unique per-write temp file in the last-run pointer atomic write to
  avoid concurrent writers clobbering a shared .tmp (greptile #6).

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

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 14:02:13 +01:00
praisonai-triage-agent[bot]andDhivya-Bharathy a66fbcb645 feat(cli): add praisonai eval last-trace to grade last traced run (fixes #5622)
Add a lightweight last-run trace pointer (stdlib-only, atomic write) in the
core SDK and an `eval last-trace` CLI subcommand that loads the pointer and
scores the run via the existing HarnessEvaluator. Exits 0 on pass, 1 on
fail/malformed trace, 2 when no completed traced run is found.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-10-08 14:02:13 +01:00
praisonai-triage-agent[bot]andMervin Praison 48050ea27d fix: handle RetrievalConfig instance in local-model embedding setup
A RetrievalConfig instance passed via retrieval_config= reached the
local-model embedding path which assumed a dict and called .get()/
.setdefault(), raising AttributeError during Agent construction.
Read/write embedder_config from either a dict or a RetrievalConfig
dataclass instance. Adds a regression test (greptile P1, #5599).

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 14:02:10 +01:00
praisonai-triage-agent[bot]andDhivya-Bharathy befc8da66c fix: accept retrieval_config kwarg on Agent.__init__ (fixes #5599)
retrieval_config= was promised by the docstring/docs and live tests but
only existed as a value derived internally from knowledge=. Passing it
explicitly hit the unknown-kwarg guard and raised TypeError. Accept it as
a legacy kwarg and merge it over the knowledge-derived config (dict merges,
RetrievalConfig instance passes through) without widening the signature.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-10-08 14:02:10 +01:00
praisonai-triage-agent[bot]andMervin Praison 3bb51b6f02 test: strengthen langflow agents test with team name + auto-generated task assertions
Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 14:02:06 +01:00
praisonai-triage-agent[bot]andDhivya-Bharathy ba6395a3c6 fix: remove stale praisonai_task import from langflow test (fixes #5361)
Test imported non-existent PraisonAITaskComponent, causing pytest
collection to abort (exit 2) when Langflow is installed. Updated test
to reference only shipped components and the current tasks-auto-generated
Agents API.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-10-08 14:02:06 +01:00
praisonai-triage-agent[bot]andDhivya-Bharathy d1aec8d884 test: guard ConsoleBackend root export in praisonai-ts (fixes #5273)
ConsoleBackend (and AutoApproveBackend) are already implemented in
src/approval/backends.ts and re-exported from the package root via
src/index.ts. Add package-root identity guards so the exports cannot
silently disappear, matching acceptance test TS-ConsoleBackend-005.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-10-08 14:02:03 +01:00
praisonai-triage-agent[bot]andMervinPraison 15867d05d6 refactor: use create_lazy_getattr helper in thinking/background/output __init__ (fixes #5254)
Replace hand-rolled __getattr__ chains with the shared DRY
_lazy.create_lazy_getattr helper in three sub-package __init__.py files.
Behaviour, lazy import timing, exported symbols and __all__ are unchanged.

Co-authored-by: MervinPraison <[email protected]>
2026-10-08 14:02:00 +01:00
MervinPraison 42e132d240 docs: auto-update feature parity trackers [skip ci] 2026-10-08 12:56:27 +00:00
MervinPraison c19d2fc824 docs: auto-update api.md [skip ci] 2026-10-08 12:51:28 +00:00
Mervin Praison 667709b5aa Merge pull request #5727 (manual fleet drain: SDK High)
fix: honour client Idempotency-Key on gateway HTTP run-create endpoints
2026-10-08 13:50:39 +01:00
Mervin Praison c07d424e4e Merge pull request #5725 (manual fleet drain: SDK High)
fix: strict gateway config key validation to catch typos (fixes #5723)
2026-10-08 13:50:34 +01:00
Mervin Praison bf34a43b76 Merge pull request #5719 (manual fleet drain: SDK High)
fix: default parallel/loop fan-out to exact context pass-through
2026-10-08 13:50:29 +01:00
Mervin Praison b8027ff112 Merge pull request #5713 (manual fleet drain: SDK High)
fix: finalize database runs for sync and async chat
2026-10-08 13:50:24 +01:00
Mervin Praison e390057766 Merge pull request #5712 (manual fleet drain: SDK High)
fix: require approval scope to inspect pending requests
2026-10-08 13:50:19 +01:00
Mervin Praison 8995713090 Merge pull request #5707 (manual fleet drain: SDK High)
feat(plugins): enforce outbound message policy decisions
2026-10-08 13:50:15 +01:00
Mervin Praison 9a55014536 Merge pull request #5702 (manual fleet drain: SDK High)
Report saved child IDs after parent lock acquisition failures
2026-10-08 13:50:09 +01:00
Mervin Praison 6fb64b8931 Merge pull request #5689 (manual fleet drain: SDK High)
fix: preserve AutoMemory dedup on preview/failed writes
2026-10-08 13:50:04 +01:00
Mervin Praison e42b45e7b3 Merge pull request #5675 (manual fleet drain: SDK High)
Avoid shifting offsets in indexed session route listing
2026-10-08 13:49:58 +01:00
Mervin Praison b41dfcf287 Merge pull request #5655 (manual fleet drain: SDK High)
fix: remove two unreferenced dead helpers from core SDK
2026-10-08 13:49:53 +01:00
Mervin Praison f5c618d3e2 Merge pull request #5643 (manual fleet drain: SDK High)
fix: close AgentOS teams/flows/WS, eval --agent, and ACP serve wrapper gaps
2026-10-08 13:49:48 +01:00
Mervin Praison 39caecddfb Merge pull request #5639 (manual fleet drain: SDK High)
fix(cli): guard profiled YAML legacy reentry
2026-10-08 13:49:42 +01:00
Mervin Praison 409bc6d063 Merge pull request #5608 (manual fleet drain: SDK High)
fix: bridge custom/plugin LLM providers to CLI/YAML surface
2026-10-08 13:49:37 +01:00
Mervin Praison efda8855bd Merge pull request #5601 (manual fleet drain: SDK High)
fix: use ProactorEventLoop for MCP stdio on Windows (fileno)
2026-10-08 13:49:31 +01:00
Mervin Praison 133865623c Merge pull request #5589 (manual fleet drain: SDK High)
fix: unify sync->async bridges, stop tool-timeout thread leak, drop dead LLM.chat_history
2026-10-08 13:49:26 +01:00
Mervin Praison 7c36169405 Merge pull request #5576 (manual fleet drain: SDK High)
fix: close SQLiteBackend connection when schema init fails
2026-10-08 13:49:21 +01:00
Mervin Praison f2aed64162 Merge pull request #5508 (manual fleet drain: SDK High)
Preserve documentation priority across creation and reload
2026-10-08 13:49:15 +01:00
Mervin Praison 56412f82f5 Merge pull request #5447 (manual fleet drain: SDK High)
fix: enforce replay safety in streaming fallback for tool turns
2026-10-08 13:49:10 +01:00
Mervin Praison 7dd40289d4 Merge pull request #5432 (manual fleet drain: SDK High)
test: stabilize release, compaction and CI fixture checks
2026-10-08 13:49:05 +01:00
Mervin Praison 06f34435dc Merge pull request #5423 (manual fleet drain: SDK High)
fix: honor workflow gates across linear and nested cached execution
2026-10-08 13:49:00 +01:00
Mervin Praison 4d91190684 Merge pull request #5413 (manual fleet drain: SDK High)
fix: persist streamed assistant output incrementally
2026-10-08 13:48:55 +01:00
Mervin Praison e32503136c Merge pull request #5412 (manual fleet drain: SDK High)
fix: capability-rank zero-config default model + add models refresh
2026-10-08 13:48:50 +01:00
Mervin Praison 2e90411f7d Merge pull request #5406 (manual fleet drain: SDK High)
fix: clear coordinator benches in FailoverManager.reset_all
2026-10-08 13:48:45 +01:00
Mervin Praison aa238217a9 Merge pull request #5400 (manual fleet drain: SDK High)
fix: close shell deny bypass, fix streaming tool loop, bound dict tool output
2026-10-08 13:48:39 +01:00
Mervin Praison efc6045203 Merge pull request #5346 (manual fleet drain: SDK High)
fix: image_generate() drops unsupported params (dall-e-3, gpt-image-*)
2026-10-08 13:48:34 +01:00
Mervin Praison 5dd0584a4d Merge pull request #5345 (manual fleet drain: SDK High)
fix: clear error for video models with no LiteLLM backend
2026-10-08 13:48:29 +01:00
Mervin Praison 170840becf Merge pull request #5344 (manual fleet drain: SDK High)
fix: default realtime model to non-deprecated gpt-realtime
2026-10-08 13:48:23 +01:00
Mervin Praison eb40feb199 Merge pull request #5311 (manual fleet drain: SDK High)
fix: return structured Markdown on dependency-free web fetch path
2026-10-08 13:48:19 +01:00
Mervin Praison 94d3e23ffe Merge pull request #5252 (manual fleet drain: SDK High)
fix: remove orphaned cli/features/ollama.py handler (dead code)
2026-10-08 13:48:14 +01:00
Mervin Praison b0d6c7faa2 Merge pull request #5250 (manual fleet drain: SDK High)
fix: inline bare @path file references on non-interactive surfaces
2026-10-08 13:48:08 +01:00
MervinPraison e0e724dee9 docs: auto-update documentation parity trackers [skip ci] 2026-10-08 12:46:07 +00:00
Mervin Praison 58d8ce97f3 Merge pull request #5443 from MervinPraison/claude/issue-5441-20261001-1022
fix: TLS/SSL labels no longer hide post-dispatch replay-safety signals
2026-10-08 13:45:30 +01:00
praisonai-triage-agent[bot]andMervin Praison bd90e45a3e fix: report top-level gateway config typos at <root> not gateway section
A misspelled top-level section key (e.g. channelz) was reported under the gateway path even though it is not a gateway-section key, misleading the operator the actionable #5723 error is meant to help. Report it at <root>.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 12:36:27 +00:00
MervinPraison 7538df1ba9 docs: auto-update documentation parity trackers [skip ci] 2026-10-08 12:33:00 +00:00
praisonai-triage-agent[bot] 9b597f3d9a Merge pull request #5724 from MervinPraison/claude/issue-5722-20261008-0915
fix: add secret sentinelisation contract for egress firewall (#5722)
2026-10-08 12:32:13 +00:00
praisonai-triage-agent[bot] 31d52fa899 Merge pull request #5721 from MervinPraison/claude/issue-5720-20261008-0823
fix: dedupe EmbeddingResult + make uvicorn reload work on all serve paths
2026-10-08 12:14:11 +00:00
Mervin Praison c92a993945 fix(ci): cut merge-gate idle wait after FINAL on HEAD (#5728)
fix(ci): cut merge-gate idle wait after FINAL on HEAD
2026-10-08 13:01:30 +01:00
praisonai-triage-agent[bot]andMervin Praison 80ef796e69 fix: serve a2a/a2u startup — use explicit app_builder (FastAPI apps are ASGI callables)
_run_server used callable(app) to tell a prebuilt app from a lazy builder, but a
FastAPI/Starlette app is itself an ASGI callable (app(scope, receive, send)). So
app() with zero args raised TypeError for cmd_a2a/cmd_a2u, which pass built apps.
Pass the builder via an explicit app_builder kwarg instead; built apps go through
untouched, and the reload worker still rebuilds from reload_spec (no double build).

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 11:52:44 +00:00
praisonai-triage-agent[bot]andMervin Praison 4299373561 fix: forward context mode through YAML parser + add fan-out regression tests
YAML parallel/loop steps now forward the context option (default "full",
exact pass-through) so YAML users keep parity with the Python API and can
opt into token-saving truncation. Adds regression tests pinning exact
pass-through for Parallel, parallel Loop, and multi-step parallel Loop,
plus truncate warnings, context validation, and YAML parity.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 12:47:42 +01:00
praisonai-triage-agent[bot]andMervinPraison 7416b0fbf7 fix: default parallel/loop fan-out to exact context pass-through (fixes #5716)
Parallel and parallel Loop previously replaced previous_output with a lossy
head+tail excerpt for every branch whenever the upstream step produced >3000
chars, silently dropping the middle with no opt-out and no default-level warning.

Make exact pass-through the default via a context param:
- Parallel(context="full"|"truncate"|"summarize"), default "full"
- Loop(context="full"|"truncate"), default "full"

"full" passes the exact upstream output to every branch (no data loss).
"truncate"/"summarize" opt into the previous token-saving excerpting and now
emit a logger.warning with before/after sizes whenever content is dropped.

Co-authored-by: MervinPraison <[email protected]>
2026-10-08 12:47:42 +01:00
MervinPraison 2a3fd629e5 docs: auto-update feature parity trackers [skip ci] 2026-10-08 11:45:45 +00:00
dajiaohuang d6890a7a65 fix: preserve cached workflow stop signals in completion hooks 2026-10-08 11:44:46 +00:00
dajiaohuang 63b07b06cd fix: key agent workflow cache by actual prompt inputs 2026-10-08 11:44:46 +00:00
dajiaohuang 56290639f4 fix: key workflow cache by prepared handler context 2026-10-08 11:44:46 +00:00
dajiaohuang 77700b7674 fix: pair conditional workflow lifecycle callbacks 2026-10-08 11:44:46 +00:00
dajiaohuang bd8ddd6d77 fix: prepare workflow gate context through start hook 2026-10-08 11:44:46 +00:00
dajiaohuang 3d9388d711 fix: recheck workflow execution conditions before cache hits 2026-10-08 11:44:46 +00:00
dajiaohuang 705bb18bd6 test: remove trailing blank line from nested condition coverage 2026-10-08 11:44:46 +00:00
dajiaohuang f66049f914 fix(workflows): scope included cache to loaded tool source 2026-10-08 11:44:46 +00:00
dajiaohuang 69dfc5e44f fix: parse and cache included recipes from one snapshot 2026-10-08 11:44:46 +00:00
dajiaohuang 509403f745 Invalidate included leaf cache when recipe definitions change 2026-10-08 11:44:46 +00:00
praisonai-triage-agent[bot]andMervin Praison c2d6606da9 fix: reset nested step status to completed on cache hit
A gated-in nested step served from the step cache kept whatever status a
previous run left in step_statuses. If an earlier run gated the step OUT
("skipped") and a later run gated it back IN, the cache hit returned the
completed output but still reported "skipped" to callers. Reset both
step.status and step_statuses to "completed" on a cache hit.

Resolves the remaining reviewer finding (cache hits retain skipped status).
Adds a regression test (verified failing before this change).

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

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 11:44:46 +00:00
dajiaohuang 86d277041d Fix skipped nested task handling and included recipe caching 2026-10-08 11:44:46 +00:00
Mervin Praison 442a9af008 Merge pull request #5449 from dajiaohuang/fix/cached-prompt-turn-context
fix: retain current turn context on prompt cache hits
2026-10-08 12:44:39 +01:00
dajiaohuang aef353e9b1 fix: honor nested workflow gates before cached leaf results 2026-10-08 11:43:45 +00:00
MervinPraison a37a8ed79f docs: auto-update api.md [skip ci] 2026-10-08 11:38:39 +00:00
praisonai-triage-agent[bot] 17b8b91821 Merge pull request #5718 from MervinPraison/claude/issue-5714-20261008-0716
fix: make Parallel(on_failure="fail_fast") actually fail fast
2026-10-08 11:31:07 +00:00
Mervin Praison e6c0504786 Merge pull request #5444 from octo-patch/octo/20261001-text-to-image-tool-recvwLFEdQh1ZR
feat(desktop): add MiniMax image generation
2026-10-08 12:14:05 +01:00
praisonai-triage-agent[bot]andMervin Praison 1cebaa0e51 fix: thread-safe AutoMemory dedup + SLOC ceiling for merged System One
- Guard AutoMemory check-store-record with a lock so overlapping calls
  with the same interaction (shared agent across worker threads) store
  exactly once; hash still recorded only after a successful store, so
  previews and post-failure retries remain correct (Greptile finding #2).
- Bump praisonaiagents.local SLOC ceiling 1300 -> 1310 to reflect the
  System One capability (PR #5401) already merged into main; this was a
  pre-existing ceiling miss surfaced by merging main into this branch.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 11:11:33 +00:00
Mervin Praison da713031bc Merge pull request #5454 from dajiaohuang/fix/llm-explicit-zero-options
fix: preserve explicit zero completion options
2026-10-08 12:10:52 +01:00
Mervin Praison dd935a0fd4 Merge pull request #5458 from dajiaohuang/fix/responses-constructor-temperature
fix: honor constructor temperature in Responses calls
2026-10-08 12:10:34 +01:00
praisonai-triage-agent[bot]anddajiaohuang b7c3543845 fix: preserve AutoMemory dedup on preview/failed writes (fixes #5500)
Only record the interaction hash in _processed_hashes after a successful
store. Previews (store=False) and storage exceptions no longer consume the
dedup hash, allowing a later real store or retry to proceed while repeated
successful stores stay deduplicated.

Co-authored-by: dajiaohuang <[email protected]>
2026-10-08 11:10:32 +00:00
Mervin Praison f78cf0fc24 Merge pull request #5462 from dajiaohuang/fix/redis-renewal-before-expiry
fix: renew Redis turn leases before expiry regardless of polling
2026-10-08 12:10:22 +01:00
Mervin Praison f31343b857 Merge pull request #5464 from dajiaohuang/fix/structured-output-request-model
fix: configure structured output for the requested model
2026-10-08 12:10:06 +01:00
Mervin Praison 2b6aabcad6 Merge pull request #5466 from dajiaohuang/fix/responses-instruction-content
fix: normalize Responses instruction content without mutation
2026-10-08 12:09:55 +01:00
Mervin Praison 5cb44ed7cb Merge pull request #5468 from dajiaohuang/fix/falsey-native-tool-results
fix: preserve falsey values in native tool results
2026-10-08 12:09:41 +01:00
Mervin Praison 8b5f66d2bd Merge pull request #5473 from camcalaquian/fix/tenki-sdk-1.4
fix(sandbox): update tenki sdk and honor idle shutdown
2026-10-08 12:09:31 +01:00
praisonai-triage-agent[bot]andMervin Praison b6672c21f6 fix(ci): tighten merge-gate cooldown bypass (SHA + newer @claude)
Address CodeRabbit/Greptile: require exact HEAD-after-finish (no 60s slack)
and keep the 35m @claude cooldown when a newer non-FINAL @claude request was
posted after the completion we rely on, until that request also completes.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 11:09:27 +00:00
dajiaohuang cc8690e49f test: document priority regression contracts 2026-10-08 11:08:46 +00:00
dajiaohuang 06c7e102a2 test: exercise document priority with real agent responses 2026-10-08 11:08:46 +00:00
Wu Shuwen 30c723f5e5 Preserve documentation priority across creation and reload 2026-10-08 11:08:46 +00:00
Mervin Praison d206e98b14 Merge pull request #5485 from dajiaohuang/fix/sqlite-shared-memory-threads
fix: share adapter-owned SQLite memory databases across threads
2026-10-08 12:08:32 +01:00
Mervin Praison 35358348e4 Merge pull request #5490 from dajiaohuang/fix/file-memory-delete-refresh
fix: preserve newer FileMemory records during selective deletion
2026-10-08 12:08:16 +01:00
Mervin Praison 6f58ce8880 Merge pull request #5492 from dajiaohuang/fix/file-memory-write-cleanup
fix: clean FileMemory staging files on serialization failure
2026-10-08 12:08:02 +01:00
Mervin Praison 471bf24407 Merge pull request #5495 from dajiaohuang/fix/file-memory-checkpoint-ids
fix: keep automatic FileMemory checkpoints independently recoverable
2026-10-08 12:07:46 +01:00
Mervin Praison 2b35d4ffba Merge pull request #5497 from dajiaohuang/fix/file-memory-zero-retention
Preserve FileMemory records during zero-retention compression
2026-10-08 12:07:25 +01:00
Mervin Praison f2c732d4a6 Merge pull request #5499 from dajiaohuang/fix/learning-store-refresh
Refresh learning stores before telemetry and targeted writes
2026-10-08 12:07:11 +01:00
Mervin Praison 381eefaa5c Merge pull request #5501 from dajiaohuang/fix/auto-memory-preview-dedup
Preserve automatic memory previews and safely resume failed writes
2026-10-08 12:05:53 +01:00
Mervin Praison fd77d6267b Merge pull request #5503 from dajiaohuang/fix/rules-home-imports
Recognize home-directory rule imports
2026-10-08 12:05:11 +01:00
Mervin Praison 89009f1364 Merge pull request #5505 from dajiaohuang/fix/memory-context-character-limits
Account for prompt formatting overhead and preserve truncation notices
2026-10-08 12:04:59 +01:00
Mervin Praison 232864c801 Merge pull request #5512 from dajiaohuang/fix/rules-yaml-frontmatter
fix: preserve YAML frontmatter in rules and documentation
2026-10-08 12:04:08 +01:00
Mervin Praison 482fd07281 Merge pull request #5514 from dajiaohuang/fix/rules-extra-file-reload
Preserve explicitly registered rules through reload
2026-10-08 12:03:49 +01:00
Mervin Praison 4692ebed13 Merge pull request #5516 from dajiaohuang/fix/sqlite-transcript-search-terms
fix: score decoded SQLite transcripts before limiting results
2026-10-08 12:03:25 +01:00
MervinPraisonandCursor 8c3beda7bd fix(ci): cut merge-gate idle wait after FINAL on HEAD
Skip the 35min @claude cooldown when FINAL completed on current HEAD
(no push after Claude finished). Raise agents-core shard timeout to 45m
to avoid cancel/rerun loops that add ~30m per PR.

Co-authored-by: Cursor <[email protected]>
2026-10-08 12:00:47 +01:00
MervinPraison 4b7db3f51c docs: auto-update documentation parity trackers [skip ci] 2026-10-08 10:57:09 +00:00
Mervin Praison 98fd4b839a Merge pull request #5549 from dajiaohuang/fix/session-mirror-record-isolation
fix(session): isolate mirror queue and retry records
2026-10-08 11:56:26 +01:00
praisonai-triage-agent[bot]andMervin Praison a66c9ae11d fix(workflows): restore outer run generation after nested run; harden tests
Addresses Greptile review on PR #5718:

- P1 "Nested runs hide failures": _begin_run_generation() stamped a new
  generation on the _run_generation contextvar but never restored it, so a
  nested Workflow.run() (e.g. an Include step) left its own generation on the
  outer thread. A later task in the outer flow would then be judged against the
  wrong generation and have its failure silently suppressed. run() now captures
  the contextvar reset token and restores it in its finally via
  _end_run_generation().
- P2 "Test misses late writes": the abandoned-branch test now gates the quick
  branch on the slow branch actually starting, and waits (event) for the
  abandoned branch to run its full failure path before asserting, instead of a
  bare sleep. The fail_fast test likewise confirms the slow sibling is in flight
  before the quick branch fails.
- Added a direct regression test proving a nested run restores the outer run's
  generation and the context is reset after the top-level run returns.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 10:18:27 +00:00
praisonai-triage-agent[bot] f525a5746e Merge pull request #5711 from MervinPraison/claude/issue-5710-20261008-0701
fix: honor sqlite db_path alias for cross-instance memory recall
2026-10-08 10:09:07 +00:00
MervinPraison 973e4c834b docs: auto-update documentation parity trackers [skip ci] 2026-10-08 10:04:28 +00:00
praisonai-triage-agent[bot] aa38d56d33 Merge pull request #5717 from MervinPraison/claude/issue-5715-20261008-0717
fix: parallel/loop crash on non-deep-copyable workflow variables
2026-10-08 10:03:53 +00:00
praisonai-triage-agent[bot]andMervin Praison 0b4e5d5998 fix: close idempotency gaps on gateway run-create endpoints (#5726)
Address the three Greptile P1 findings on the Idempotency-Key path:
- Reserve the key before dispatch and make overlapping retries await the
  in-flight run (new "inflight" verdict) so concurrent duplicates no longer
  start a second run.
- Scope keys per route so a key reused across /v1/responses and
  /v1/chat/completions never replays the wrong response shape.
- Refresh a background run's committed idempotency payload to its terminal
  result on completion so a retry after the run is evicted from the response
  store replays the final answer instead of a stale "queued" snapshot.

Add 3 regression tests (overlapping-retry dedup, per-route scoping,
post-eviction replay). Full gateway api-endpoints file: 52 passed.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 10:03:21 +00:00
praisonai-triage-agent[bot]andMervinPraison 6d01abe89a fix: honour client Idempotency-Key on gateway HTTP run-create endpoints (fixes #5726)
A retried or load-balancer-duplicated POST to /v1/responses or
/v1/chat/completions no longer starts a second agent run. When a caller
sends an Idempotency-Key header, the first use creates and records the
run (fingerprinting the canonicalised body); a retry with the same key
and an equivalent body returns the original run; the same key with a
materially different body returns a 409. Absent the header, behaviour is
unchanged (opt-in, backward compatible).

The store is in-process, dependency-free and bounded like the existing
background-response store: it holds only a request fingerprint and the
run's public id/status (never bodies or credentials), is scoped per
caller/operator, and evicts only entries whose run is terminal so a
long-running run is never dropped mid-flight.

Co-authored-by: MervinPraison <[email protected]>
2026-10-08 09:52:54 +00:00
praisonai-triage-agent[bot]andMervin Praison f23516ef4b fix: tolerate non-string gateway config keys in strict validation (#5725)
YAML can produce numeric/boolean mapping keys. The new unknown-key check
sorted mixed str/int keys and fed them to difflib, raising TypeError and
crashing config loading even with strict=False (previously those extras
were silently ignored). Guard _closest_known_key, _check_unknown_keys,
_check_typo_keys, and ConfigValidationError formatting to compare/sort by
string form and skip non-string keys, restoring fail-open tolerance and a
clean strict error. Adds 3 regression tests.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 09:31:46 +00:00
praisonai-triage-agent[bot]andMervin Praison 6262862ee9 fix: sentinelise short secrets and scope egress token lookup (#5722)
- sentinelize() now hides any non-empty string; removes the <4-char
  bypass that left short PINs/passwords in model-visible text (Greptile P1).
- desentinelize()/has_sentinel() extract only token-shaped substrings from
  the input and look up just those, instead of scanning every minted token
  (Greptile P2). Regex is lazily compiled to keep import time light.
- Tests: hide-short-secrets, token-scoped lookup, forged-token ignore.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 09:25:09 +00:00
praisonai-triage-agent[bot]andMervinPraison 4a61aa18f6 fix: strict gateway config key validation to catch typos (fixes #5723)
Add opt-in strict unknown/typo key validation to MultiChannelGatewayConfig.from_dict so a mistyped channel token, gateway option, or top-level section surfaces as a path-scoped, actionable error (e.g. channels.telegram.tokenn: unknown key (did you mean 'token'?)) instead of silently no-op'ing.

- New ConfigValidationError (path + keys + suggestion) exported from the gateway package.

- from_dict gains strict=False (backward-compatible): unknown keys warn prominently today, raise when strict=True (CLI/doctor can opt in).

- Channel sections keep pass-through metadata; only near-miss typos of reserved channel keys are flagged, leaving genuine metadata untouched.

Co-authored-by: MervinPraison <[email protected]>
2026-10-08 09:21:51 +00:00
praisonai-triage-agent[bot]andMervinPraison 24b45c4231 fix: add secret sentinelisation contract for egress firewall (fixes #5722)
Adds a lightweight, stdlib-only core seam so a resolved secret can be
referenced by an opaque per-process sentinel instead of entering
model-visible context, with the real value substituted back only on
egress by a wrapper guard.

- sentinelize/desentinelize/has_sentinel in secrets.py
- EgressGuardProtocol seam (allowlist decision reuses policy.check_network)
- unit tests

Co-authored-by: MervinPraison <[email protected]>
2026-10-08 09:18:24 +00:00
praisonai-triage-agent[bot]andMervin Praison 6c511b8821 fix: guard list/set subclass constructors in _rebuild_isolated fallback
A list/set subclass whose constructor requires arguments raised inside the
un-copyable fallback (value.__class__()), aborting Parallel/parallel Loop
before any branch ran. Mirror the dict branch's try/except and degrade to a
plain list/set on constructor failure. Adds a regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 09:17:11 +00:00
praisonai-triage-agent[bot]andMervin Praison 03a7122230 fix: correct AgentOS reload (cannot cross subprocess) + dedupe serve app build
- AgentOS.serve(reload=True): the previous module-level _LIVE_INSTANCE +
  _agentos_factory could never work — uvicorn's reload spawns a fresh worker
  process that re-imports the module with _LIVE_INSTANCE=None, so the factory
  raised RuntimeError and the server failed to start (Greptile P1). A
  programmatically-built AgentOS holds live Agent objects that are not
  importable by a separate process, so reload is genuinely unsupported here.
  Now warn and serve without reload instead of crashing; point users to
  running uvicorn against their own importable app factory. Removed the dead
  _LIVE_INSTANCE/_agentos_factory. Updated `praisonai app --reload` message.

- serve.py reload: pass a lazy app builder to _run_server so the app is built
  once (here when not reloading, or in the reload worker from reload_spec)
  instead of building then discarding the parent's app — avoids a redundant
  build and double YAML parse for `serve agents`/`serve unified` (Greptile P2).

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

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 08:48:03 +00:00
Lewis aef2fc81a3 Finish async DB writes before cancellation and share initialization gate 2026-10-08 16:42:38 +08:00
praisonai-triage-agent[bot]andMervinPraison 8c697b8cbf fix: dedupe EmbeddingResult + make uvicorn reload work on all serve paths (fixes #5720)
Two validated, minimal correctness fixes inside src/praisonai/praisonai/:

1. capabilities/embeddings.py now re-exports EmbeddingResult / embed / aembed /
   embedding / aembedding from the Core SDK (praisonaiagents.embedding) instead
   of redefining a second EmbeddingResult dataclass. There is now exactly one
   EmbeddingResult type on the public API, so isinstance() no longer depends on
   which import a caller picked. The Core functions lazy-import litellm, so this
   stays import-light.

2. uvicorn reload=True was silently dropped on every serve path because an app
   instance (not an import string) was passed. AgentOS.serve, praisonai serve
   --reload (agents/unified) and praisonai recipe serve --reload now hand uvicorn
   an import-string app-factory with factory=True when reload is enabled, so
   auto-reload actually fires. Non-reload paths are unchanged.

Scope note: the issue also proposed deleting the capabilities stub modules and
changing HostedAgent.__new__. Both were rejected after review:
- containers/container_files are live consumers of praisonai-code's CLI
  (handle_containers via _cap), so deleting them breaks that package.
- HostedAgent.__new__ returning a foreign backend is the intentional, live
  factory used by the run_on= compute backends (modal/e2b/docker/flyio), which
  are registered managed runtimes and not AnthropicManagedAgent subclasses.

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

Co-authored-by: MervinPraison <[email protected]>
2026-10-08 08:36:11 +00:00
praisonai-triage-agent[bot]andMervin Praison 850064b47b fix: rebuild un-copyable scope node-by-node, preserve container subclasses
A failed copy.deepcopy leaves unfinished copies in its memo; the fallback
reused them, so seed data nested two levels around an un-copyable leaf
vanished (e.g. {'outer': {'mid': {...}}} -> {'outer': {}}). Discard the
failed attempt's memo entries and rebuild via _rebuild_isolated, which also
preserves container subclass behaviour (namedtuple fields, defaultdict
factory, OrderedDict) instead of flattening to plain containers.

Addresses Greptile PR #5717 review findings.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 08:26:36 +00:00
praisonai-triage-agent[bot]andMervin Praison 4731660488 test: guard MemoryConfig(config={db_path}) end-to-end through Agent (#5710)
Adds a regression test that drives the documented surface
MemoryConfig(backend="sqlite", config={"db_path": ...}) through
Agent._init_memory and asserts the resulting Memory's short_db/long_db
both resolve to the caller-supplied file. The prior tests only exercised
Memory(config=...) directly, leaving the config→flatten→Memory wiring
(agent.py) untested. Mutation-verified: fails if the db_path fix is reverted.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 08:23:28 +00:00
MervinPraison ea744976c1 docs: auto-update documentation parity trackers [skip ci] 2026-10-08 08:19:20 +00:00
Mervin Praison 46bb0e27b5 fix: pin mcp extra to <2 so Streamable HTTP MCP works on fresh installs (#5705)
fix: pin mcp extra to <2 so Streamable HTTP MCP works on fresh installs
2026-10-08 09:18:33 +01:00
Lewis a967549850 fix: use existing async database lifecycle callbacks 2026-10-08 16:13:33 +08:00
praisonai-triage-agent[bot]andMervin Praison e5ecfe36ca fix: honor db_path in legacy direct-SQLite init for cross-instance recall
_init_legacy_compatibility set self.short_db/self.long_db (used by the
legacy _get_stm_conn/_get_ltm_conn direct connections) without honoring the
db_path single-file alias, so a caller using the legacy connection helpers
silently hit the default per-user file instead of the shared db_path. Map
db_path to both here too, matching _get_adapter_config; explicit
short_db/long_db still win. Adds a mutation-verified regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 07:48:23 +00:00
Lewis 6dee653b69 test: unregister the approval hook after its regression check 2026-10-08 15:43:11 +08:00
Lewis dfbe56b843 fix: keep guardrail failures scoped to each database run 2026-10-08 15:40:40 +08:00
MervinPraison 79e7bac356 docs: auto-update documentation parity trackers [skip ci] 2026-10-08 07:40:07 +00:00
praisonai-triage-agent[bot] 5816a1d9be Merge pull request #5676 from MervinPraison/claude/issue-5672-20261007-0935
fix: skip malformed transcript turns in SessionData.from_dict
2026-10-08 07:39:26 +00:00
praisonai-triage-agent[bot]andMervin Praison 047ad7c1c7 fix(workflows): harden Parallel fail_fast/timeout per review (#5718)
- Reject non-positive Parallel(timeout=...) instead of silently disabling
  the bound (timeout=0 was truthiness-false).
- Start the timeout clock before context-preparation so a blocking
  summarization call is counted within the Parallel block's deadline.
- Guard shared-status writes behind a per-run generation so a fail_fast/
  timeout-abandoned branch cannot corrupt a later run's status.
- Add regression tests for fast-failure timing, wall-clock timeout,
  non-positive timeout validation, and cross-run status isolation.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 07:36:41 +00:00
praisonai-triage-agent[bot] b9be820516 Merge pull request #5687 from MervinPraison/claude/issue-5644-20261007-0940
fix: make praisonai-code default run self-sufficient without the wrapper
2026-10-08 07:30:48 +00:00
Lewis 2515d96bdd test: require an explicit model for live approval checks 2026-10-08 15:29:44 +08:00
praisonai-triage-agent[bot]andMervin Praison 30fa5e6325 fix: preserve cross-variable aliasing and nested-container isolation in parallel scope
Address Greptile findings on #5717: _isolate_scope now copies the whole scope
with one shared deepcopy memo (so aliased variables stay connected) and, on an
un-copyable value, degrades to a by-reference share of only the exact un-copyable
leaf while still deep-copying the surrounding containers. Adds regression tests
for uncopyable seeds in Parallel and parallel Loop, nested-container isolation,
and cross-variable aliasing.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 07:28:53 +00:00
praisonai-triage-agent[bot]andMervin Praison e3d110734c fix: restore prompt_str and record guardrail-blocked runs as error
- Restore the prompt_str local in _chat_impl (removed by the DB-run refactor
  but still used by the BEFORE_AGENT hook input) — fixes the test-core
  NameError regression.
- Mark guardrail-blocked turns via _turn_guardrail_blocked so the public
  chat()/achat() finalizer records them as "error" (not "completed") while
  still persisting the refusal message and duration_ms.
- Drop the inner _end_run() in the reasoning-guardrail path so the outer
  finalizer owns run lifecycle at the public boundary.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 07:28:15 +00:00
praisonai-triage-agent[bot]andMervinPraison 53a0e75df2 fix: make Parallel(on_failure="fail_fast") actually fail fast (fixes #5714)
Consume parallel branches in completion order via concurrent.futures.wait
(FIRST_COMPLETED) and shutdown(wait=False) so a fail_fast error is raised as
soon as any branch fails, instead of waiting for every earlier-declared and
still-running sibling. Add an optional Parallel(timeout=...) wall-clock bound
so one hung branch can no longer block the workflow forever.

results/outputs/branch_deltas are rebuilt in declaration order after collection,
so parallel_outputs[idx] alignment and partial_ok/fail_all ordering are unchanged.

Co-authored-by: MervinPraison <[email protected]>
2026-10-08 07:20:43 +00:00
praisonai-triage-agent[bot]andMervinPraison f8484a44dd fix: isolate parallel scope container, share un-copyable vars by reference (fixes #5715)
Co-authored-by: MervinPraison <[email protected]>
2026-10-08 07:20:18 +00:00
MervinPraison 3ca82356aa docs: auto-update documentation parity trackers [skip ci] 2026-10-08 07:14:55 +00:00
praisonai-triage-agent[bot] 3fe52219cb Merge pull request #5682 from MervinPraison/claude/issue-5669-20261007-0940
fix: prevent orphan tool outputs and stale-loop Responses API fallback
2026-10-08 07:14:17 +00:00
Lewis e9128bc442 fix: finalize database runs for sync and async chat 2026-10-08 15:14:11 +08:00
Lewis 8a2c0f03ce test: verify approval polling budget survives denied requests 2026-10-08 15:13:31 +08:00
praisonai-triage-agent[bot]andMervin Praison 84d002f60f test: prove db_path file is used in cross-instance recall test (#5710)
Strengthen test_db_path_persists_and_recalls_across_instances so it fails
when db_path is ignored: assert the shared file is created and the default
per-user short/long stores are not. Runs in an isolated temp cwd so the
default-path check is hermetic. Addresses Greptile review feedback.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 07:12:39 +00:00
Lewis 840f2b8437 fix: require approval scope to inspect pending requests 2026-10-08 15:04:29 +08:00
praisonai-triage-agent[bot]andDhivya-Bharathy 28b54af179 fix: honor sqlite db_path alias for cross-instance memory recall (fixes #5710)
MemoryConfig(backend="sqlite", config={"db_path": ...}) silently dropped
db_path because Memory._get_adapter_config only read short_db/long_db.
The caller-supplied file was never used, so two Agent instances sharing a
db_path fell back to default paths and failed to recall stored facts. Map
db_path to both short_db and long_db (distinct tables, safe in one file);
explicit short_db/long_db still win.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-10-08 07:04:04 +00:00
praisonai-triage-agent[bot]andMervin Praison 12f75a0078 fix: remove unreachable standalone renderer; test the real CLI path (#5687)
The standalone default/plain/silent text run is already served by the
in-process Agent branch of _run_prompt (selected by
_text_run_renders_in_process when the wrapper is absent), which carries
full tools/session/instructions parity and the stop-reason/truncation/
single-print contract. The separately-added _render_default_prompt_in_process
was therefore unreachable dead code — the wrapper-absent branch that called
it is never taken (text runs are caught earlier; image runs gate earlier).

- Delete _render_default_prompt_in_process and collapse the wrapper fallback
  to the wrapper-only handle_direct_prompt delegation it actually serves.
- Replace the tests that called the dead helper in isolation with real
  _run_prompt standalone (wrapper-absent) coverage in test_run_outcome_exit.py:
  renders in-process (never delegates to the raising wrapper stub), prints
  once, and propagates a step-limit cutoff to exit 2.

Resolves the Greptile "tests miss the CLI path" finding and keeps the
package lightweight (net -193 lines). No core changes; C7 hot path
unaffected (praisonaiagents stays function-local).

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 05:58:31 +00:00
praisonai-triage-agent[bot]andMervin Praison 8c3e87e134 test: close SQLite store connections before temp dir cleanup (Windows WinError 32)
Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 05:54:33 +00:00
praisonai-triage-agent[bot] 5b94ab18a2 Merge pull request #5681 from MervinPraison/claude/issue-5521-20261007-0941
fix: preserve retained routes and archived recall on clear_session
2026-10-08 05:50:59 +00:00
praisonai-triage-agent[bot]andMervin Praison c0e26ef92e fix(llm): make async client loop-rebind thread-safe; stub guard in loop test
- async_client now serialises its drop/rebuild under a per-client lock and
  returns a local reference, so concurrent sync callers (each on their own
  asyncio.run loop) never observe a transient None (Greptile P1).
- The loop-rebind regression test stubs check_model_request so it runs under
  PRAISONAI_ALLOW_MODEL_REQUESTS=0 without hitting the model-request guard; it
  sends no real request (Greptile P2). Added a concurrency regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 05:47:36 +00:00
praisonai-triage-agent[bot]andMervin Praison baff2cff5c fix: never drop externally injected async client on loop change
The loop-rebind guard discarded any cached AsyncOpenAI when the running
loop changed, including stubbed transports injected directly in tests.
This broke guardrail retry and bounded-reflection behaviour because the
stub was replaced by a real client mid-run, causing connection errors
and a fallback to Chat Completions. Track client ownership so only
clients built by the property itself are loop-bound and droppable.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 05:15:04 +00:00
praisonai-triage-agent[bot]andMervin Praison 6002894efd fix: shift compaction anchor when dropping malformed pre-boundary turns
When SessionData.from_dict drops a non-object active turn that precedes
last_compaction.message_index, decrement the anchor by the number of
entries removed before it so get_working_history slices the retained tail
from the correct offset instead of silently dropping a healthy
post-compaction turn (Greptile P1, Issue #2741 interaction with #5672).

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 05:13:59 +00:00
praisonai-triage-agent[bot]andMervin Praison 8a6e9d5072 fix: standalone renderer parity — tools/session/instructions + truncation/single-print
Addresses the Greptile findings on PR #5687's in-process default renderer:

- Extract _wire_run_agent_config from the --output actions fast path and reuse
  it in _render_default_prompt_in_process so a standalone run wires --tools/
  --toolset/MCP, --session/--continue continuity, and explicit --instructions
  instead of silently dropping them (P1 #1-3).
- Renderer now returns (result, agent, printed); the caller consults
  agent.last_stop_reason via _run_block_reason/_run_was_truncated so a provider
  block/length cutoff or step-limit truncation exits 2 rather than reporting a
  clean completion (P1 #4), and skips its own print when the renderer's status/
  trace layer already surfaced the answer, avoiding double output (P2 #5).
- Carry instructions/approval surface onto the standalone Args object.
- Tests updated to the (result, agent, printed) contract with added coverage
  for session-identity wiring and stop-reason propagation.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 06:12:22 +01:00
praisonai-triage-agent[bot]andMervinPraison dfa8b29c51 fix: make praisonai-code default run self-sufficient without the wrapper (fixes #5644)
Render the default/plain/verbose/silent text run in-process from the same
Agent the structured --output modes already use, so a standalone
`pip install praisonai-code` delivers the default human-readable run on its
own. The old blanket wrapper gate is narrowed to only genuinely wrapper-only
features (--image). When the wrapper IS installed, behaviour is unchanged
(text runs still delegate to handle_direct_prompt). C7 hot-path discipline is
preserved (praisonaiagents imported function-locally; no wrapper imports).

Co-authored-by: MervinPraison <[email protected]>
2026-10-08 06:12:22 +01:00
MervinPraisonandCursor 3d5551f927 fix(llm): keep injected async clients across loop changes (#5682)
Only invalidate cached AsyncOpenAI when the running loop changes; do not
drop test transports. Treat Responses API as available only when
responses.create is callable.

Co-authored-by: Cursor <[email protected]>
2026-10-08 06:11:56 +01:00
praisonai-triage-agent[bot]andMervin Praison 6942c404e0 fix: pin mcp<2 in praisonai-mcp/-code/wrapper to fully close #5705
The praisonaiagents[mcp] extra was pinned to <2, but praisonai-mcp,
praisonai-code, and the praisonai wrapper still declared an unbounded
hard mcp>=1.20.0. A fresh `pip install praisonai` co-installs those,
so pip could still resolve mcp 2.x (which removed streamablehttp_client)
and break Streamable HTTP. Pin all three to mcp>=1.20.0,<2 for a
consistent, fully-working fresh install.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 04:59:56 +00:00
MervinPraison f7f54303dc docs: auto-update documentation parity trackers [skip ci] 2026-10-08 04:59:41 +00:00
praisonai-triage-agent[bot]andMervin Praison d714ff3863 fix: bind async client to first in-loop use to catch later loop changes
Addresses Greptile P1: when async_client is first read outside any running
loop, _async_client_loop was left None, so a later loop change never dropped
the stale client and sync-bridged calls could reuse a transport on a closed
loop. Now the first in-loop use records the loop so the existing guard fires
on the next loop change. Adds a regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 05:59:02 +01:00
praisonai-triage-agent[bot]andDhivya-Bharathy 341e5048c4 fix: prevent orphan tool outputs and stale-loop Responses API fallback (fixes #5669)
Drop function_call_output items whose call_id has no matching function_call
when building Responses API input to avoid the 400 'No tool call found for
function call output' that forced a Chat Completions fallback. Also recreate
the cached AsyncOpenAI client when the running event loop changes so reused
sync-bridged calls no longer hit 'Event loop is closed'.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-10-08 05:59:02 +01:00
ab54a5e81b fix(mcp): accept any JSON Schema property name and run remote tool calls on the session loop (#5704)
* fix(mcp): accept any JSON Schema property name and run remote tool calls on the session loop

MCP tool wrappers built inspect.Parameter objects straight from inputSchema property names, so a property named with a Python keyword (e.g. 'from') or a non-identifier, or a required property listed after an optional one, raised ValueError and aborted the whole server connection on stdio, SSE, HTTP-stream, and WebSocket. A shared build_tool_signature() helper now keeps representable properties as named parameters (required first) and accepts the rest through **kwargs; the schema sent to the LLM is unchanged.

SSE, HTTP-stream, and WebSocket tool wrappers scheduled calls on the caller's thread-local event loop. The agent executes tools in executor threads, where that loop is not running, so every call timed out with 'Error: ' and the coroutine was never awaited. The wrappers now remember the loop that owns their session and run calls there.

* fix(mcp): expose stdio tool schema and fix positional arg mapping

The stdio _create_tool_wrapper routed non-identifier/keyword property
names (from, max-results) through **kwargs, so when the wrapper was passed
as a plain callable (Agent(tools=list(mcp))) the LLM schema derived from
the signature dropped those properties, and positional calls mapped args
by raw schema order rather than the visible parameters.

- Add wrapper.get_schema() returning the server's original inputSchema so
  schema builders surface every property; tracks __name__ for prefixing.
- Build positional mapping from the signature's named parameters.
- Tests for schema visibility and positional-call correctness.

Co-authored-by: Mervin Praison <[email protected]>

* docs(mcp): add docstrings to MCP wrapper and signature test functions

---------

Co-authored-by: MrScraper <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 05:58:39 +01:00
praisonai-triage-agent[bot]anddajiaohuang eb1d8b7e34 fix: preserve retained routes and archived recall on clear_session (fixes #5521)
SqliteSessionStore.clear_session overrode the parent to deindex all three
index tables, but clearing only empties active turns -- the JSON transcript,
gateway/agent bindings and archived turns remain persisted. The parent clear
already goes through _modify_session_locked, whose indexed override refreshes
the index from the retained content and routes. The redundant clear override
is removed; deindexing stays in delete_session where it belongs.

Co-authored-by: dajiaohuang <[email protected]>
2026-10-08 05:56:50 +01:00
praisonai-triage-agent[bot]andMervin Praison 6b12387c0e test: cover SqliteSessionStore indexed route for malformed transcript (#5672)
Add the originally reported crash path to the regression suite. The prior
tests exercised SqliteTranscriptStore (sessions-table storage), but Issue
#5672's AttributeError surfaced in SqliteSessionStore._read_indexed_route,
which loads the transcript from an on-disk JSON file via the session_route
index. Corrupt the durable JSON directly and assert the indexed lookup and
agent listing still resolve without touching the file or its route record.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 05:56:47 +01:00
praisonai-triage-agent[bot]anddajiaohuang a3b8194ea1 fix: skip non-object transcript turns in SessionData.from_dict (fixes #5672)
A transcript message stored as JSON null, a string, or a number made
SessionData.from_dict crash with AttributeError via SessionMessage.from_dict's
.get calls, interrupting gateway routing on a single malformed turn. Such
entries are now skipped so healthy sessions stay routable and the original
transcript and index record are preserved. Covers both the SQLite indexed
route and the default-store scan, for active and archived messages.

Co-authored-by: dajiaohuang <[email protected]>
2026-10-08 05:56:47 +01:00
praisonai-triage-agent[bot] 2fda0b7ab7 Merge pull request #5677 from MervinPraison/claude/issue-5670-20261007-0940
fix: inspect router routes in AG-UI include_router test
2026-10-08 04:55:51 +00:00
praisonai-triage-agent[bot]andMervin Praison 85282d06ba fix: verify AG-UI router inclusion through TestClient (addresses review)
Inspect app endpoints via TestClient instead of router.routes so the test
actually verifies app.include_router wired the AG-UI routes, not just that
the router defines them.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 05:08:16 +01:00
praisonai-triage-agent[bot]andDhivya-Bharathy fd876b8482 fix: inspect router routes in AG-UI include_router test (fixes #5670)
Starlette/FastAPI nests included routers under an internal wrapper, so
app.routes no longer exposes flat /agui and /status paths. Runtime is
unaffected. Assert against the router's own routes instead.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-10-08 05:08:16 +01:00
MervinPraison 145795972b docs: auto-update documentation parity trackers [skip ci] 2026-10-08 04:07:39 +00:00
praisonai-triage-agent[bot] adc4ed3784 Merge pull request #5695 from MervinPraison/claude/issue-5469-20261007-1009
fix: handle assistant list content with tool calls in Responses converters
2026-10-08 04:06:57 +00:00
MervinPraison d88df20d04 docs: auto-update documentation parity trackers [skip ci] 2026-10-08 03:24:51 +00:00
praisonai-triage-agent[bot] 1dd4f96c73 Merge pull request #5680 from MervinPraison/claude/issue-5531-20261007-0941
fix: iterative session tree traversal with cycle detection
2026-10-08 03:24:06 +00:00
praisonai-triage-agent[bot]andMervin Praison 0cc8ed8172 fix: skip empty-name tool calls in LLM Responses converter
Align _build_responses_params with _build_responses_input so both skip
function_call items with an empty name (the Responses API rejects them).
Add regression tests for both converters.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 02:57:40 +00:00
praisonai-triage-agent[bot]andMervin Praison 6d624e2d2c fix: extract function from SDK tool-call objects in Responses converters
Both Responses converters used the whole tool-call object as `fn` when it
was a non-dict SDK ChatCompletionMessageToolCall, so getattr(fn,"name")
read the missing top-level attribute instead of fn.function.name. The
OpenAIClient path dropped the call (empty name skipped); the LLM path
emitted an empty-name function_call. Now use getattr(tc,"function",tc)
to reach the nested function on SDK objects, matching the dict path.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 02:45:53 +00:00
praisonai-triage-agent[bot]andMervin Praison 37a39f97d2 perf: use single active-path set in get_session_tree traversal
Replace per-child ancestor-set copies (path | {child_id}) with one
mutable active-path set that adds ids on descend and removes them on
pop. This drops the traversal from O(depth^2) memory/time to O(depth)
while preserving cycle detection and shared-descendant behavior.

Addresses Greptile review finding on PR #5680.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 03:31:19 +01:00
praisonai-triage-agent[bot]anddajiaohuang eb22baff71 fix: iterative session tree traversal with cycle detection (fixes #5531)
Replace the recursive HierarchicalSessionStore.get_session_tree with an
explicit traversal stack so valid deep parent-child chains no longer raise
RecursionError. Detect a repeated ID on the current ancestor path and reject
it with ValueError; a per-path set (not a global visited set) keeps shared
descendants on different branches intact. Nested dict shape, child order and
missing-child behavior are preserved.

Co-authored-by: dajiaohuang <[email protected]>
2026-10-08 03:31:19 +01:00
praisonai-triage-agent[bot]andMervin Praison 4982645c11 test: add regression tests for assistant list content with tool calls (#5469)
Cover both Responses converters (_build_responses_params and
_build_responses_input) for assistant list content alongside tool_calls,
the exact case fixed in this PR. Also guard string, whitespace, None, and
empty-list behavior so ordering with the matching tool result is preserved.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 03:31:12 +01:00
praisonai-triage-agent[bot]anddajiaohuang 9595a0d5d0 fix: handle assistant list content with tool calls in Responses converters (fixes #5469)
Both Responses history converters assumed assistant content was a string
when tool_calls were present, calling content.strip() directly. List text
parts raised AttributeError before Responses dispatch (OpenAIClient then
fell back to Chat Completions). Now list content is translated via the
existing _build_responses_content converter, preserving function
call/output ordering and existing string/whitespace/None/empty-list behavior.

Co-authored-by: dajiaohuang <[email protected]>
2026-10-08 03:31:12 +01:00
praisonai-triage-agent[bot]andMervin Praison 66ec26a3d3 fix(plugins): include HookResult in outbound hook return types
Plugin message/tool/agent hooks document returning HookResult, and the
manager adapter already coerces it, but the return-type unions omitted it.
Add HookResult (via TYPE_CHECKING import) so plugin authors returning the
documented HookResult.block(...) / HookResult(modified_input=...) pass type
checks when overriding these methods.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-08 02:03:15 +00:00
Lewis 2fc63e0b7a feat(plugins): enforce outbound message policy decisions 2026-10-08 09:54:37 +08:00
praisonai-triage-agent[bot]andai-mrscraper 64cf4d0400 fix: pin mcp extra to <2 and correct Streamable HTTP error hint (fixes #5705)
Co-authored-by: ai-mrscraper <[email protected]>
2026-10-08 01:54:08 +00:00
dajiaohuang 907fabcc26 merge: consolidate Redis renewal regression coverage 2026-10-08 09:16:34 +08:00
MervinPraison c4bdb3ff91 docs: auto-update documentation parity trackers [skip ci] 2026-10-07 21:54:36 +00:00
praisonai-triage-agent[bot] 11bf2bf3b7 Merge pull request #5686 from MervinPraison/claude/issue-5517-20261007-0942
fix: SQLite transcript export/import uses DB rows not JSON sidecars
2026-10-07 21:53:45 +00:00
MervinPraisonandCursor 16cb547e5a ci: retrigger Core Tests after cancelled stuck agents-core shard
Co-authored-by: Cursor <[email protected]>
2026-10-07 22:01:23 +01:00
MervinPraisonandCursor 206f5152e0 fix(session): drop duplicate SqliteTranscriptStore import overrides
Second export/import method block shadowed _save_imported_session and
dropped the overwrite kwarg, breaking transcript portability tests.

Co-authored-by: Cursor <[email protected]>
2026-10-07 21:40:28 +01:00
praisonai-triage-agent[bot]andMervin Praison 290118004a test: add SQLite small-window import no-truncation guard (PR #5686)
Covers Greptile's review suggestion: a SqliteTranscriptStore destination
with active_window=2/retention=truncate must restore all 6 exported
messages, guarding the no-window-truncation import contract against an
accidental switch from _save_imported_session to _save_session.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 21:32:55 +01:00
praisonai-triage-agent[bot]anddajiaohuang af6db5bb4a fix: SQLite transcript export/import uses DB rows not JSON sidecars (fixes #5517)
Override export_all, _collect_lineage and _save_imported_session on
SqliteTranscriptStore so portable export/import reads and writes the
SQLite rows that actually hold the transcripts, instead of inheriting
the JSON-sidecar implementation from DefaultSessionStore.

Co-authored-by: dajiaohuang <[email protected]>
2026-10-07 21:32:55 +01:00
MervinPraison dc5e9e8985 docs: auto-update documentation parity trackers [skip ci] 2026-10-07 20:31:19 +00:00
praisonai-triage-agent[bot] 1febd95b95 Merge pull request #5679 from MervinPraison/claude/issue-5668-20261007-0940
fix: reject non-adapter MemoryConfig.db to stop silent persist failure
2026-10-07 20:30:34 +00:00
dajiaohuang 4d2e536409 Merge remote-tracking branch 'origin/main' into followup/prompt-cache-ci-oct08 2026-10-08 03:53:21 +08:00
praisonai-triage-agent[bot]andMervin Praison 46101d224c fix: harden MemoryConfig.db guard and isolate concurrency test (#5668)
Require the DbAdapter lifecycle hooks (on_agent_start/on_user_message/
on_agent_message) instead of a weaker database_url signal, so a half-built
object can no longer pass validation yet silently drop messages (Greptile #1).
Correct the error-message SQLite advice to use the honoured short_db/long_db
keys rather than the ignored path key (Greptile #2).

Isolate test_tool_executor_concurrency agents with a private HookRegistry so a
leaked global BEFORE_TOOL hook (xdist --dist loadfile shares a worker) can no
longer block paired_tool with "blocked by security policy" -- a pre-existing
test-isolation flake surfaced by this PR's new test file shifting collection
order. Assertions unchanged.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 20:43:12 +01:00
praisonai-triage-agent[bot]andDhivya-Bharathy 333ab1216f fix: reject non-adapter MemoryConfig.db to stop silent persist failure (fixes #5668)
A bare path string (or other non-adapter) passed as memory db flowed
through to memory_mixin, where adapter hooks raised
"'str' object has no attribute 'on_agent_start'" and were swallowed as
warnings -- memory looked enabled while every message was dropped.
Validate db in MemoryConfig.__post_init__ and fail fast with a clear
TypeError pointing at db(...) / MemoryConfig(backend="sqlite").

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-10-07 20:43:12 +01:00
praisonai-triage-agent[bot] 2ab7e38cd0 Merge pull request #5685 from MervinPraison/claude/issue-5665-20261007-0940
fix: run json/stream/stream-json in-process on standalone (fixes #5665)
2026-10-07 19:41:14 +00:00
dajiaohuang e780ef3ca4 test: allow scheduling contention in SQLite peer recovery 2026-10-08 03:04:10 +08:00
praisonai-triage-agent[bot]andMervin Praison 3dacddf9aa fix: honour quiet mode when surfacing partial stream answers
_emit_partial printed the partial --output stream answer on an exit-2
(blocked/truncated) run without checking quiet mode; is_json_mode is
False in QUIET so it leaked the answer. Also check output.is_quiet.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 18:48:06 +00:00
dajiaohuang 41c3cf2937 fix: roll back failed SQLite memory mutations 2026-10-08 02:43:19 +08:00
dajiaohuang bef21a9796 Preserve parent registration uncertainty after lock cleanup errors 2026-10-07 18:36:14 +00:00
dajiaohuang bd0e64c5aa Report saved child IDs after parent lock acquisition failures 2026-10-07 18:35:55 +00:00
praisonai-triage-agent[bot]andMervin Praison f36e0c73db fix: reconcile standalone structured-output rebase with main (#5665)
main already merged the superset fix (#5666) for the same issue, so the
rebase conflicts were resolved by keeping main's in-process logic and
dropping this branch's now-redundant duplicates. Two follow-ups kept the
PR's P1 guarantees true against main's implementation:

- run.py: surface a `--output stream` partial answer on blocked/truncated
  exits (main only emitted the json envelope), mirroring the documented
  "partial answer before exit" contract.
- test_run_outcome_exit.py: align the PR's regression tests to main's
  `{result, status}` envelope + stream-json-prints-answer contract, and fix
  a pre-existing cross-test leak where a stubbed `praisonaiagents.session.
  store` was bound into `project_sessions` on first import (Greptile #5685).

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

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 19:34:15 +01:00
praisonai-triage-agent[bot]andMervin Praison 4c89571b25 fix: surface partial answers + honor --memory for in-process structured runs
Address two Greptile P1 findings on the #5665 in-process dispatch:

- Partial answers were lost on exit-2 runs: the blocked/truncated reporters
  raise typer.Exit before the per-mode stdout envelope printed, so
  `--output json | script` got empty stdout and `--output stream` dropped the
  partial answer. Extract the per-mode stdout into `_emit_inprocess_stdout`
  and call it before the blocked/truncated exits (and on clean completion).
- Explicit `--memory` was dropped: the in-process path only used
  build_cli_memory_config (None without a session/auto-save), whereas the old
  wrapper path set memory=True. Honor the flag when no session config exists.

Adds regression tests that actually exercise `_run_prompt` for each structured
mode with a fake Agent and a raising wrapper stub (pinning in-process dispatch,
the #5665 bug), plus tests for the partial-answer and memory-flag fixes.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 19:34:15 +01:00
MervinPraison 9ab9e47624 docs: auto-update documentation parity trackers [skip ci] 2026-10-07 18:33:57 +00:00
praisonai-triage-agent[bot] 54d625074a Merge pull request #5683 from MervinPraison/claude/issue-5527-20261007-0941
fix: raise on failed durable writes in hierarchy creation APIs
2026-10-07 18:33:13 +00:00
dajiaohuang 5d2967aaab Preserve Responses instruction error message compatibility 2026-10-08 02:26:48 +08:00
dajiaohuang 8800cd030c Preserve duplicate imported records during memory compression 2026-10-08 02:18:41 +08:00
praisonai-triage-agent[bot] 5acb83b6ec Merge pull request #5700 from MervinPraison/fix/fleet-tool-concurrency-hook-isolation
fix(tests): isolate hooks in tool executor concurrency tests
2026-10-07 17:59:43 +00:00
MervinPraisonandCursor 202e32e57d fix(tests): isolate hooks in tool executor concurrency tests
Global BEFORE_TOOL hooks from other xdist tests could block paired_tool
and flake agents-core on unrelated PRs.

Co-authored-by: Cursor <[email protected]>
2026-10-07 18:11:23 +01:00
MervinPraisonandCursor a04848883a fix(tests): isolate hooks in tool executor concurrency tests
Global BEFORE_TOOL hooks from other xdist tests could block paired_tool
and flake agents-core on unrelated PRs.

Co-authored-by: Cursor <[email protected]>
2026-10-07 18:11:13 +01:00
MervinPraison 150f88618a ci: retrigger Core Tests after cancelled run 2026-10-07 18:00:04 +01:00
praisonai-triage-agent[bot]andMervin Praison 85649170bb test(session): remove unused flaky FileLock.acquire helper
The _fail_parent_lock_for helper (patching FileLock.acquire by lock-path
match) was the flaky pattern that failed agents-core xdist CI. The lock
tests now patch _modify_session_locked directly (deterministic), leaving
this helper unreferenced. Drop the dead code to prevent regressing to the
flaky approach.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 17:23:09 +01:00
MervinPraison 7f3eb311db test(session): stabilise hierarchy lock-failure cases on Linux CI
Patch _modify_session_locked for parent registration failures instead of
FileLock.acquire path matching, which was flaky under agents-core xdist on Linux.
2026-10-07 17:23:09 +01:00
praisonai-triage-agent[bot]anddajiaohuang abf2546f89 fix: raise on failed durable writes in hierarchy creation APIs (fixes #5527)
create_session, fork_session, create_snapshot and import_session now raise
OSError when a persistence write or parent registration fails instead of
returning a success-shaped ID. The child is saved before being registered so
a parent never lists a child JSON file that was never written; if the child is
saved but parent registration fails the child is retained and its ID is
surfaced in the exception.

Co-authored-by: dajiaohuang <[email protected]>
2026-10-07 17:23:09 +01:00
MervinPraison d462b9b1d9 docs: auto-update documentation parity trackers [skip ci] 2026-10-07 16:19:12 +00:00
praisonai-triage-agent[bot] ecb59bee63 Merge pull request #5688 from MervinPraison/claude/issue-5667-20261007-0940
fix: cross-instance memory recall on sqlite/file backends (fixes #5667)
2026-10-07 16:18:27 +00:00
Mervin Praison 08e1841c91 fix(ci): allow triage bot PRs when author_association is CONTRIBUTOR (#5699)
Check trusted automation authors before association so merge gate does not
mis-label triage PRs as maintainer-only.
2026-10-07 16:52:32 +01:00
Mervin Praison 1b71e29bbf fix(ci): fleet merge gate — core-green pending checks + maintainer-only authors (#5697)
* fix(ci): fleet-wide merge gate — core-green pending + maintainer-only authors

Ignore slow Optimized Suite jobs (smoke, main 3.11) while test-core is green
so the fleet is not stuck on blocked:ci. Restrict auto-merge to triage bot and
maintainer allowlist; external and first-time contributors stay manual-only.

* fix(ci): ignore pending GitGuardian when test-core is green
2026-10-07 16:45:11 +01:00
praisonai-triage-agent[bot]andMervin Praison 9f1c205fef test: use disposable SQLite stores in session_id recall test
Memory ignores db_path for SQLite (uses short_db/long_db), so the prior
db_path config fell back to the project default databases and polluted
real memory stores. Point short_db/long_db inside tmpdir (Greptile #P2).

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 15:16:45 +00:00
praisonai-triage-agent[bot]andMervin Praison 5ada43d57f fix: stamp session_id on persisted turn for session-scoped recall (#5667)
When MemoryConfig.session_id is set, turn-start prefetch sends a
metadata.session_id filter that Memory.search_long_term post-filters
against record metadata. _persist_memory_turn only stamped user_id, so a
same-user turn was silently dropped on recall whenever a session was
configured. Stamp session_id alongside user_id (Greptile P1).

Adds TestSqliteUserIdFilter.test_session_id_stamp_matches_prefetch_session_filter
driven through Memory (SQLite long-term) — the session_id post-filter lives
in Memory, not the raw adapter — proving same-session recall and
different-session exclusion.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 13:56:59 +00:00
praisonai-triage-agent[bot] 0e4be474e9 Merge pull request #5431 from MervinPraison/claude/issue-5427-20261001-0949
fix: stream recipe/YAML-backed gateway agents to parity with Agent path
2026-10-07 13:44:05 +00:00
Mervin Praison 37e97eccc5 Merge pull request #5666 from Andiii208/fix/5665-standalone-structured-output
fix: run structured output modes in-process (fixes #5665)
2026-10-07 14:30:25 +01:00
Mervin Praison 0b6efcf668 Merge pull request #5664 from Andiii208/fix/5644-standalone-default-run
fix: render standalone praisonai-code text runs in-process (fixes #5644)
2026-10-07 14:29:58 +01:00
Mervin Praison 39908a7314 Merge pull request #5693 from copiumnicus/claktor/672a687a
refactor(cli): Share help rendering and error handling
2026-10-07 14:27:39 +01:00
praisonai-triage-agent[bot] 3026bd2711 Release v4.7.13 2026-10-07 12:42:25 +00:00
praisonai-triage-agent[bot]andMervin Praison 96cb91fcdc fix: harden cross-instance memory persistence (PR #5688 review)
Address reviewer findings on the persist->prefetch pipeline:
- memory_mixin: write long-term and short-term under independent guards so a
  long-term failure no longer skips the short-term fallback (Greptile #2).
- file_memory: break equal-importance eviction ties by recency so a freshly
  appended raw turn is not discarded once long-term is full (Greptile #3).
- tests: add a SQLite-backed user_id metadata-filter test proving matching-user
  recall and different-user exclusion on a shared store (Greptile #5).

Merged main to pick up the local/ SLOC ceiling bump (1305); package is 1302.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 12:28:57 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 706179ffeb fix: persist raw turn to long-term for cross-instance memory recall (fixes #5667)
Cross-instance memory recall failed because the persist->prefetch pipeline
had two gaps: (1) turn-start prefetch searches long-term memory and scopes by
metadata.user_id, but persisted turns were written only to short-term with no
user_id stamp, so the scope filter dropped every row; (2) with auto_memory=True
only the best-effort pattern extractor ran and the raw-turn fallback was skipped,
so arbitrary facts (e.g. a codename) were never stored.

_persist_memory_turn now writes the turn to long-term (keeping a short-term copy)
and stamps user_id into the record metadata so a second Agent sharing the same
user_id recalls it via prefetch. The raw turn is persisted alongside auto_memory
extraction rather than only when it is off.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-10-07 13:23:13 +01:00
praisonai-triage-agent[bot] fe06899ac7 Merge pull request #5691 from MervinPraison/claude/issue-5480-20261007-0959
fix: assert repr(path) in Chroma bootstrap diagnostics for Windows
2026-10-07 12:21:18 +00:00
dajiaohuang d712cec6e0 test: isolate Responses instruction endpoints 2026-10-07 12:12:52 +00:00
dajiaohuang def85f4379 fix: normalize Responses instruction content without mutation 2026-10-07 12:12:52 +00:00
praisonai-triage-agent[bot]andMervin Praison 70f52dcfb5 fix: marshal recipe stream events onto event loop; forward cancel_token (PR #5431 review)
Recipe adapter ran run_stream in run_in_executor and emitted synchronously from
the worker thread. The bot hot path's stream callback schedules coroutines via
asyncio.get_running_loop().create_task(...), which raises RuntimeError off the
loop thread — so recipe draft edits and turn-context SSE chunks were silently
dropped. Marshal every emit back onto the loop via call_soon_threadsafe so the
recipe surface matches the Agent streaming contract. Also forward cancel_token
to run_stream (when supported) and stop iterating once cancelled, so a
cancelled/timed-out turn does not leave the recipe worker running.

Tests: add async-callback-bridge (real bot contract), cancel-forwarding, and
cancel-mid-stream coverage.

Also fix two pre-existing, environment-dependent CI flakes unrelated to the
recipe change:
- test_compaction_wiring: content was sized at chars/4; tiktoken collapses a
  run of identical chars well below that, so the budget was never crossed when
  the accurate tokeniser is available offline. Size content against the
  compactor's own estimator so the intent holds under both tokenisers.
- test_run_on_docker reclaim: _release is fire-and-forget during live GC, so the
  shutdown runs on the async bridge's background loop; poll briefly instead of
  asserting on the same tick.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 12:12:09 +00:00
praisonai-triage-agent[bot]andMervinPraison 37a8bc47ef fix: stream recipe/YAML-backed gateway agents to parity with Agent path (fixes #5427)
RecipeBotAdapter only had a blocking, string-only chat() that referenced a
non-existent RecipeRuntime and never fed its StreamEventEmitter. Add an async
astart/achat surface that runs the wrapper's recipe.run_stream off the event
loop and forwards incremental events into the existing stream_emitter
(progress -> TOOL_PROGRESS, output -> FIRST_TOKEN/DELTA_TEXT, then STREAM_END),
so recipe agents reach the bot DraftStreamer / SSE hot path. Degrades safely to
a single final send and fixes the broken sync chat() to use the real
praisonai.recipe API.

Co-authored-by: MervinPraison <[email protected]>
2026-10-07 12:12:09 +00:00
praisonai-triage-agent[bot]andMervin Praison 7bd499b910 fix: close AgentOS WS auth/isolation, flow concurrency, and eval build gaps
Address the valid reviewer findings on PR #5643:
- AgentOS WebSocket now validates the configured launch token (Bearer /
  X-API-Key / ?api_key=) before accepting the socket, closing the
  unauthenticated back door the HTTP middleware could not cover.
- WebSocket and POST /chat share one _isolate_agent helper (clone + bind
  session) so concurrent sockets never share mutable chat_history.
- Team/flow invocations are serialised per-instance so overlapping
  requests for the same object cannot trample its mutable run state.
- eval loads agents via a context manager and evaluates inside the
  generator's lifetime so tool_timeout-wrapped tools keep a live
  executor; the YAML `framework` key is honoured; build_agents() threads
  the per-agent tool_timeout wrap resolver like the real run paths.
- Add WS/team/flow unit tests.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 12:07:21 +00:00
praisonai-triage-agent[bot]andMervinPraison 80a9200934 fix: close AgentOS teams/flows/WS, eval --agent, and ACP serve wrapper gaps (fixes #5642)
- AgentOS: add REST routing for teams (/teams/{name}/run) and flows
  (/flows/{name}/run) plus a WebSocket /chat/stream endpoint so the
  constructor's teams/flows/streaming surfaces are no longer silent no-ops;
  /approvals now prefers the registry's public list_requirements() instead of
  reaching into private _required_tools fields.
- Core: add ApprovalRegistry.list_requirements() as the stable public view.
- eval: fix the four --agent call sites that constructed AgentsGenerator with
  missing required args and called a non-existent generate_agents(); add a
  shared _load_agents_from_yaml() that mirrors _entrypoint.run (registry +
  config_list + with-block) and a thin AgentsGenerator.build_agents() builder.
- ACP: route serve() through the shared AsyncBridge via run_cli_coro instead of
  bare asyncio.run so scoped_bridge() is honoured and per-loop clients survive.

Co-authored-by: MervinPraison <[email protected]>
2026-10-07 12:07:21 +00:00
praisonai-triage-agent[bot] d1dc77a263 Merge pull request #5694 from MervinPraison/claude/issue-5465-20261007-1010
fix: normalize text-part system/developer instructions in Responses converters
2026-10-07 12:01:38 +00:00
Mervin Praison ca45308cd5 Merge pull request #5696 from MervinPraison/fix/auto-merge-pipeline
ci: unblock auto-merge pipeline and harden Claude OAuth
2026-10-07 12:38:13 +01:00
praisonai-triage-agent[bot]andMervin Praison 25c3bfc8f4 fix: harden merge-gate FINAL completion ordering and oauth tertiary fallback
- finalClaudeCompletedOnSha: require the Claude finished reply to follow BOTH
  the latest FINAL trigger and HEAD. No longer rejects a FINAL solely because
  its trigger predates HEAD (fix-commit sequence), and an older reply can no
  longer satisfy a newer FINAL trigger.
- isStaleFinalAfterPush: apply the same max(FINAL, HEAD) floor so a reply
  predating HEAD cannot suppress stale-FINAL recovery.
- oauth-pick-token.sh: fallback now honors the operator-selected preference
  (primary/secondary/tertiary) before falling back to other configured tokens.
- Add merge-gate self-tests for the fix-commit and old-reply-before-new-FINAL
  sequences.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 11:23:47 +00:00
praisonai-triage-agent[bot]andMervin Praison 637d3f1f7d test: cover instruction normalisation; fix local SLOC ceiling drift
Add committed regression tests through both Responses converters
(LLM._build_responses_params and OpenAIClient._build_responses_input)
for text-part instruction lists, mixed string/list ordering, caller
history immutability, and rejection of unsupported parts (Greptile).

Raise local/ SLOC ceiling 1300 -> 1305 to absorb the SYSTEM_ONE
capability growth landed on main (capabilities.py), which is the
pre-existing drift that tipped the package to 1302 SLOC in CI.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 11:15:49 +00:00
praisonai-triage-agent[bot]andMervin Praison 4b9d1a355d test(cli): add regression tests for shared help/error output helpers
Lock JSON shapes, plain-text spacing/widths, description handling and
error exit behaviour for printPlainHelp, printCommandHelp and
handleCommandError now that ~38 commands depend on them. Revert the
unrelated handoffFilters removal from the handoff SDK usage example.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 11:13:47 +00:00
praisonai-triage-agent[bot]andMervin Praison 606d3c9583 fix: raise local package SLOC ceiling to 1305 for merged SYSTEM_ONE capability
main added the SYSTEM_ONE capability (+4 SLOC in local/capabilities.py) without
updating the budget in test_import_boundary.py. Merging this PR with main pushes
the local/ package to 1302 SLOC, tripping the 1300 ceiling. Bump the package
ceiling to 1305 to acknowledge the already-merged capability (the test's own
policy is "set just above measured reality so growth is a decision"). No test
standards weakened; the per-file and import-sink guards are untouched.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 11:03:57 +00:00
praisonai-triage-agent[bot] 36f97f737f Merge remote-tracking branch 'origin/main' into claude/issue-5480-20261007-0959 2026-10-07 11:03:16 +00:00
MervinPraisonandCursor 4ff26e62d6 ci: unblock auto-merge pipeline and harden Claude OAuth wiring
Merge gate ignores queued Auto PR Comment checks when test-core is green,
skips scans on low GitHub API quota, and ties FINAL completion to HEAD.
Pipeline sync uses a lower PR cap and the same rate-limit guard.
OAuth pick action adds tertiary token support for merge gate and fleet sync.

Co-authored-by: Cursor <[email protected]>
2026-10-07 12:02:11 +01:00
praisonai-triage-agent[bot]andMervin Praison 3aa6e63db1 fix: regenerate parity tracker + drop stale ollama inventory line (#5252)
Removing the orphaned Ollama wrapper handler dropped pythonWrapperFeatures
21->20, so the exported-symbol tracker (FEATURE_PARITY_TRACKER.json / PARITY.md)
was out of date and the parity-gate `names` job failed. Regenerate the tracker
files and remove the now-stale `ollama` entry from the code-side feature
inventory docstring.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 10:27:28 +00:00
praisonai-triage-agent[bot]andMervin Praison 4877eed073 fix: remove orphaned cli/features/ollama.py handler and dead plumbing (fixes #5251)
Delete the self-contained OllamaHandler / handle_ollama_command /
add_ollama_arguments module that no CLI dispatch path reaches, along with
its now-unused re-export plumbing (wrapper + code-side __all__ entries,
the praisonai_code bridge shim + lazy-loader branch, and the ollama
importability line in the bridge smoke test).

Ollama provider resolution and the fine-tuning --ollama flow are
untouched; they never referenced this handler.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 10:26:52 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy f199c8a733 fix: export collapse (+ RunTerminal contract) in praisonai-ts (fixes #5298)
Adds the canonical terminal-outcome contract to praisonai-ts to match
Python praisonaiagents/run_outcome.py: RunTerminal, TerminalKind,
TerminalSource, isSticky, mergeRunTerminal, and collapse. Closes the
four P3 parity gaps (collapse, RunTerminal, is_sticky, merge_run_terminal)
in one coordinated change and refreshes the parity tracker.

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

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-10-07 10:23:52 +00:00
praisonai-triage-agent[bot]andMervin Praison 289ae6741e fix: scope connection getter lock to in-memory tiers; guarantee parallel-write race
- Acquire _write_lock in _get_stm_conn/_get_ltm_conn only for :memory: tiers,
  preserving unlocked file-backed connection access (CodeRabbit).
- Barrier-synchronize parallel-write test workers so the unique-ID assertion
  reliably exercises the cross-thread ID-collision scenario (Greptile).

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 10:18:16 +00:00
praisonai-triage-agent[bot]anddajiaohuang 7b403ef282 fix: normalize text-part system/developer instructions in Responses converters (fixes #5465)
Both Responses converters concatenated system/developer content directly
into instructions, which broke on Chat Completions-style text-part content
([{"type": "text", "text": ...}]): a string followed by a list raised
TypeError, a list followed by a string appended characters into the
caller-owned list, and a lone text-part list was sent verbatim.

Add OpenAIClient._normalise_instruction_content to coerce string or
text-part list content into a plain string, reject unsupported parts
explicitly, and never mutate caller lists. Use it in both
LLM._build_responses_params and OpenAIClient._build_responses_input.

Co-authored-by: dajiaohuang <[email protected]>
2026-10-07 10:12:29 +00:00
praisonai-triage-agent[bot]anddajiaohuang c48436db7d fix: assert repr(path) in Chroma bootstrap diagnostics for Windows (fixes #5480)
Production diagnostics format the configured directory with {path!r}, which
escapes backslashes on Windows. The tests asserted the raw path substring,
so they failed on Windows paths. Match the existing repr format.

Co-authored-by: dajiaohuang <[email protected]>
2026-10-07 10:02:10 +00:00
praisonai-triage-agent[bot]andMervin Praison b5ea9b80e6 fix: exclude same-id record replacements from compression deletion
Revalidate selected records by content, not just id, before deleting
sources. An import_data replacement that reuses an id during the unlocked
summarization window is now treated like turnover: it is excluded and the
survivors are re-summarized, so the replacement is never deleted after
summarizing stale content.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 10:00:31 +00:00
MervinPraison e283f35215 docs: auto-update api.md [skip ci] 2026-10-07 09:49:35 +00:00
praisonai-triage-agent[bot]andMervin Praison 4cffafd961 fix: frame run.start for per-command stream-json runs
The direct-prompt run_main emits its start event while the controller is
still in TEXT mode; _run_prompt only switches to STREAM_JSON afterwards.
So a run selected via --output stream-json (without the global
--output-format stream-json) recorded start/run.start without framing
them, leaving the NDJSON stream missing its documented first event and
target metadata. Switch the controller across just the emit_start call
and restore immediately, mirroring the TEXT-only guard _run_prompt uses
so an explicit global quiet/verbose/screen-reader/json still wins.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 09:47:56 +00:00
MervinPraison 1b4ff4538b docs: auto-update documentation parity trackers [skip ci] 2026-10-07 09:46:43 +00:00
dajiaohuang 8293bd3eee fix: share adapter-owned SQLite memory databases across threads 2026-10-07 09:46:23 +00:00
praisonai-triage-agent[bot] b98b4d45f1 Merge remote-tracking branch 'origin/main' into fix/cached-prompt-turn-context 2026-10-07 09:46:05 +00:00
cb8cbaffbe feat: Ollama System One (Jev) decision models + praisonai-ts (#5401)
* feat: Ollama System One (Jev) decision models and triage routing

Add /v1/systemone client, ticket triage, CLI, praisonai-ts, optional typesafe-sdk extra, unit tests. Live-validated with tev1:0.8b.

* fix: harden System One decisions (security, routing, parity trackers)

- system_one: scope TYPESAFE_API_KEY to TYPESAFE_BASE_URL hosts only; validate
  base URL (reject non-http(s) and plain-HTTP to non-loopback); fix timeout
  sentinel so explicit 120s is honoured (None is the unset sentinel).
- routing: reject empty/non-choice route questions; trigger fallback when
  confidence is missing; preserve the prompt over extra_state in triage state.
- CLI: validate --route-question is a choice question; print JSON via builtin so
  global --json mode no longer swallows decision output.
- pyproject: include decisions extra in [all].
- TS: normalize bare OLLAMA_HOST with http scheme; use getChoice + own-key route
  check; await async function targets; export routing API from package root.
- Regenerate parity tracker artifacts (ts + rust) for new decision symbols.

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

Co-authored-by: Mervin Praison <[email protected]>

* chore: regenerate TS parity tracker after rebase on main

---------

Co-authored-by: Dhivya-Bharathy <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 10:44:37 +01:00
praisonai-triage-agent[bot]andMervin Praison 0283c5d538 fix: fail closed on implicit console approval off a TTY
The in-process text path derives the console approval backend when a
permission policy is present without an explicit --approval, but never
passed non_interactive. An unmatched `ask` rule would then block on
input() or read piped stdin as operator consent on a non-TTY. Mark the
implicitly-derived backend non-interactive off a TTY so it fails closed,
matching the existing comment's stated contract. An explicit --approval
keeps the backend's own interactive handling.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 09:43:30 +00:00
praisonai-triage-agent[bot]andMervin Praison ac3af43fcf fix(tools): bound only orphaned timeout workers, report capacity distinctly
The timeout-worker semaphore previously charged a slot for every timed tool
call and released it only when the worker exited, so 32 concurrent *healthy*
timed calls would refuse the 33rd — and misreport it as a per-tool timeout
(Greptile P1/P2 on #5589).

Now a slot is charged lazily at expiry and only when the worker is still
running (genuinely abandoned); it is released when that worker exits. Healthy
concurrent calls never count against the cap. Saturation surfaces a distinct
ToolCapacityError -> error_kind="capacity", so operators/models can tell
worker exhaustion from a slow tool. A lock makes the charge/release handoff
race-free between the worker finish and the deadline.

Added regression tests: many concurrent healthy timed calls are not refused,
and a saturated orphan pool reports "capacity" not "timeout".

Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 09:41:52 +00:00
praisonai-triage-agent[bot]andMervinPraison 7b1dd9d594 fix: unify sync->async bridges, stop tool-timeout thread leak, drop dead LLM.chat_history (fixes #5588)
- Route all hand-rolled sync->async bridges through the single
  utils.async_bridge.run_coroutine_from_any_context helper so contextvars
  propagate and provider errors surface unchanged (unified_adapters,
  execution_mixin, call_executor._resolve_value, agents, shared_compute,
  context aggregator, compaction, approval utils, eval trials, fast
  parallel executor, lsp tools).
- Run timed-out tools on a shared, bounded daemon worker so a hung tool
  cannot leak non-daemon threads or block interpreter exit.
- Remove the write-only, unbounded, unlocked LLM.chat_history list and its
  dead structured-output appends; the LLM layer is stateless w.r.t.
  conversation history (the Agent keeps its own locked, bounded history).
- Add regression tests asserting no module re-implements the bridge and
  that timeout workers are daemon threads.

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

Co-authored-by: MervinPraison <[email protected]>
2026-10-07 09:41:52 +00:00
352823ebd8 fix(session): persist explicitly cleared agent histories (#5543)
* fix(session): persist explicitly cleared agent histories

* fix(session): forward overwrite kwarg through SqliteSessionStore import

- SqliteSessionStore._save_imported_session now accepts and forwards the
  overwrite keyword to the base DefaultSessionStore, fixing a pre-existing
  TypeError regression surfaced after overwrite= was added to the base.
- Update delayed_save monkeypatch in test_sqlite_import_index to forward
  kwargs so the concurrent-write test matches the new signature.
- Retarget test_verified_replacement_succeeds_on_first_write at the
  merge_session_metadata_map locked-merge path (#5545) it now takes,
  preserving the single-write + peer-preservation guarantee.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 07:33:13 +01:00
Wu Shuwen 75fb69c6ce fix(session): validate hierarchy fork message indices (#5530) 2026-10-07 07:33:05 +01:00
praisonai-triage-agent[bot]andMervin Praison d7538082e3 fix: atomic locked writes for MCP auth & todos, isolate sync hooks (#5658)
* fix: atomic locked writes for MCP auth & todos, isolate sync hooks (fixes #5657)

- utils/atomic_io: add update_json, a locked (in-process mutex + FileLock)
  read-modify-write over atomic_write_json; corrupt files are moved aside,
  never silently treated as empty.
- mcp/mcp_auth_storage: route mutations through update_json so concurrent
  OAuth flows no longer lose entries and a crash mid-write keeps the previous
  file; tokens are never world-readable (temp file is 0600); corrupt store is
  moved to .corrupt and logged.
- tools/todo_tools: serialise todo_add/todo_update through update_json and
  derive ids from max(id)+1, so parallel adds all persist with unique ids and
  a torn write no longer resets the plan.
- hooks/runner: run sync hooks in a dedicated pool (not the loop default
  executor), preserve contextvars, and fail closed on a later call while a
  previous invocation is still stuck, capping the leak at one thread per hook.

Co-authored-by: Mervin Praison <[email protected]>

* fix: restore _save_todos for MCP adapter; close quarantine & hook races

- todo_tools: re-add TodoTools._save_todos (atomic_write_json) — the MCP CLI
  adapter does its own read-modify-write via _load_todos/_save_todos; removing
  it broke test_cli_surface_defects (AttributeError).
- atomic_io: quarantine corrupt files under a unique <path>.<pid>.<ns>.corrupt
  sidecar and raise if the move fails (never overwrite recoverable bytes);
  add shared quarantine_corrupt_file helper.
- mcp_auth_storage._read: quarantine under the writer FileLock with a re-read,
  so a store another process just repaired is never moved aside.
- hooks/runner: reserve the single in-flight slot under an asyncio lock BEFORE
  submitting and clear it via a done-callback, so concurrent invocations can't
  both launch a worker (cap bypass) and a cancelled/timed-out hook still holds
  its slot.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 07:32:56 +01:00
claktor[bot] 9e4ccea7da refactor(cli): share help renderers and error handler across commands 2026-10-06 14:25:46 +00:00
bde2501f6f fix: drain compute teardown before process exit (#5440)
* Drain compute teardown before the process bridge exits

* fix: synchronize GC-to-exit teardown handoff for compute release

Close the unsynchronised window where the GC finalizer submits the
teardown future before recording it in pending_release, letting a
concurrent exit callback return early and the bridge cancel the
in-flight shutdown. Guard both reclaim and reclaim_at_exit with a lock.

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

Co-authored-by: Mervin Praison <[email protected]>

* Coordinate release ownership and future publication at exit

* fix: retain process-owned bridge for compute cleanup

* test: collect coverage from compute exit subprocesses

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-05 21:41:24 +01:00
praisonai-triage-agent[bot]andMervin Praison f02e58fee7 fix: harden ACP session store, atomic JSON writes, and scheduler PID safety (#5660)
* fix: harden ACP session store, atomic JSON writes, and scheduler PID safety (fixes #5659)

Three validated correctness gaps in src/praisonai/praisonai/:

1. ACP SessionStore: validate client-supplied session_id against a
   server-minted format before interpolating into a filesystem path
   (blocks arbitrary *.json read/delete via traversal) and write session
   files + the .last_session pointer atomically.

2. Non-atomic JSON writes: route JSONConversationStore and
   SchedulerStateManager saves through atomic temp-file + os.replace so a
   crash/Ctrl-C mid-write can't zero conversation history or scheduler
   state; sanitize session_id / name before filename interpolation
   (reuses the existing _atomic_write_json helper for scheduler state).

3. Scheduler lifecycle races: record process start_time and compare it in
   is_process_alive / stop_daemon so a recycled PID is never signalled
   (prevents killing an innocent process), and make generate_unique_name
   race-free via O_CREAT|O_EXCL.

Changes are backward-compatible (start_time optional) and reuse existing
in-tree helpers/patterns; no new user-facing params.

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

Co-authored-by: MervinPraison <[email protected]>

* fix: close scheduler lifecycle + conversation-index gaps from review

- scheduler state: validate name in load_state/delete_state so a crafted
  name can't traverse out of the state dir to read/unlink an arbitrary
  *.json (greptile #2)
- schedule start: validate name BEFORE launching the daemon so an invalid
  name can't orphan a running daemon with no stoppable state file
  (greptile #1); use is_process_alive(pid, start_time) instead of a
  PID-only probe so a reused PID no longer falsely blocks a fresh start
  (greptile #5)
- stop_daemon/astop_daemon: re-verify PID ownership before SIGKILL so a
  PID reused during the SIGTERM wait is never force-killed (greptile #4)
- conversation json_store: reserve the "_sessions_index" id so a session
  can't overwrite the store's index file (greptile #6)

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-10-05 21:35:49 +01:00
dajiaohuang ade32c6ef7 Index ordered agent route pages without temporary sorting 2026-10-05 23:04:54 +08:00
dajiaohuang 05f477d99a Avoid shifting offsets in indexed session route listing 2026-10-05 22:29:08 +08:00
Andiii208 55463e47f5 fix: cover an unexpected raise with the json envelope
Greptile's review of #5666: when agent setup or `Agent.start()` raises during
`run --output json`, the envelope was only emitted on the normal result path, so
a script reading stdout got no `result` or `status` for that failure — the
opposite of the documented one-envelope-per-outcome behaviour, and for exactly
the failures that are hardest to diagnose.

The envelope emitter moves to a module-level helper that takes the run-scoped
`json_stdout` decision, so the exception handler shares one definition with the
result path instead of growing a near-duplicate. A nested `_emit` shim keeps the
call sites in the in-process branch readable. The exception now emits
`{"result": null, "status": "failed"}` before the stderr message, consistently
with the reported failure path.

Covered by `test_json_envelope_covers_an_unexpected_raise`, which raises from
`Agent.start` and asserts stdout is one parseable line with the exception text
on stderr. It was confirmed red before the fix.
2026-10-05 18:02:17 +08:00
Andiii208 9a482a42b4 fix: keep the failed json run a single document and honour --quiet
Two P1s from Greptile's review of #5666, both in the envelope I added.

**The failed run printed a Rich panel onto stdout.** I had claimed in the
review thread that `print_error` "already writes to stderr in every branch" —
that was wrong, and it was my own claim, not the reviewer's. When Rich is
available, `OutputController.print_error` renders a Panel via
`self.console.print(...)`, which goes to stdout; only the Rich-unavailable and
JSON-mode branches use `file=sys.stderr`. So a failed `run --output json`
emitted the envelope and then an error panel, and a script could parse neither
position. `_report_run_failure` takes `suppress_human` and sends the message and
remediation to stderr instead. The same guard is applied to the
`except Exception` handler at the end of `_run_prompt`, which had the same
panel. `_report_run_blocked` and `_report_run_truncated` needed no new
behaviour — they only emit warnings, already suppressed — but their parameter is
renamed `suppress_human` to match.

**`--quiet` was overridden.** The envelope ignored the quiet setting, so a
blocked, truncated or failed run printed JSON to stdout despite an explicit
request to suppress output. QUIET is now excluded from `_json_stdout`.
app.py resolves quiet above output_format and the `stream-json` wiring already
only replaced the neutral TEXT mode, so the per-command selector replacing human
TEXT output with machine output still must not override "be silent".

The output-controller stub now reproduces the real controller's stdout
behaviour for notices, including the Rich error panel. Stubs that silently
dropped `print_error` are precisely why the earlier round passed with a
corrupted stream.
2026-10-05 17:53:25 +08:00
Andiii208 85a98ed00c fix: keep the session usage footer out of the json envelope
CodeRabbit found that the envelope I added could still be preceded by
human output. `run --output json` keeps auto-save on, so
`_record_session_usage` has a session id and a recorded usage total, and it
prints the footer through `output.print_info`. That notice reaches stdout
because the controller is the human-facing one — `--output json` is a
per-command selector, so `is_json_mode` is False and the existing guard does
not catch it. The result was `INFO: 10 in / 32 out · $0.0000` on stdout
ahead of the envelope, making the stream invalid as a single JSON document.

Move the `_json_stdout` decision to the top of `_run_prompt` so it is known
before anything runs, and give `_record_session_usage` a `suppress_info` flag
for this case. The decision is now expressed without the event bridge (an
attached bridge means the controller is in STREAM_JSON mode, where it frames
its own result event instead), which is what makes it usable up front.

The suppression is scoped to the JSON run: a human run still gets its footer,
pinned by `test_usage_footer_still_shows_for_a_default_run`. The new envelope
test uses the default auto-save path rather than `no_save=True`, which is why
none of the earlier tests reached it.
2026-10-05 15:16:03 +08:00
Andiii208 448f6b6582 fix: emit the json envelope on every terminal outcome
CodeRabbit's review of #5666 found a real gap in the envelope I added: it was
only printed after a clean completion. A blocked, failed or truncated run calls
_report_run_blocked/_report_run_failure/_report_run_truncated, each of which
raises typer.Exit before that line is reached, so `run --output json` exited
non-zero with nothing machine-readable on stdout. A script that parses stdout
worked only on the happy path and got a bare failure otherwise, which is the
opposite of what an exit code is for.

Every terminal outcome now prints the same one-line `{result, status}` envelope
before the exit that reports it, using the status taxonomy the reporters already
carry ("refused"/"content_filtered"/"truncated"/"failed"). _report_run_blocked
and _report_run_truncated take suppress_warning so their human notice is not
mixed into the stdout a script is parsing; print_error already writes to stderr,
so the failure path needed no change.

The envelope also now covers `--output-format json` combined with a structured
selector (notably `--output stream-json`), which the mode wiring above makes
reachable: the global mode keeps precedence, so no NDJSON framing is installed,
but the envelope still has to reach stdout. `actions` is excluded because its
core status module renders the answer itself and a second envelope would
duplicate it. A plain run under `--output-format json` is deliberately left
alone — the controller's JSON-mode finalisation is a pre-existing gap shared by
every command, and redefining it here would widen a routing fix into a
cross-command output change. Both exclusions are pinned by tests.

Regression tests cover the three blocked/truncated reasons, the failed case, the
global-json combination, and both exclusions.
2026-10-05 14:44:59 +08:00
Andiii208 f76b82164f fix: scope the stream-json controller mode to the run and to the default mode
Two findings from Greptile's second pass on #5666, both defects in how the
mode is selected rather than in the selection itself.

**Global preference was overridden.** The switch ran whenever the controller
was not already in a JSON mode, so `run --quiet --output stream-json` (and
`--verbose`, `--screen-reader`) had its selected mode replaced and started
printing NDJSON events to stdout. app.py already resolves quiet/verbose/
screen-reader above output_format, so the per-command selector has to lose
there too. It now only replaces the neutral TEXT mode, which keeps the
precedence the rest of the CLI already applies.

**The mode leaked past the run.** `output.mode` is process-wide and was never
restored, so an embedded caller that ran `--output stream-json` and then a
plain-text run had the second one framed as NDJSON. The prior mode is now
saved and restored in the existing finally block, next to the
`--allow-local-tools` grant that is scoped the same way for the same reason.

Tests pin both: each explicit global mode is left alone and produces no NDJSON
framing, and a second plain-text run after a stream-json run gets plain text
with the controller back in TEXT mode. The earlier "global json wins" test is
subsumed by the parametrized one.
2026-10-05 13:52:17 +08:00
Andiii208 ee759f58ac fix: connect --output stream-json to the controller, gate every image run
Two findings from CodeRabbit's review of #5666.

`--output stream-json` names the NDJSON wire format but never selected it: the
event bridge only writes stdout in STREAM_JSON mode, and the mode is otherwise
reachable solely through the global `--output-format stream-json`. A user who
typed the per-command selector got a plain-text answer, which is the same
silent mismatch the README row was claiming. Set the controller's mode when the
per-command selector asks for stream-json and no explicit global JSON mode was
given, so the global flag still wins.

The image gate had the inverse problem: it was guarded by
`_direct_prompt_needs_wrapper`, which returns False for the structured modes
and for `--command`. Those image runs therefore skipped the targeted hint and
fell through to the wrapper path, failing on a standalone install with the
bare "praisonai.cli.legacy.direct_prompt requires the praisonai wrapper"
instead of the message that points at `run --output actions`. Every image run
that can reach the gate needs the wrapper — the combinations rejected outright
(--agent, --profile, a YAML target) already fail earlier with their own error
— so gate on `image` alone and drop the predicate that was hiding the cases it
did not cover.

Regression tests cover the mode selection (and the global flag overriding it),
every output mode's image run reaching the targeted hint, and the `--command`
variant. The output-controller stub now mirrors the real controller's
`is_json_mode`/event-printing behaviour instead of hard-coding
`is_json_mode = False`, which is what let the stream-json blind spot survive
the previous round.
2026-10-05 13:40:21 +08:00
Andiii208 c22a74ff86 fix: own the structured-mode output instead of delegating to core presets
Greptile's review of #5666 found two defects in the first cut of the
structured-mode routing, both confirmed against the core:

The core `json` output preset routes its JSONL to stderr —
praisonaiagents.output.status defaults to sys.stderr "to not interfere
with agent output" — so `run --output json` piped stdout to a script
delivered nothing at all. And the core `stream` preset sets the agent's
`stream` attribute, which makes `Agent.start()` return a generator; the
in-process branch treated that generator as the finished result, so the
model was never called and the run reported success with no output.

Neither core preset is usable from a CLI wire-format mode, so map only
`actions` onto a core preset and keep the agent display silent for
`json`/`stream`/`stream-json`, leaving the output to the CLI:

- `json` prints one single-line envelope (`result` + `status`) on stdout,
  the shape `code -p --output json` already emits, so a script reads it
  instead of scraping interleaved decorations.
- `stream`/`stream-json` print the answer, and `stream-json` still gets
  its NDJSON events from the bridge when the OutputController is in
  STREAM_JSON mode.
- The bare print is skipped whenever the event bridge is active, so the
  envelope never lands on top of an NDJSON stream it would corrupt.

`_prints_final_text` now covers these modes for the same reason it covers
the silent-style text modes: the agent renders nothing, so the CLI must.

Corrected the README's standalone row for these modes, which claimed
"(structured output)" while the run actually printed plain text.

Regression tests cover the routing, the per-mode stdout (no stderr noise,
no unconsumed generator), the single-line envelope, and the envelope
yielding to an active event bridge. The faithful-fake check that found
Greptile's two defects is reproduced as tests, not just as a manual probe.
2026-10-05 13:27:48 +08:00
Andiii208 9b7bc89667 fix: run structured output modes in-process (fixes #5665)
`praisonai-code run --output json|stream|stream-json` is documented as
working on a standalone install (the "Standalone limits" matrix in
src/praisonai-code/README.md), but the in-process fast path branched on
`output_mode == "actions"` alone, so the other three structured modes fell
through to praison.handle_direct_prompt and failed on a keyless standalone
box with "requires the praisonai wrapper. Install the full wrapper:
pip install praisonai". `_direct_prompt_needs_wrapper` already treated them
as never needing the wrapper, so the wiring and the predicate disagreed.

Widen the dispatch to `_IN_PROCESS_OUTPUT_MODES` and map each mode onto the
matching core Agent output preset: the `json` preset emits JSONL (the core's
piping/scripting mode) and `stream` streams the response. `stream-json` has
no core preset — that NDJSON framing is owned by the CLI's OutputController
and reaches stdout through the event bridge attached in this same block —
so its agent display stays silent instead of writing human text into the
event stream. The preset map is now the single source of truth for
`_IN_PROCESS_OUTPUT_MODES`, so the dispatch set and the presets cannot drift.

There is no behaviour to preserve on the wrapper path: the wrapper's
direct-prompt handler derives the Agent preset from `--verbose` only and
maps every `--output` mode to the same silent preset, then prints the raw
text answer. So `--output json` previously meant "print plain text", which
is exactly what the README row promised it was not. Structured modes now
dispatch in-process whether or not the wrapper is installed, so the promise
holds in both environments.

Covered by regression tests: each mode dispatches in-process with the
expected preset instead of delegating, structured modes never print the
final text themselves, and a wrapper-installed structured run stays
in-process too.
2026-10-05 13:12:42 +08:00
Andiii208 55830162d3 fix: honour --verbose in standalone default runs
CodeRabbit's review of #5664 flagged that the in-process text path selects
the Agent preset from `output_mode` alone, so `run --verbose "prompt"` took
the silent `minimal` preset on a standalone install and dropped the agent's
verbose display. The wrapper text path it replaces folds the flag in:
praisonai.cli.legacy.direct_prompt derives `output="verbose"` whenever
`args.verbose` is set and no explicit mode was selected, and falls back to
`minimal` otherwise.

Derive the same effective text mode here, and use it for both the preset
choice and the final-text print so the CLI does not print an answer the
agent already rendered. An explicit `--output plain|silent` wins over the
flag, matching the wrapper's "no explicit mode selected" condition.

Covered by regression tests in both directions: `--verbose` alone selects
the verbose preset without a second print, and an explicit output mode
still wins.
2026-10-05 13:12:15 +08:00
Wu Shuwen cfa1d131cc fix: retain indexed session routes when clearing turns (#5522)
Merged after rebase onto current main with minimal sqlite_store fix (stop de-indexing on clear_session).
2026-10-05 06:10:56 +01:00
Wu Shuwen 2135d03120 fix: isolate provider tool additions to each request (#5451) 2026-10-05 05:01:23 +01:00
praisonai-triage-agent[bot]andMervinPraison 1424ca016d fix: add resolved-config snapshot/export for gateway (fixes #5646) (#5648)
A running gateway's posture was split between the declared gateway.yaml and a
hidden per-host:port start-flags side file, so the YAML never described how the
gateway was actually running and no command rendered the resolved, merged,
redacted configuration. Add resolve_effective_config()/export_effective_config()
(wrapper) that merge declared YAML with the persisted CLI overrides into one
promotable document, and a `praisonai gateway config --resolved/--export` CLI.
Secrets are redacted by default via the existing diagnostics _SECRET_KEYS set
while non-secret values are preserved so the output stays a usable, commitable
config.

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-10-05 05:01:05 +01:00
Wu Shuwen 3e7fd41807 fix(storage): match SQLite key prefixes literally (#5560) 2026-10-05 04:29:00 +01:00
Wu Shuwen ca332ceebe Return SQLiteBackend clear count from DELETE rowcount (#5573) 2026-10-05 04:28:23 +01:00
Wu Shuwen 295ebf33ad fix: persist portable SQLite transcript operations in the database (#5518)
* fix: use SQLite transcript rows for portable session operations

* fix: preserve transcript import no-clobber and cache contracts

* fix(session): filter lineage exports before loading transcript rows
2026-10-05 04:27:47 +01:00
Wu Shuwen 1f2c9c58e6 fix(session): honor hierarchy retention and preserve snapshot transcripts (#5534)
* fix(session): honor hierarchy retention policies

* fix(session): restore hierarchy imports without retention loss

* fix(session): restore independent snapshot transcripts after retention

* fix: preserve imported live history when taking snapshots

* fix: pool durable snapshot transcript records

* fix: reject pooled snapshots in positional readers

* fix: reject incomplete pooled snapshot transcripts

* fix: validate pooled snapshot reference types
2026-10-05 04:27:11 +01:00
Wu Shuwen e8657b709b fix(session): recover structured spill messages and skip invalid UTF-8 (#5551)
* fix(session): recover structured spill messages and skip invalid UTF-8

* fix: distinguish JSON scalar types during spill recovery

* fix: avoid recursive keys during spill recovery
2026-10-05 04:26:35 +01:00
MervinPraison c739ae2f00 docs: auto-update feature parity trackers [skip ci] 2026-10-05 03:26:04 +00:00
c5c1f90932 fix(session): recover failed import indexes and preserve peer updates (#5628)
* fix: forward overwrite through indexed session imports

* fix: retain refreshed portable import cache generations

* fix: preserve newer peer indexes after failed import refresh

* fix(session): invalidate import indexes after SQL refresh failures

* fix(run): guard profiled YAML path against legacy re-dispatch recursion

The profiled YAML runner (_run_from_file_profiled) delegated to the legacy
PraisonAI.run() without setting the PRAISONAI_IN_MODERN_RUN sentinel, so the
legacy dispatcher would re-enter the modern Typer run app and recurse. Set and
restore the sentinel around the profiled run just like _run_from_file, and add
a focused regression.

Co-authored-by: Mervin Praison <[email protected]>

* fix(session): retain atomic index refresh in portable import repair

* fix(session): recover import file identity after path stat failures

* fix(session): preserve a peer refreshed index of the same generation

* fix(session): invalidate stale indexes after plain peer replacements

* fix: roll back failed outermost index refresh commits

* fix: avoid import backfill lock inversion and retry incomplete passes

* fix: retain successful backfill upgrades across retries

* fix: check transcript generation before skipping backfill

* fix(session): allow callback queries during import backfill

* refactor(session): isolate profiled YAML routing in separate PR

* test(session): proxy in_transaction in atomic-refresh fake connection

Main's new WAL atomic-refresh test monkeypatches a fake connection, but
this PR's _index_session reads conn.in_transaction before the savepoint
(for the owns_transaction rollback contract). Proxy that attribute to the
real connection so the merged test exercises both intents.

Co-authored-by: Mervin Praison <[email protected]>

* Verify imported session routes before using indexed bindings

* Retry failed import indexes from current durable transcripts

* Preserve import recovery through nested cleanup and unavailable transcripts

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-05 04:25:20 +01:00
dajiaohuang ec8492f334 fix: keep ephemeral text attachments out of automatic memory 2026-10-05 04:24:48 +01:00
dajiaohuang 6b2d140ef9 fix: retain prompt text and roll back memory after save exceptions 2026-10-05 04:24:48 +01:00
dajiaohuang af8028d41b fix(memory): retain only text from multimodal prompts 2026-10-05 04:24:48 +01:00
dajiaohuang b5ba8aa985 fix(memory): persist raw turns in the async after-agent hook 2026-10-05 04:24:48 +01:00
dajiaohuang 6e773b5037 fix: defer pending backpressure until memories need storage 2026-10-05 04:24:48 +01:00
dajiaohuang 5c18a399a6 fix: recover pending auto memory and serialize first use 2026-10-05 04:24:48 +01:00
dajiaohuang 917ecce48d fix: keep auto-memory processing off async event loops 2026-10-05 04:24:48 +01:00
Wu Shuwen 2c81572b1f Make automatic memory retries durable and serialize duplicate calls 2026-10-05 04:24:48 +01:00
Wu Shuwen b9e17884c4 Deduplicate automatic memories only after successful storage 2026-10-05 04:24:48 +01:00
Wu Shuwen 574be3c574 Reserve prompt formatting space before truncating content 2026-10-05 04:24:44 +01:00
Wu Shuwen 5c7f977c11 Enforce complete character limits for memory prompt contexts 2026-10-05 04:24:44 +01:00
dajiaohuang 52e6cc4dcd fix: preserve search availability after interrupted snapshots 2026-10-05 04:24:40 +01:00
dajiaohuang e4b81d564b fix: bound non-WAL search backup restarts between writes 2026-10-05 04:24:40 +01:00
dajiaohuang 79c6defa9c fix: preserve transcript backup fallback on Python 3.10 2026-10-05 04:24:40 +01:00
dajiaohuang 18402da579 fix: allow memory transcript writes between backup steps 2026-10-05 04:24:40 +01:00
dajiaohuang 319043a30e fix(session): complete non-WAL search snapshots and handle full storage 2026-10-05 04:24:40 +01:00
dajiaohuang 1c7c8ed31e fix: keep non-WAL search snapshots on disk 2026-10-05 04:24:40 +01:00
dajiaohuang a51114c9b3 fix: release non-WAL source snapshots before search scoring 2026-10-05 04:24:40 +01:00
dajiaohuang 50fba95352 fix: search transcript batches from a stable read snapshot 2026-10-05 04:24:40 +01:00
dajiaohuang 27f4144a4a fix: release transcript search lock during scoring 2026-10-05 04:24:40 +01:00
Wu Shuwen 6339230c53 fix: rank decoded transcript matches before limiting results 2026-10-05 04:24:40 +01:00
Wu Shuwen 482fe12e1b fix: align SQLite transcript candidates with search terms 2026-10-05 04:24:40 +01:00
Andiii208 47c19a2ff6 fix: enforce permission policies on in-process run paths
Greptile review on #5664 flagged that the in-process Agent path dropped
--allow/--deny/--permissions unless --approval was also selected, so a
standalone run's declarative policy was silently bypassed (the wrapper
delegation path never had this shape, but #5644 newly routes standalone
text runs here).

Derive the console backend when a permission policy is present but no
backend was selected — the same derivation `_run_from_file` already makes
for YAML runs — so deny/ask patterns are enforced. The console backend
fails closed on a non-TTY (unmatched patterns deny, EOF denies), and a run
without permission flags still gets no approval backend at all.

Covered by regression tests: a permission policy gates the in-process run,
and a plain run stays approval-free.
2026-10-05 02:31:57 +08:00
Andiii208 e6a97a6328 fix: render standalone praisonai-code text runs in-process (fixes #5644)
`pip install praisonai-code` alone used to hard-fail the default
human-readable run (`run "prompt"`, `--output plain/verbose/silent`) with
an install hint, because those modes delegated to the wrapper's
handle_direct_prompt. The structured `--output actions` path already ran
in-process, so standalone users were pushed onto the heavy `praisonai`
wrapper for the CLI's most basic command.

When the wrapper is absent, text modes now render in-process from the same
Agent loop the structured modes already use: the mode maps to a core
output preset (default/plain/silent -> minimal + CLI prints the final
text; verbose -> verbose), sessions/tools/MCP/approval wiring is shared
with the actions path, and `--memory` is honoured instead of silently
dropped. When the wrapper is installed, behaviour is unchanged — text runs
still delegate to handle_direct_prompt.

`--image` keeps a targeted gate (the vision path is wrapper-only).
C7 is unaffected: the praisonaiagents import stays function-local, like
the structured modes; check_c7_imports.sh passes with 0 wrapper import
lines. The smoke job's default-run assertion is updated accordingly (no
wrapper import error, reaches the in-process credential gate); chat stays
gated as before.

This overturns the gate kept by #2839/PR #2853 under the boundaries
proposed on the issue: wrapper-present behaviour unchanged, C7 enforced,
chat out of scope.

Tests: gate assertions flipped to the in-process contract, a new
standalone text-mode dispatch regression suite, and two existing tests
declare their environment explicitly (wrapper present / fake Agent).
2026-10-05 02:19:07 +08:00
dajiaohuang 54ffe0d4d6 test: wait for durable training completion before restart 2026-10-04 22:17:59 +08:00
dajiaohuang aa45765bfa Format reopen diagnostics consistently 2026-10-04 22:01:36 +08:00
dajiaohuang 8a0ba0b418 Merge remote-tracking branch 'origin/main' into followup/desktop-ci-main-oct03 2026-10-04 22:00:46 +08:00
dajiaohuang 239148f157 Merge remote-tracking branch 'origin/main' into fix/temperature-history-feedback-oct02 2026-10-04 21:57:26 +08:00
dajiaohuang f9f65f0482 Merge remote-tracking branch 'origin/main' into followup/redis-ci-main-oct03 2026-10-04 21:56:34 +08:00
dajiaohuang 79bd35f3ef Merge remote-tracking branch 'origin/main' into fix/checkpoints-main-feedback-oct02 2026-10-04 21:55:21 +08:00
dajiaohuang 41047a0842 Merge remote-tracking branch 'origin/main' into audit/rules-reload-timeout-oct03 2026-10-04 21:54:02 +08:00
dajiaohuang 06d78d542c Isolate the non-repository fixture from ancestor Git metadata 2026-10-04 21:32:42 +08:00
dajiaohuang 65cd31feec test: scan hook event references once per source file 2026-10-04 21:31:25 +08:00
dajiaohuang add77d55d1 docs: describe prompt cache regression checks 2026-10-04 16:40:28 +08:00
MervinPraison e49b254509 docs: auto-update documentation parity trackers [skip ci] 2026-10-03 18:05:16 +00:00
praisonai-triage-agent[bot] 55aff2bc1c Merge pull request #5656 from MervinPraison/claude/issue-5653-20261003-1713
refactor: extract shared lazy run-lock helper
2026-10-03 18:04:38 +00:00
praisonai-triage-agent[bot] c7be8d9047 Merge pull request #5652 from MervinPraison/claude/issue-5650-20261003-1616
fix: forward native tool-timeout to core (fixes #5650)
2026-10-03 17:56:00 +00:00
praisonai-triage-agent[bot]andMervin Praison 3bdfbb8d79 test: add regression tests for shared run-lock helper
Covers stable per-instance identity, both plain-attribute (AgentTeam) and
preset-None dataclass-field (Workflow) cases, and single-lock observation
under a concurrent first-access race (double-checked locking). Addresses
Greptile P2 finding that the shared concurrency path lacked a regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-03 17:22:34 +00:00
praisonai-triage-agent[bot]andMervin Praison 22dae2f83f fix: update stale references to removed dead helpers
- test_lazy_imports.py: reword docstring to describe the surviving
  __getattr__ lazy pattern instead of the removed resolve_tool_search.
- indexing.ts: point the estimateTokensSimple port comment at the live
  rag/budget.estimate_tokens heuristic now that indexing.py's duplicate
  is gone.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-03 17:21:18 +00:00
praisonai-triage-agent[bot]andMervinPraison 22ee4fe369 refactor: extract shared lazy run-lock helper (fixes #5653)
Consolidate the duplicate double-checked-locking idiom (_RUN_LOCK_INIT_GUARD
+ _execution_lock) from agents/agents.py and workflows/workflows.py into a
single praisonaiagents/_run_lock.py helper. Behaviour-preserving: getattr-based
access works for both the dataclass-field (Workflow) and plain-attribute
(AgentTeam) cases. No public API or behaviour changes.

Co-authored-by: MervinPraison <[email protected]>
2026-10-03 17:17:49 +00:00
praisonai-triage-agent[bot]andMervinPraison 4af8a87c48 fix: remove two unreferenced dead helpers from core SDK (fixes #5654)
Delete estimate_tokens_simple from knowledge/indexing.py (duplicate of the
live rag/budget.estimate_tokens) and resolve_tool_search from
config/feature_configs.py plus its __all__ entry. Neither is part of the
public package API and neither has any call sites. _resolve_tool_search_config
and the ToolSearchConfig lazy loader are kept intact.

Co-authored-by: MervinPraison <[email protected]>
2026-10-03 17:16:01 +00:00
praisonai-triage-agent[bot]andMervin Praison 1ff45385b5 fix: preserve subsecond native tool-timeout when forwarding to core (#5650)
The native adapter rounded the resolved per-agent budget via int(round())
before building ToolConfig, collapsing subsecond budgets (e.g. 0.4 -> 0).
Core treats a non-positive timeout as "no timeout" and the native callable
is left unwrapped, so enforcement was silently lost. Core consumes
ToolConfig.timeout directly as seconds (float) on both sync and async paths,
so forward the budget unrounded. Adds a subsecond regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-03 16:31:27 +00:00
praisonai-triage-agent[bot]andMervinPraison 6b6c3f5113 fix: forward native tool-timeout to core instead of wrapper-side pool (fixes #5650)
On the praisonai-native execution path, a plain callable already flows
through core's tool executor (agent/tool_execution.py), which enforces
the per-tool timeout with the same abandon-on-expiry semantics. The
wrapper no longer stands up a second ThreadPoolExecutor for those tools:
it leaves native callables unwrapped and forwards the resolved per-agent
tool_timeout to the core Agent via tool_config=ToolConfig(timeout=...).

Framework tool objects (CrewAI/AutoGen/LangChain BaseTool) never reach
core's executor, so they are still wrapped in place to preserve their
args_schema. Non-native frameworks keep the existing wrapper-side guard.
No new API surface; backward-compatible (flag defaults off).

Co-authored-by: MervinPraison <[email protected]>
2026-10-03 16:23:56 +00:00
pei711 a1fc1d8d6a fix(ts): discard stale memory snapshots before initialization retries
Reproduce a second writer deleting and compacting the log after a failed startup rename. Rebuild entries from the current log on each attempt. Implemented and tested with Codex.
2026-10-03 21:40:24 +08:00
dajiaohuang 4b3e877b45 test: cover YAML permissions in the wrapper suite 2026-10-03 21:29:33 +08:00
pei711 a88392899d fix(ts): await startup compaction before marking memory ready
Preserve the maintainer bot refactor and add deterministic persistence regressions for concurrent add/delete and failed rewrite retry. Implemented and tested with Codex.
2026-10-03 21:27:23 +08:00
praisonai-triage-agent[bot]andMervin Praison cfaea47e7f fix(ts): serialize FileMemory init with compaction and allow retry
- Cache in-flight init promise so concurrent add()/delete() await the
  threshold-triggered compaction and are not lost on reload
- Reset initialized/initPromise on failure so a later call can retry
  after a transient filesystem error
- Extract writeCompactedFile() so compaction no longer re-enters
  initialize(), removing the recursion path
- Relax reload test timeout to 10s for slow CI/Windows filesystems

Co-authored-by: Mervin Praison <[email protected]>
2026-10-03 13:15:34 +00:00
pei711 b1f7d8ca13 fix(ts): finish FileMemory initialization before compaction
Implemented and tested with Codex.
2026-10-03 21:08:20 +08:00
dajiaohuang 784b059df7 fix: retry compression for surviving snapshot records 2026-10-03 21:05:03 +08:00
dajiaohuang 810a1eb935 fix: report compression success only for retained summaries 2026-10-03 21:01:16 +08:00
dajiaohuang 83ca5046e1 fix: validate compression snapshot and retained summary before deletion 2026-10-03 20:39:28 +08:00
dajiaohuang 9f3e6cc3eb fix: reject nonpositive Redis turn lock lifetimes 2026-10-03 20:35:03 +08:00
dajiaohuang fe0a2d3900 fix: preserve explicit null YAML approval timeout 2026-10-03 20:27:12 +08:00
dajiaohuang e7998746b6 fix(cli): retain YAML permission rules through legacy approval 2026-10-03 20:15:22 +08:00
MervinPraison 566e94edf2 docs: auto-update documentation parity trackers [skip ci] 2026-10-03 11:09:21 +00:00
praisonai-triage-agent[bot] 6a7d65202b Merge pull request #5426 from MervinPraison/claude/issue-5424-20261001-0941
fix: ignore invalid Retry-After numeric hints that poison backoff and scheduler holds
2026-10-03 11:08:48 +00:00
praisonai-triage-agent[bot] 6fdad4d964 Merge pull request #5411 from MervinPraison/claude/issue-5409-20261001-0902
fix: preserve StepResult stop flag and handler variables on workflow cache hits
2026-10-03 11:08:45 +00:00
praisonai-triage-agent[bot] 7886e17f26 Merge pull request #5558 from MervinPraison/claude/issue-5556-20261002-0141
fix: skip invalid UTF-8 transcripts during session search
2026-10-03 10:38:01 +00:00
praisonai-triage-agent[bot] 27c6045e50 Merge pull request #5579 from MervinPraison/claude/issue-5577-20261002-0304
fix: recover from invalid UTF-8 in JSON stores
2026-10-03 10:37:51 +00:00
praisonai-triage-agent[bot] d38b3cbcf6 Merge pull request #5647 from MervinPraison/claude/issue-5645-20261003-0914
fix: make AgentRegistry inbound routing thread-safe
2026-10-03 10:37:47 +00:00
praisonai-triage-agent[bot]andMervin Praison 921608ebe0 test: add shared-key contention regression test for AgentRegistry lock
Address greptile P2: the existing concurrent test used a distinct number per
worker (no shared-key contention) and accepted the fallback, so it could pass
even with the lock removed. Add a reader/writer test where writers churn an
overlapping key set while many readers snapshot concurrently — this surfaces
the exact race (RuntimeError: dictionary changed size during iteration / torn
resolve) that the per-instance lock + __iter__ snapshot prevent.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-03 09:21:38 +00:00
praisonai-triage-agent[bot]andMervinPraison 58ee5bc269 fix: make AgentRegistry inbound routing thread-safe (fixes #5645)
Guard the phone-number→agent routing table with a per-instance
threading.Lock so concurrent inbound turns cannot race on the shared
dict, matching the lock pattern every sibling gateway store already uses.
__iter__ now snapshots over a copy to avoid torn reads under mutation.

Co-authored-by: MervinPraison <[email protected]>
2026-10-03 09:16:10 +00:00
MervinPraison 8a5f427208 docs: auto-update documentation parity trackers [skip ci] 2026-10-03 08:56:37 +00:00
praisonai-triage-agent[bot]andMervinPraison 945d3ea3b2 fix: async-safe sync hooks/compaction + bounded session cache (#5641)
* fix: async-safe sync hooks/compaction + bounded session cache (fixes #5640)

- hooks/runner.execute_sync: route through the canonical
  run_coroutine_from_any_context bridge instead of raising inside a
  running event loop, so blocking policy/guardrail hooks still run (and
  can deny) from FastAPI/aiohttp/Jupyter/bot callbacks.
- compaction/compactor.compact: route LLM_SUMMARIZE through the same
  bridge so the user-configured summariser runs in both sync and
  async-host contexts; naive fallback is kept only for real failures and
  is now logged.
- session/store.DefaultSessionStore: bound the in-memory cache with an
  LRU (default 256, cache_maxsize=0 disables) so a long-lived
  multi-tenant process no longer retains every session in RAM.

Co-authored-by: MervinPraison <[email protected]>

* fix: bound hierarchical extended cache + add bridge timeout backstops

- hooks/runner.execute_sync: replace timeout=None with a bounded bridge
  timeout (default_timeout per hook + headroom) so a stuck hook can never
  pin the caller's thread indefinitely (Greptile P1).
- compaction/compactor.compact: drop timeout=None on the LLM summariser
  bridge call so a hung summariser falls back to the naive summary via the
  existing except handler instead of blocking forever (Greptile P1).
- session/hierarchy.HierarchicalSessionStore: bound _extended_cache with the
  same LRU policy/maxsize as the base _cache and prune _cache_mtimes on
  eviction via a new on_evict hook, so the subclass no longer retains every
  loaded session in RAM (Greptile P2).
- tests: assert extended cache stays bounded, reloads after eviction, and
  stays unbounded when cache_maxsize=0.

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-10-03 09:55:54 +01:00
praisonai-triage-agent[bot]andMervin Praison 2131cae365 fix: accumulate retried handler variables and isolate mutable cache values
Addresses two correctness gaps in the linear workflow step cache (PR #5411):

- P1 (retried variables): accumulate handler variables across guardrail
  retries instead of replacing, so a cache hit replays the same union of
  variables the cold run left in scope.
- P2 (mutable corruption): deep-copy cached variable values on set and on
  replay (falling back to the live reference when deepcopy fails), so a
  downstream step mutating a replayed object cannot corrupt what later hits see.

Adds 2 regression tests (rejected-retry variable union, mutable isolation).

Co-authored-by: Mervin Praison <[email protected]>
2026-10-03 08:13:49 +00:00
praisonai-triage-agent[bot]anddajiaohuang e86069943b fix: preserve StepResult stop flag and handler variables on workflow cache hits (fixes #5409)
The linear executor cached only output and the generated output variable,
and on a hit appended only {step, output} then continued unconditionally.
This dropped stop_workflow, handler-supplied variables, status, and retries.

Now the cache snapshots the full step delta (stop signal, handler variables,
status, retries, and -- unless the step stopped first -- the output variable)
and the hit path replays it exactly, so a cached run is indistinguishable
from re-executing.

Co-authored-by: dajiaohuang <[email protected]>
2026-10-03 08:11:46 +00:00
praisonai-triage-agent[bot]andMervin Praison f59b6e9404 fix: preserve valid message Retry-After hints while rejecting negatives (#5424)
The sign-aware message patterns from the prior fix introduced two regressions
flagged by Greptile (P1):
- Valid delays went unmatched: the "(-?\d+)...second" pattern required the
  number adjacent to "second", dropping windows like "retry in 30 more seconds".
- Later valid hints were skipped: re.search saw only the first match, so an
  echoed negative ("retry after -5 seconds; retry after 30 seconds") shadowed a
  valid later 30s hint and returned None.

Fix: allow intervening word tokens before "second", capture fractional seconds
inside the group, and scan every match per pattern (re.finditer) so an earlier
invalid hint no longer masks a later usable one. Negatives/nan/inf remain
rejected via _usable_retry_delay. Added regression tests for all three cases.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-03 08:10:03 +00:00
praisonai-triage-agent[bot]andMervin Praison 859edd76de fix: reject echoed negative Retry-After in message fallback (#5424)
The message-fallback parser captured only digits, so a provider that echoed a
negative Retry-After in its error text (e.g. "retry after -3600 seconds") was
read as a positive delay and could park a scheduler hold on an invalid reset
window. Capture the optional leading sign and route the result through
_usable_retry_delay so negatives are rejected consistently with the structured
header/attribute sites, falling through to None.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-03 08:08:33 +00:00
praisonai-triage-agent[bot]anddajiaohuang fce6751700 fix: ignore invalid Retry-After numeric hints to protect backoff/scheduler (fixes #5424)
extract_retry_after() accepted non-finite (nan/inf) and negative
structured numeric hints, which poisoned classify_llm_error() backoff
and quota_hold_from_failure() scheduler holds. Validate structured
header and attribute hints are finite and non-negative; invalid values
are ignored and fall through the existing fallback order (attribute ->
message -> None). Preserves cap, HTTP-date handling and zero/fractional
delays.

Co-authored-by: dajiaohuang <[email protected]>
2026-10-03 08:08:22 +00:00
dajiaohuang 8a750f29c9 fix: include learning refresh failures in warnings 2026-10-03 08:07:48 +00:00
dajiaohuang 0c1a2b9a16 fix(memory): preserve learning cache on strict refresh failures 2026-10-03 08:07:48 +00:00
Wu Shuwen 84e2eaf6da Propagate malformed learning record errors during retrieval 2026-10-03 08:07:22 +00:00
Wu Shuwen e362c75412 Keep learning retrieval read-only when refresh fails 2026-10-03 08:07:22 +00:00
Wu Shuwen 3bf7205adc Refresh learning stores before telemetry and targeted writes 2026-10-03 08:07:22 +00:00
praisonai-triage-agent[bot]andMervin Praison c4471bd58d test: deterministically cover LIKE candidate branch for invalid-UTF-8 skip
Patch _init_schema to report FTS5 unavailable in one loop iteration so the
LIKE fallback branch in _candidate_ids is exercised even on SQLite builds that
ship FTS5, and assert _fts_available to prove the intended branch runs
(addresses greptile finding on #5558 / Issue #5556).

Co-authored-by: Mervin Praison <[email protected]>
2026-10-03 08:05:57 +00:00
praisonai-triage-agent[bot]anddajiaohuang eeffecf339 fix: skip invalid UTF-8 transcripts during session search (fixes #5556)
DefaultSessionStore.search and SqliteSessionStore.search skipped malformed
JSON and file I/O errors but UnicodeDecodeError (a ValueError subclass, not
OSError) escaped the two transcript-reading boundaries, aborting search when
any candidate contained invalid UTF-8. Add UnicodeDecodeError to both except
tuples so unreadable transcripts are skipped, consistent with existing
malformed-JSON handling. Covers the direct JSON scan and indexed candidate
loading (FTS5 and LIKE branches).

Co-authored-by: dajiaohuang <[email protected]>
2026-10-03 08:05:57 +00:00
praisonai-triage-agent[bot]andMervin Praison 1bed1178af fix: recover from invalid UTF-8 in FileBackend + add regression tests
- FileBackend.load() now catches UnicodeDecodeError (returns None), matching
  the direct-file path and closing the backend-route gap noted in review.
- Add TestInvalidUtf8Recovery covering sync (locked/unlocked), FileBackend,
  BaseJSONStore-over-FileBackend, and async load_async() paths; all assert
  recovery to defaults while preserving the original bytes on disk.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-03 08:05:47 +00:00
MervinPraison 595addffdb docs: auto-update feature parity trackers [skip ci] 2026-10-03 07:48:08 +00:00
MervinPraison 0b65ff7ed5 docs: auto-update documentation parity trackers [skip ci] 2026-10-03 07:47:25 +00:00
Carl Calaquian 1b3f0c70f8 fix(sandbox): update tenki sdk and honor idle shutdown 2026-10-03 08:44:58 +01:00
praisonai-triage-agent[bot]andMervin Praison 47970d7ade test(memory): cover os.replace failure cleanup in FileMemory atomic write
Adds coverage for the OSError path flagged in review: a failing
os.replace must leave the destination unchanged, remove the staging
temp file, and return False.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-03 08:44:55 +01:00
Wu Shuwen 131c972cd6 Clean atomic FileMemory staging files after serialization errors 2026-10-03 08:44:55 +01:00
dajiaohuang a65cfaaf4b fix: preserve falsey values in native tool results 2026-10-03 08:44:46 +01:00
Wu Shuwen bf15ea50f2 fix: surface hierarchy persistence failures before returning IDs (#5528)
* Report hierarchy persistence failures before returning identifiers

* Reject self-parent session creation before persistence
2026-10-03 08:44:09 +01:00
40eae2d0e3 fix(storage): preserve unreadable file-backed JSON after fallback (#5578)
* fix: recover default JSON stores from invalid UTF-8

* test(storage): exercise async UTF-8 recovery without requiring aiofiles

Replace importorskip with a fixture that injects a minimal real-decode
aiofiles stub when the optional package is absent, so the async
UnicodeDecodeError/JSONDecodeError recovery branch is always covered in
CI instead of silently skipped.

Co-authored-by: Mervin Praison <[email protected]>

* test: install real aiofiles for JSON store unit coverage

* fix(storage): refuse default saves over unreadable JSON files

* fix: preserve explicit JSON store clearing after read failures

* Handle invalid UTF-8 in FileBackend loads

* fix(storage): protect unreadable records from default saves

* test(storage): align main's invalid-UTF-8 test with unreadable-record guard

Resolve semantic rebase conflict: main added test_file_backend_load_invalid_utf8_returns_none
which re-saved the same key after a failed load; the PR's guard now refuses that
overwrite. Use distinct keys per iteration to preserve the test's full intent
(return None, warn, preserve bytes, neighbor loads) under the new guard.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-03 08:43:37 +01:00
Wu Shuwen 99b1242319 fix: preserve workflow step state on cache hits (#5410)
* fix: preserve workflow step state on cache hits

* fix: keep uncacheable workflow state out of shared entries

* fix(workflows): snapshot custom cache payloads at each boundary
2026-10-03 08:43:08 +01:00
0a483ed08f fix: reject invalid Retry-After delay hints (#5425)
* fix: reject invalid retry-after delay hints

* fix: reject negative Retry-After hints in message fallback

Message-fallback patterns captured only unsigned digits, so a negative
hint like "-3600 seconds" was read as a positive delay despite the new
header/attribute validation. Capture the full signed (and fractional)
token so negatives are rejected by the finite/non-negative check and
fall through, preventing an invalid scheduler hold.

Co-authored-by: Mervin Praison <[email protected]>

* Parse complete numeric tokens in retry delay messages

* fix: reuse retry hint parsing in LLM retry loops

* fix: validate retry duration field and sentence boundaries

* fix: skip invalid retry hints before later valid durations

* fix(llm): normalize negative retry delay caps

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-03 08:43:05 +01:00
Wu Shuwen 3dc6a994bd fix: preserve Responses refusals and terminal outcomes (#5472)
* fix: preserve Responses refusal signals and terminal outcomes

* fix: clear response refusal before tool continuation

* test: isolate Responses refusal endpoints

* fix: retain incomplete outcomes alongside Responses refusals

* fix: isolate concurrent LLM terminal outcomes

* fix: observe async outcomes after a synchronous turn

* fix: preserve outer outcomes across nested tool requests

* fix: initialize outcome scopes for constructor-free callers
2026-10-03 08:43:01 +01:00
38cf60970e fix(session): preserve recall and reject incomplete transcript exports (#5557)
* fix(session): skip invalid UTF-8 transcript search candidates

* fix(session): isolate invalid UTF-8 in directory reads and exports

* fix(session): log skipped unreadable transcripts during search

Surface invalid-UTF-8/corrupt transcript skips via a debug log in both
the JSON scan and indexed candidate loader so a corrupt session is
observable instead of an indistinguishable silent miss. Search stays
read-only (no quarantine/rewrite), preserving corrupt bytes.

Co-authored-by: Mervin Praison <[email protected]>

* fix(session): page recall candidates and report corrupt scan entries

* fix(session): bound readable recall candidates with a stable cursor

* Preserve search lineages and release index lock before transcript work

* Stream search IDs from isolated SQLite readers

* Spool matching search IDs before transcript work

* Enumerate WAL search IDs on a separate reader connection

* fix: atomically refresh session indexes and reject incomplete exports

* fix(session): report atomic index refresh outcomes

* fix(session): score indexed continuations without retaining payloads

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-03 08:41:50 +01:00
b549d3cac6 fix(session): validate hierarchy cache against loaded JSON content (#5536)
* fix(session): validate hierarchy caches against loaded JSON bytes

* fix: retain hierarchy cache across contention and local writes

* fix(session): forward overwrite kwarg in SqliteSessionStore import

SqliteSessionStore._save_imported_session did not accept the overwrite
keyword that import_sessions forwards to the base implementation, causing
TypeError on every SQLite import. Forward the kwarg and update the stale
test monkeypatch wrapper to match the base signature.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-03 08:41:46 +01:00
Wu Shuwen 2216795fd0 fix: preserve base session fields in hierarchy deserialization (#5526)
* Preserve the full base session schema in hierarchy conversions

* Verify archives survive hierarchy reload and mutation
2026-10-03 08:41:43 +01:00
dajiaohuang 8ef9a0da83 fix: renew Redis turn leases independently of waiter polling 2026-10-03 08:41:00 +01:00
dajiaohuang 4fa90fbbd0 fix: retain Responses schemas for the requested model 2026-10-03 08:40:57 +01:00
dajiaohuang 791b967a90 fix: configure structured output for the requested model 2026-10-03 08:40:57 +01:00
dajiaohuang 97c4bea08e fix: serialize peer file-memory compression commits 2026-10-03 08:40:35 +01:00
Wu Shuwen 2b16f4efac Preserve all summarized and intervening FileMemory records 2026-10-03 08:40:35 +01:00
Wu Shuwen 0794c42ebb Fix FileMemory compression with zero retained items 2026-10-03 08:40:35 +01:00
Wu Shuwen 85397b5abc Recognize home-directory rule imports 2026-10-03 08:40:27 +01:00
dajiaohuang fa9df9038c fix(cli): preserve profiled YAML approval and guard cleanup 2026-10-03 13:59:22 +08:00
dajiaohuang 4e0c2850cf fix(cli): guard profiled YAML legacy reentry 2026-10-03 09:15:45 +08:00
dajiaohuang 0d251e9d09 Merge commit '701bfba56dade9d853148fa63e4e6a96645de21e' into audit/rules-reload-timeout-oct03 2026-10-03 07:30:40 +08:00
dajiaohuang 3004e2fc27 test: cover changed default tools on cached prompts 2026-10-03 06:42:32 +08:00
dajiaohuang b21c452ee7 Merge main to include smoke timeout correction 2026-10-03 06:34:33 +08:00
dajiaohuang 64668f3af9 Merge main to include smoke timeout correction 2026-10-03 06:30:58 +08:00
dajiaohuang eed879ff4e Merge main to include fork conflict-check handling 2026-10-03 06:18:17 +08:00
dajiaohuang 1927a1f714 Merge main to include fork conflict-check handling 2026-10-03 06:16:10 +08:00
dajiaohuang 46ebffe199 fix: retain manual lookup of workspace root instructions 2026-10-03 04:04:57 +08:00
dajiaohuang f2f7a0aa69 fix: keep invalid rule frontmatter out of automatic context 2026-10-03 03:51:41 +08:00
dajiaohuang c6e717e282 fix: retain documents with non-mapping YAML frontmatter 2026-10-03 03:49:40 +08:00
dajiaohuang e0f1537961 merge main into YAML metadata fixes 2026-10-03 03:33:38 +08:00
dajiaohuang 3327dec559 fix: preserve rule and documentation YAML metadata 2026-10-03 03:33:37 +08:00
701bfba56d fix(ci): tolerate 403 clearing conflict-pending on fork PRs (#5638)
* fix(ci): tolerate 403 when clearing conflict-pending label on fork PRs

Fork pull requests can deny label removal; swallow 403/404 so detect-and-trigger does not fail the required check.

Co-authored-by: Cursor <[email protected]>

* fix(ci): only tolerate 403/404 clearing conflict label on fork PRs

Narrow the swallow to genuine forks (headRepo != baseRepo) so upstream
permission failures surface instead of silently leaving the merge-blocking
claude-conflict-pending label in place.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-02 18:38:40 +01:00
b981fcf54a fix: default gateway agent model to detected provider, not OpenAI (#5611)
* fix: default gateway agent model to detected provider, not OpenAI (fixes #5609)

The gateway agent config schema defaulted `model` to the hardcoded
`gpt-4o-mini`, and onboarding wrote that literal into every generated
config, so an operator with only a non-OpenAI credential was handed a
config that could not work.

- AgentConfigSchema.model now defaults to None; new resolved_model()
  (and "auto") resolve to the detected provider's model via the existing
  default_model_for_available_provider() helper (reached through the lazy
  _code_bridge), with gpt-4o-mini only as last-resort fallback.
- Gateway server, bot CLI and the startup preflight route an unset/"auto"
  model through the same detection instead of assuming OpenAI.
- Onboarding YAML leads with the detected model, and the failure hint
  names the detected provider's API key env var instead of OPENAI_API_KEY.

An explicit model: (or --model) always wins; detection only fills the gap.

Co-authored-by: Mervin Praison <[email protected]>

* chore: regenerate gateway.schema.json after rebase onto #5416

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-10-02 18:29:40 +01:00
praisonai-triage-agent[bot] 3983e45d08 Merge pull request #5417 from MervinPraison/claude/issue-5415-20261001-0914
fix: wire progressive streaming replies for Discord & Slack
2026-10-02 17:29:26 +00:00
praisonai-triage-agent[bot] 88e858adb9 Release v4.7.12 2026-10-02 16:34:56 +00:00
MervinPraison f9ed8ff862 docs: auto-update documentation parity trackers [skip ci] 2026-10-02 16:33:53 +00:00
Wu Shuwen dadac293e3 fix: surface Responses failures before accepting partial output (#5477)
* fix: surface Responses failures before accepting partial output

* fix: close Responses streams on consumer exit

* fix: surface native failed Responses without fallback replay

* test: defer optional HTTPX import until stream test execution

* fix: preserve dictionary Responses output in failure controls

* fix: surface failed native Responses during streaming

* Preserve token usage from dictionary Responses
2026-10-02 17:33:11 +01:00
praisonai-triage-agent[bot] 1ead4131e8 Merge pull request #5626 from MervinPraison/claude/issue-5617-20261002-1135
fix: add CI perf-gate flags to agent benchmark (#5617)
2026-10-02 16:24:59 +00:00
praisonai-triage-agent[bot] 2432197239 Merge pull request #5636 from MervinPraison/claude/issue-5632-20261002-1333
fix: capture re-entrancy sentinel before guarded import in YAML runner
2026-10-02 16:17:09 +00:00
praisonai-triage-agent[bot] 82574988f8 Merge pull request #5630 from MervinPraison/claude/issue-5624-20261002-1138
perf(agents): skip plugin-manager scan in Agent init when no plugins registered
2026-10-02 16:16:55 +00:00
MervinPraison 698aef54b8 docs: auto-update documentation parity trackers [skip ci] 2026-10-02 15:59:46 +00:00
praisonai-triage-agent[bot]andMervin Praison 96e76b535a fix: publish gateway.yaml/bot.yaml JSON Schema for editor + CI validation (#5416)
* fix: publish gateway.yaml/bot.yaml JSON Schema for editor + CI validation (fixes #5414)

Bring the gateway/bot config surface to parity with agents.yaml:
- Add gateway_config_json_schema() deriving a draft-07 schema from the
  canonical Pydantic GatewayConfigSchema (single source of truth).
- Add GATEWAY_SCHEMA_URL / GATEWAY_SCHEMA_HEADER mirroring AGENTS_SCHEMA_*.
- Add `praisonai gateway schema [-o FILE]` CLI command.
- Scaffold the yaml-language-server header into onboarded configs and the
  shipped gateway.yaml sample.
- Publish gateway.schema.json artefact and include it as package-data.

Co-authored-by: MervinPraison <[email protected]>

* fix: align published gateway schema with runtime validation (#5414)

Post-process the Pydantic-derived gateway/bot JSON Schema so the published
artefact faithfully reflects what the gateway actually validates, closing the
author-time/runtime gaps flagged in review:

- Inline GatewayServerSchema/HookSchema (extra="forbid") into gateway/hooks so
  a misspelled server knob (drain_timout) or hook key is caught while authoring,
  not only at startup.
- owner_user_id (top-level + channel) accepts an integer, mirroring the runtime
  str() coercion of unquoted numeric Telegram/Discord ids.
- Reject unknown top-level keys so a typo (auto_enable_from_en) the runtime
  silently ignores is surfaced; config_version (stamped) is allowed and channels
  keep additionalProperties open for plugin-declared keys.
- Allow array env-ref channel fields (allowed_users/allowlist/blocklist) to be a
  single ${VAR} string so scaffolded configs validate pre-substitution.

Regenerate committed gateway.schema.json. Shipped sample + onboard scaffolds now
validate clean; all three findings fixed.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-10-02 16:56:14 +01:00
praisonai-triage-agent[bot]andMervin Praison fdbbcbf52d fix: opt-in per-reply runtime footer for gateway bots (#5437)
* fix: add opt-in per-reply runtime footer for gateway bots (fixes #5428)

Surface a privacy-safe footer (model · latency) on the final reply only,
reusing the already-collected model name and measuring turn latency at the
bot turn site. Off by default via the existing BotConfig.footer knob.

Co-authored-by: MervinPraison <[email protected]>

* fix: exclude runtime footer from voice/TTS and cover slash-command replies

Address reviewer findings on the opt-in gateway-bot footer:

- Voice replies no longer narrate the footer. The footer is now appended to
  the delivered TEXT only (inside _send_response_with_media via a footer_line
  param), never to the pre-footer text handed to TTS/media synthesis. This also
  makes the streaming and non-streaming voice paths behave identically.
- File-based / unknown slash-command agent replies now include the footer,
  routed through the same delivery call with a per-turn latency start.
- Add handler-level delivery tests (opt-in gating, model/latency capture, and
  footer-in-text-not-voice) alongside the existing formatting unit tests.

Scope intentionally kept minimal per AGENTS.md: gateway channel-config wiring
and non-Telegram channel adapters remain out of scope (the PR is a deliberate
Telegram-only opt-in; the shared BotConfig.footer flag is a no-op on channels
that have not wired it, matching other per-channel BotConfig knobs).

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-10-02 16:52:55 +01:00
praisonai-triage-agent[bot]andMervin Praison 2c91e0cc4f fix: add --json to gateway status and report real version (#5368)
* fix: add --json to gateway status and report real version (fixes #5363)

- Add --json to `gateway status`, emitting a single machine-readable
  snapshot (version, protocol_version, uptime, sessions, clients,
  per-channel health, degraded owners, delivery back-pressure, config
  revision + drift) built from /health + /info + daemon status.
- Replace the hardcoded "1.0.0" version on GET /info and MCP serverInfo
  with the installed praisonai-bot version via a single helper.
- Add protocol_version (GATEWAY_PROTOCOL_VERSION) to /info.

Co-authored-by: MervinPraison <[email protected]>

* fix: load persisted gateway token for status --json /info + guard version regressions

- Resolve GATEWAY_AUTH_TOKEN from ~/.praisonai/.env when not exported so an
  authenticated gateway's /info answers and status --json reports version
  (greptile P1). Surface version_unavailable="info_unreachable" when /health
  is reachable but /info is not, so a missing version is explicit not silent.
- Strengthen MCP initialize test to assert serverInfo.version == installed
  version and != "1.0.0" (greptile P2), guarding the endpoint from regressing
  back to a hardcoded value.
- Add token-resolution + version-unavailable snapshot tests.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-10-02 16:49:42 +01:00
MervinPraison 27206ed5bd docs: auto-update documentation parity trackers [skip ci] 2026-10-02 15:40:59 +00:00
dajiaohuang 2a38665eda Merge remote-tracking branch 'origin/main' into fix/frontmatter-main-feedback-oct02 2026-10-02 23:24:12 +08:00
Wu Shuwen 1fca8e2706 fix(memory): enumerate SQLite records and preserve MCP user filters (#5479)
* fix: enumerate all SQLite memory records without a search cap

* fix: filter SQLite memory users before display limits

* fix: use compatible metadata parsing for SQLite user filters

* fix: keep null memory owners distinct from literal user IDs

* fix(memory): filter explicitly supplied falsey user identifiers

* fix(mcp): preserve explicit memory user filters
2026-10-02 16:23:10 +01:00
dajiaohuang 3e2c469d04 Merge remote-tracking branch 'origin/main' into fix/checkpoints-main-feedback-oct02 2026-10-02 23:17:25 +08:00
dajiaohuang d7616e0f1a Merge remote-tracking branch 'origin/main' into fix/delete-main-feedback-oct02 2026-10-02 23:13:21 +08:00
dajiaohuang b06fdd8571 Initialize temperature in Responses history builder fixture 2026-10-02 23:08:51 +08:00
dajiaohuang 85f47d8986 Merge remote-tracking branch 'origin/main' into fix/temperature-main-feedback-oct02 2026-10-02 22:57:31 +08:00
dajiaohuang 5e973c2c4d Merge remote-tracking branch 'origin/main' into fix/zero-main-feedback-oct02 2026-10-02 22:56:11 +08:00
MervinPraison ccd5849008 docs: auto-update documentation parity trackers [skip ci] 2026-10-02 14:02:15 +00:00
praisonai-triage-agent[bot]andMervin Praison 6cf57513c9 ci: re-trigger unit suite after flaky native segfault (no code change)
The failed run crashed with exit code 139 (Fatal Python error:
Segmentation fault) inside an unrelated tool-retry test
(tests/unit/tools/test_async_tool_retry_parity.py), with background
threads from posthog/asyncio/heartbeat and C-extensions on the stack.
The test passes in isolation and the crash point does not touch this
PR's plugin-manager change (has_registered_plugins + merge gates), which
is a pure, allocation-free dict read. Empty commit to re-run the full
suite on identical code.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-02 13:53:58 +00:00
Wu Shuwen 8641da4633 fix: retain incomplete Responses outcomes and streamed usage (#5475)
* fix: retain incomplete Responses outcomes and streamed usage

* fix: preserve Responses usage mappings and verify real metrics
2026-10-02 14:53:11 +01:00
praisonai-triage-agent[bot]andMervin Praison 1ce3567532 fix(session): forward overwrite kwarg in SqliteSessionStore import override
SqliteSessionStore._save_imported_session lacked the overwrite keyword that
DefaultSessionStore.import_sessions now forwards, raising a TypeError that
aborted every indexed import. Forward it to super and let the test's delayed
save wrapper pass kwargs through.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-02 13:47:40 +00:00
Wu Shuwen 863386de5c fix(session): normalize mirror snapshots without failing local writes 2026-10-02 13:47:09 +00:00
Wu Shuwen d09d11c364 fix(session): isolate mirror queue and retry records 2026-10-02 13:47:09 +00:00
praisonai-triage-agent[bot] f7b2ff1be0 Merge pull request #5438 from MervinPraison/claude/issue-5433-20261001-0955
fix: implement live LangSmith trace delivery (PAI-TS-002)
2026-10-02 13:40:37 +00:00
praisonai-triage-agent[bot]anddajiaohuang ebca47a8ff fix: capture re-entrancy sentinel before guarded import in YAML runner (fixes #5632)
The _run_from_file YAML runner assigned _prev_in_run inside the try block,
right after the `from praisonai_code.cli.main import PraisonAI` import. When
that import raised, the broad except re-raised typer.Exit(1) and the finally
block then read the never-assigned _prev_in_run, raising UnboundLocalError and
masking the original diagnostic. Capture the sentinel's prior value before the
guarded block so the finally restore is always safe.

Co-authored-by: dajiaohuang <[email protected]>
2026-10-02 13:35:53 +00:00
Mervin PraisonandCursor 4d604e7716 ci: extend optimised smoke job timeout to 16 minutes (#5634)
Smoke runs were being cancelled at 12 minutes under load; 16 minutes matches headroom used elsewhere without widening test scope.

Co-authored-by: Cursor <[email protected]>
2026-10-02 14:22:07 +01:00
praisonai-triage-agent[bot]anddajiaohuang ce458b2ba5 fix: forward overwrite flag to indexed imported-session save (fixes #5627) (#5631)
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: dajiaohuang <[email protected]>
2026-10-02 14:19:42 +01:00
praisonai-triage-agent[bot]andMervin Praison 3e048ab457 fix(session): forward overwrite kwarg in SqliteSessionStore import override
The base DefaultSessionStore._save_imported_session now takes an
overwrite keyword (import no-clobber work) and import_sessions calls it
with overwrite=overwrite, but the SqliteSessionStore override and a test
monkeypatch still used the old positional-only signature, raising
"unexpected keyword argument 'overwrite'" and breaking portable imports.

- sqlite_store.py: match base signature and forward overwrite to super()
- test_sqlite_import_index.py: update delayed_save mock to accept/forward
  overwrite so the concurrent-write test exercises the real code path

Co-authored-by: Mervin Praison <[email protected]>
2026-10-02 12:46:59 +00:00
praisonai-triage-agent[bot] 0a7195dac0 Merge remote-tracking branch 'origin/main' into claude/issue-5617-20261002-1135 2026-10-02 12:44:44 +00:00
Wu Shuwen 8dd23bf1c1 fix: isolate in-memory metadata and read results from caller edits (#5483)
* fix: isolate in-memory metadata and read results from caller edits

* fix: copy memory enumeration outside the write lock
2026-10-02 13:44:06 +01:00
Wu Shuwen 7fb721f3f3 fix(session): distinguish current and completed handoff progress (#5539) 2026-10-02 13:43:55 +01:00
praisonai-triage-agent[bot]andMervin Praison 92add1ef85 test(plugins): cover uncreated-singleton fast path in has_registered_plugins
Adds an isolated regression test that patches the module-global
_default_manager to None and asserts has_registered_plugins() returns
False without constructing the singleton. Closes the greptile P2 gap;
protects the allocation-free hot-path optimisation from regression.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-02 12:41:30 +00:00
praisonai-triage-agent[bot]andMervin Praison 151d74096c fix: keep --json output parseable with --save and honor thresholds iterations/warmup
- Route save_results/update_readme status messages through an injectable
  logger; under --json they go to stderr so stdout stays pure JSON when
  --json and --save are combined (greptile P1).
- Make thresholds.json iterations/warmup authoritative for both the
  measurement and the emitted JSON metadata so configuration is no longer
  silently ignored (greptile P2).

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

Co-authored-by: Mervin Praison <[email protected]>
2026-10-02 12:13:57 +00:00
praisonai-triage-agent[bot]andMervin Praison 1e6794b82a test: add SQLiteBackend schema-init failure regression test
Covers the cleanup path added in the fix for #5574: when _create_table()
raises during __init__, the opened thread-local connection must be closed
and the original error re-raised, while the existing table survives.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-02 12:05:38 +00:00
praisonai-triage-agent[bot]anddajiaohuang 3b541d6b0a fix: close SQLiteBackend connection when schema init fails (fixes #5574)
Co-authored-by: dajiaohuang <[email protected]>
2026-10-02 12:05:38 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 42c410d326 perf(agents): skip plugin-manager scan in Agent init when no plugins registered (fixes #5624)
Add a cheap, allocation-free has_registered_plugins() fast-path so the two
unconditional plugin merge calls in Agent.__init__ (_merge_plugin_tools and
_merge_plugin_subsystems) early-return when no plugins are registered — the
common case. Removes manager construction, lock acquisition and list scans
from the hot init path.

Tool-schema building was already lazy (first run()/chat, cached), so this
targets the remaining per-init overhead profiling surfaced. Benchmark (500
iters, warmed): min ~121us -> ~100us, mean ~135us -> ~111us; function calls
170k -> 134k. No behavioral change: plugins still merge when registered.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-10-02 11:46:35 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy e9a5538a9c fix: add CI perf-gate flags to agent benchmark (fixes #5617)
Add --skip-crewai, --json, and --fail-over-threshold flags to
benchmarks/simple_benchmark.py plus a thresholds.json budget file so CI
can baseline and gate agent instantiation performance regressions.

- --skip-crewai avoids the optional-framework import hang
- --json emits machine-readable results for baseline artifacts
- --fail-over-threshold exits non-zero when PraisonAI init exceeds budget

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-10-02 11:37:21 +00:00
dajiaohuang a11c50a52d fix: discard explicit rule markers after deletion 2026-10-02 19:13:47 +08:00
dajiaohuang c5f008f960 Merge main and preserve event-driven compute release tests 2026-10-02 19:05:25 +08:00
praisonai-triage-agent[bot]andMervin Praison 13959d6798 fix: route discovered providers through runtime model dispatch & credentials (#5607)
Close the gaps raised in PR review so a custom/plugin provider is not merely
visible in listings but actually usable end-to-end:

- core: LLM._detect_provider() routes an explicit ``myprovider/model`` to a
  registered/entry-point adapter instead of silently falling back to "openai"
  (lazy, failure-tolerant; built-ins unchanged).
- catalogue: provider_for_model() resolves an explicit discovered prefix before
  bare-name fallbacks so ``gptlike/…``/``geminity/…`` are not misattributed to
  OpenAI/Gemini.
- credentials: inject_credentials_into_env() exports a discovered provider's
  stored key; _stored_providers_for_vars() maps its env-var back so
  is_configured("myprovider/model") honours a stored credential.
- env: has_provider_credential() counts a discovered provider's
  <PROVIDER>_API_KEY as a cloud key so a plugin-only first run is not pushed to
  the local/OpenAI fallback.

Adds core routing regression test and a praisonai-code bridge test suite.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-02 09:16:17 +00:00
8b00049b58 fix: bridge custom/plugin LLM providers to CLI/YAML surface (fixes #5607)
Add a praisonai.providers entry-point group and a public read-side accessor
so a provider registered via add_provider_adapter() or shipped as a pip
plugin is first-class in the CLI/YAML surface, not just in Python.

Core (praisonaiagents/llm/adapters): add lazy, read-only
list_provider_adapters() and an idempotent load_provider_entry_points()
scanning the praisonai.providers group (guarded like discover_plugins());
get_provider_adapter() now triggers discovery so myprovider/model routes to
a discovered adapter.

Wrapper (praisonai_code): catalogue.discovered_providers() unions the core
registry + entry-point providers (minus built-ins/static catalogue), and
list_providers()/provider_env_vars()/env_vars_for_provider()/
provider_for_model() fold them in with the conventional <PROVIDER>_API_KEY,
so setup picker, auth login/list/status, models, and default-model
resolution see them and reuse existing key validation. Fully additive and
backward compatible: with nothing registered the static catalogue is
unchanged.

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

Co-Authored-By: Claude <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-10-02 09:04:06 +00:00
praisonai-triage-agent[bot]andMervin Praison a395f98d9c fix(mcp): graceful Windows loop teardown + restore test loop state
Mirror asyncio.run()'s teardown on the Windows ProactorEventLoop path
(shutdown async generators + default executor, reset current loop) so
outstanding tasks are finalized before close. Restore the pytest thread's
prior event loop in the Windows-branch test to keep results order-independent.

Addresses greptile findings on PR #5601.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-02 08:39:44 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 5228e55550 fix: use ProactorEventLoop for MCP stdio on Windows (fixes #5598)
MCPToolRunner runs asyncio in a background thread. On Windows a non-main
thread defaults to a SelectorEventLoop, which cannot spawn subprocesses
(required by the stdio transport), causing an opaque "fileno" RuntimeError
during MCP.__init__ for npx-based servers. Drive a ProactorEventLoop on
Windows so subprocess stdio works; other platforms keep asyncio.run.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-10-02 08:33:38 +00:00
dajiaohuang 100e56a22b Merge main and keep cached prompt changes scoped to turn context 2026-10-02 12:35:25 +08:00
dajiaohuang 9081c77040 Merge main and retain measured compaction and release completion tests 2026-10-02 12:32:24 +08:00
dajiaohuang 8ca5b68ea1 test: wait for elapsed lease time and renewal count 2026-10-02 11:53:07 +08:00
dajiaohuang 53771628fb fix: honor explicit rule registration order across scopes 2026-10-02 11:52:10 +08:00
Wu Shuwen 1858acd191 fix: retain rule scopes and distinct path-scoped instructions 2026-10-02 02:11:17 +08:00
Wu Shuwen f76e905e52 fix: preserve explicit rule identities through reload 2026-10-02 01:51:13 +08:00
Wu Shuwen ba7acefdbc fix: retain YAML integer priority formats 2026-10-02 01:45:06 +08:00
Wu Shuwen f29309845e fix: accept trailing whitespace on document delimiters 2026-10-02 01:42:40 +08:00
Wu Shuwen 91abbae783 Preserve rule metadata compatibility and parsing fallbacks 2026-10-02 01:24:03 +08:00
Wu Shuwen 7442f59a88 Reload explicitly registered instruction files and globs 2026-10-02 01:15:11 +08:00
Wu Shuwen 192b7c5b33 Parse and serialize rule frontmatter as YAML 2026-10-02 01:10:44 +08:00
Wu Shuwen 6c33fb55d2 Preserve documentation metadata through YAML roundtrips 2026-10-02 01:04:45 +08:00
Wu Shuwen 952b1088ab Keep automatic FileMemory checkpoints independently recoverable 2026-10-01 23:55:38 +08:00
Wu Shuwen 820f02e354 Refresh persisted FileMemory records before selective deletion 2026-10-01 23:39:50 +08:00
dajiaohuang 5b8c12bdf9 fix: apply completion reasoning policy to the requested model 2026-10-01 20:16:33 +08:00
dajiaohuang c723a2a29e fix: classify the effective Responses request model 2026-10-01 20:04:57 +08:00
dajiaohuang 4f540c5ca5 fix: classify the effective Responses request model 2026-10-01 20:04:53 +08:00
dajiaohuang cd15e8e70c test: drive Redis renewal with a controlled TTL clock 2026-10-01 20:01:18 +08:00
praisonai-triage-agent[bot]andMervin Praison f0643584cf test: cover reasoning guard directly in _build_responses_params
The public-path tests mock _call_responses_api and bypass
_build_responses_params, so add direct unit tests that assert the
reasoning-model guard strips all sampling params while non-reasoning
models retain the resolved constructor temperature.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-01 11:55:26 +00:00
dajiaohuang 8010968c76 fix: preserve reasoning sampling policy in Responses 2026-10-01 19:52:35 +08:00
dajiaohuang b138acd144 fix: filter reasoning sampling after Responses overrides 2026-10-01 19:51:12 +08:00
dajiaohuang cb98fbf862 fix: honor configured temperature in Responses calls 2026-10-01 19:47:38 +08:00
dajiaohuang a3e1642d64 fix: preserve zero top-p in Responses requests 2026-10-01 19:40:26 +08:00
dajiaohuang 526198a1e4 test: use monotonic deadline for reclaim wait 2026-10-01 19:37:14 +08:00
dajiaohuang a8dbd18cd4 fix: preserve explicit zero completion options 2026-10-01 19:34:05 +08:00
praisonai-triage-agent[bot]andMervin Praison cb17bcbb9e test: make compaction and docker-reclaim tests tokeniser/timing robust
Fixes two pre-existing CI failures (from PR #5322, unrelated to this PR's
prompt-cache change):

- test_compaction_wiring: sized conversation content by a fixed chars*4
  ratio, which only matches the heuristic token counter. With tiktoken
  installed (CI), repeated chars pack ~8/token, so the "over budget"
  message counted far below the resolved budget and compaction did not
  trigger. Calibrate content size against the compactor's own estimator so
  the token target holds under either tokeniser.
- test_run_on_docker reclaim test: _release is fire-and-forget during live
  GC (submits shutdown to the shared background bridge loop), so the reclaim
  lands a beat later on another thread. Wait (bounded) for it instead of
  racing the assertion. Still proves the finalizer fired and reclaimed.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-01 11:27:45 +00:00
dajiaohuang 780b7d27f3 fix: retain current turn context on prompt cache hits 2026-10-01 19:15:12 +08:00
praisonai-triage-agent[bot]andMervin Praison 90b4690e02 test: add LLM get_response fallback gate coverage; drop unused import
Addresses greptile review on #5447:
- Remove unused `types` import in test_streaming_fallback_replay_safety.py.
- Add six regression tests mirroring the llm.py get_response streaming
  fallback gate (side_effecting = formatted_tools or execute_tool_fn +
  is_replay_unsafe_chain), covering instance/request tool turns, wrapped
  read timeouts, and tool-less/connect-timeout/generic-error controls.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-01 10:51:37 +00:00
praisonai-triage-agent[bot]anddajiaohuang c7f03918b0 fix: enforce replay safety in streaming fallback for tool turns (fixes #5445)
Switching response mode (streaming -> non-streaming) is still a provider
replay. The retry driver already surfaces a post-dispatch failure (read
timeout, connection reset mid-response) on a side-effecting tool turn per
#3860, but the outer fallbacks caught that error and reissued the same
request with stream=False, re-executing a turn whose tools may already have
run server-side.

- Add is_replay_unsafe_chain() to walk __cause__/__context__ so a read
  timeout wrapped by the streaming path's "raise Exception(...) from" is
  still detected.
- LLM.get_response_stream: on a tool turn, re-raise a replay-unsafe
  streaming failure instead of falling back to stream=False.
- ChatMixin._stream_fallback_chat: on a tool turn (request or instance
  tools), surface a replay-unsafe streaming error instead of reissuing
  chat(). Tool-less turns and pre-dispatch (connect) failures keep their
  existing fallback behaviour.

Co-authored-by: dajiaohuang <[email protected]>
2026-10-01 10:43:43 +00:00
praisonai-triage-agent[bot]andMervin Praison dce504c9eb fix(desktop): robust MiniMax image key load and response validation
- Load ~/.praisonai/.env for MINIMAX_API_KEY so the documented setup
  reaches a fresh Images request (engine does not load it at startup).
- Validate MiniMax payload/base_resp/data shapes before indexing so a
  malformed JSON response returns the defined error, not an AttributeError.
- Reject empty image downloads instead of reporting a saved file.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-01 10:40:37 +00:00
praisonai-triage-agent[bot]andMervin Praison a7c1d3d497 fix: TLS handshake-phase reset is pre-dispatch replay-safe (PR #5443 review)
A connection reset/disconnect during the TLS handshake completes before any
request byte is written, so it is pre-dispatch and safe to replay. The prior
precedence let the generic post-dispatch "reset" pattern win over the handshake
signal, surfacing provider_outcome_unknown on side-effecting turns and blocking
a safe retry. Phase-specific signals (handshake, certificate verification) now
win first; true mid-response resets stay replay-unsafe.

Addresses Greptile P1 finding on #5441.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-01 10:33:53 +00:00
octo-patch 4cf2756be2 feat(desktop): add MiniMax image generation 2026-10-01 18:32:07 +08:00
praisonai-triage-agent[bot]anddajiaohuang c9c11afb31 fix: TLS/SSL labels no longer hide post-dispatch replay signals (fixes #5441)
A generic TLS/SSL transport label (and the stdlib ssl.SSLError class) were
treated as proof of a pre-dispatch failure in is_replay_unsafe, masking a
post-dispatch read timeout / connection reset. On a side-effecting turn this
wrongly produced a retry decision instead of surface_error/
provider_outcome_unknown, risking a double-executed turn.

Explicit post-dispatch signals (read timeout, connection reset) now win over
the transport label, while explicit connect-timeout, TLS handshake and
certificate-verification signals remain replay-safe. Tool-less turns keep
their auto-retry behaviour.

Co-authored-by: dajiaohuang <[email protected]>
2026-10-01 10:26:03 +00:00
praisonai-triage-agent[bot]andMervin Praison 07ac1d0547 fix(observability): harden LangSmith adapter per review (#5438)
- Surface failed run deliveries in flush() instead of resolving silently
- Reject plaintext http endpoints to non-loopback hosts (API key leak)
- Export span events into the run payload (extra.events)
- Evict completed runs/ordering state to bound memory in long processes
- Recognise legacy LANGCHAIN_API_KEY in CLI readiness via altEnvKeys
- Tests save/restore credential env; add coverage for all of the above

Co-authored-by: Mervin Praison <[email protected]>
2026-10-01 10:11:39 +00:00
praisonai-triage-agent[bot]andMervin Praison cf0043c585 fix(test): make compaction budget tests tokeniser-independent
test_unset_triggers_when_budget_approached assumed a flat 4 chars/token
ratio via "A" * 4 * n. Under tiktoken (available when its vocab is cached,
e.g. on CI) a degenerate all-A string collapses to ~8 chars/token, falling
under the resolved budget so needs_compaction returned False and the
assertion failed. Size payloads with the compactor's own estimate_tokens
so both the trigger and no-trigger assertions hold under the accurate and
heuristic token-counting paths. Production code unchanged.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-01 10:08:15 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy c3f2676958 fix: implement live LangSmith trace delivery (fixes #5433)
Upgrade the LangSmith observability adapter from an honest stub to a real
deliverer using the existing axios dependency (no new deps). isEnabled is
derived from a live, credential-validated HTTP client, spans map to
LangSmith runs with tool/LLM child runs under the agent root, flush is
batched async, and secrets are read from env only with http(s)-only
endpoint validation. All other external vendors remain honest stubs.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-10-01 10:00:14 +00:00
dajiaohuang c4def1b970 Wait for background compute release in lifecycle tests 2026-10-01 17:53:18 +08:00
praisonai-triage-agent[bot]andMervin Praison c2b84b84c0 fix: harden Discord/Slack streaming delivery guardrails (#5417)
Address Greptile review findings on the progressive-streaming wiring:
- Slack: defer to the thread-aware send path when a reply must be threaded
  (the root-only DraftStreamer would otherwise strand it in the channel),
  and extract MEDIA: directives so streamed audio is uploaded via Slack's
  file path instead of being edited in as literal text.
- Discord: when a streamed answer exceeds the 2000-char cap, delete the
  placeholder and fall back to the chunked _send_long_message path so long
  replies are split and keep their reply reference.
- Tests: add regression coverage for thread deferral, Slack media upload,
  and the Discord long-reply chunked fallback.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-10-01 09:34:36 +00:00
praisonai-triage-agent[bot]andMervin Praison 32562f5679 fix: distinguish partial-read failure from absence in discard precheck
A transient read failure in _has_trailing_partial previously returned
False, causing discard_partial_assistant_message to report success without
removing a stale partial. The precheck now returns None on read failure so
callers fall through to the locked read-modify-write (which re-reads under
the lock) instead of silently skipping the discard (Issue #5407).

Co-authored-by: Mervin Praison <[email protected]>
2026-10-01 09:30:07 +00:00
praisonai-triage-agent[bot]andMervin Praison 2782d5e56d fix: route partial persistence through transactional seam + encrypt streamed turns
Addresses reviewer findings on PR #5413 (Issue #5407):

- store.py: upsert/discard partial assistant messages now route through
  _modify_session_locked instead of a bespoke FileLock+JSON write. This makes
  SQLite-backed stores write their authoritative row (not a stray JSON file),
  serializes concurrent turns, propagates failed writes (with spill recovery),
  and refreshes the SQLite FTS index on finalize.
- encrypted_store.py: add encryption-aware upsert/discard so streamed partial
  content is encrypted at rest instead of being forwarded raw via __getattr__.
- chat_mixin.py: track the active streaming phase so a post-tool follow-up
  answer is persisted incrementally and finalized, and a GeneratorExit during
  the follow-up recovers that answer rather than the stale pre-tool text.
- tests: add SQLite transcript, encrypted-at-rest, and failed-finalization
  coverage (14 tests total; full session suite 453 passed / 12 skipped).

Co-authored-by: Mervin Praison <[email protected]>
2026-10-01 09:22:53 +00:00
praisonai-triage-agent[bot]andMervinPraison 9bec7d3227 fix: wire progressive streaming replies for Discord & Slack (fixes #5415)
The channel-agnostic DraftStreamer was only instantiated by Telegram, so a
configured streaming.mode: draft|auto was silently inert on Discord and Slack
despite both supporting in-place edits. Reuse the existing engine in their
reply paths via a shared build_streaming_config factory.

Co-authored-by: MervinPraison <[email protected]>
2026-10-01 09:20:28 +00:00
praisonai-triage-agent[bot]andMervin Praison 9939efa511 fix: harden zero-config model ranking + accurate refresh reporting
Addresses CI failure and reviewer (Greptile) findings on PR #5412:

- rank_models/best_available now exclude non-chat models (embeddings,
  rerankers, speech, image, moderation) so a zero-config default is always a
  usable chat model (was picking e.g. cohere/embed-v4.0).
- De-prioritise unstable preview/experimental/beta/snapshot/auto-router ids so
  the default is a stable flagship, not a preview build.
- Treat litellm's `google` provider label and the credential catalogue's
  `gemini` as aliases so a GEMINI_API_KEY-only run ranks Gemini models instead
  of falling back to the fixed default.
- refresh() returns {cached, models}; _save_to_cache returns success, so the
  CLI reports "cached" only when litellm data was actually written (no false
  success on litellm-unavailable).
- Update test_env_default_detected_for_every_catalogue_provider to assert
  provider-routing correctness (prefix preserved) rather than equality with the
  now-superseded fixed representative; make cache-dir test PRAISONAI_HOME-robust;
  cover refresh cached/fallback signalling.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-01 09:18:21 +00:00
praisonai-triage-agent[bot]andMervinPraison ec36e31a6c fix: persist streamed assistant output incrementally (fixes #5407)
Streamed assistant output was only persisted to the session store after a
turn completed, so an interrupt mid-generation (Ctrl-C/crash) lost the whole
in-progress turn and the streaming path never persisted turns at all.

Add a minimal, optional incremental-persistence seam:
- DefaultSessionStore.upsert_partial_assistant_message / discard_* overwrite
  a single trailing assistant record (metadata partial flag) instead of
  appending per token, finalizing in place at turn end.
- MemoryMixin._persist_assistant_delta (debounced) / _finalize_assistant_turn
  / _discard_partial_assistant_turn with graceful fallback to the existing
  single terminal write for stores lacking partial support.
- Wire both streaming loops in chat_mixin (_start_stream_impl) to init the
  session store, persist the user turn, flush partial deltas as they arrive,
  recover on GeneratorExit, and finalize at turn end.
- Document the optional IncrementalSessionStoreProtocol.

Backward compatible: existing stores (SQLite/Hierarchical/custom) are
feature-detected and fall back to prior per-turn behaviour.

Co-authored-by: MervinPraison <[email protected]>
2026-10-01 09:11:13 +00:00
praisonai-triage-agent[bot]andMervinPraison a8921822d3 fix: capability-rank zero-config default model + add models refresh (fixes #5408)
Replace the fixed, often-weak per-provider representative (e.g. gpt-4o-mini)
with the best-available model ranked from the capability metadata the
ModelCatalogue already loads (tool-use, context, reasoning/vision; de-prioritise
-mini/nano-class ids). Add `praisonai models refresh` to rebuild the capability
cache on demand, and move the catalogue cache to the canonical ~/.praisonai home.

Co-authored-by: MervinPraison <[email protected]>
2026-10-01 09:07:16 +00:00
praisonai-triage-agent[bot]andMervin Praison 15ad0d4ef1 fix: make FailoverManager.reset_all survive transient coordinator clear failures
Addresses Greptile P1: a transient coordinator clear() failure left the stale
bench in place, which get_next_profile() re-imported via
_sync_bench_from_coordinator(), silently undoing the reset. reset_all() now
verifies the clear and retries once before warning, keeping the local reset
durable. Documented the intentional unconditional clear semantics vs
mark_success(), plus a regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-01 09:04:17 +00:00
praisonai-triage-agent[bot]anddajiaohuang 8010f80f39 fix: clear coordinator benches in FailoverManager.reset_all (fixes #5404)
reset_all() only cleared each profile's local fields, leaving the
coordinator's retained bench in place. A subsequent get_next_profile()
re-applied the old cooldown via _sync_bench_from_coordinator(), silently
undoing the reset. Mirror mark_success()'s fail-open coordinator clear so
an explicit reset leaves registered profiles available.

Co-authored-by: dajiaohuang <[email protected]>
2026-10-01 08:58:48 +00:00
dajiaohuang 3cad60119d Merge remote-tracking branch 'origin/main' into followup-4460 2026-10-01 16:48:53 +08:00
praisonai-triage-agent[bot]andMervin Praison c343313bda fix: preserve external wrapper paths, inner write targets, cumulative tool limit, ollama follow-up chaining
Addresses greptile review findings on PR #5400 (all valid):

command_parser.py:
- External wrapper/shell paths (/tmp/sudo, /tmp/bash -c) now keep a
  path-shaped guard op so the workspace-boundary gate still prompts.
- _is_standard_bin_path rejects traversal (/bin/../../tmp/tool) so the
  prefix strip no longer hides external executables.
- bash/sh -c inner redirect write_targets are preserved through
  normalization so a deny on an inner `> /protected` write fires.
- timeout durations with unit suffixes (5s/1m/2h/1d) are recognised as
  operands so the real command still resolves.

llm.py streaming:
- max_tool_calls_per_turn now bounds the cumulative tools across all
  follow-up batches, not each batch independently.
- Ollama follow-up batches execute sequentially (mirroring the first
  streamed batch) so a dependent call sees the prior call's result.
- Reused provider index (index=0 for distinct parallel calls) routes
  later id-less argument chunks to the new slot, preventing JSON
  corruption of the first call.

Tests: permissions 202 pass, llm 196 pass, streaming 82 pass.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-01 07:46:01 +00:00
praisonai-triage-agent[bot]andMervinPraison 83fd934c9b fix: close shell deny bypass, fix streaming tool loop, bound dict tool output (fixes #5399)
1. Permissions: POSIX wrappers (sudo/env/command/nohup/nice/timeout/exec/
   busybox/xargs/doas), path prefixes (/bin/rm) and `sh -c '<script>'` now
   resolve to the effective inner command so a specific DENY still fires
   instead of matching a broad ALLOW. Unparseable commands (unbalanced
   quotes) escalate to ASK rather than falling through to ALLOW.

2. Streaming LLM: hardened the stream tool-call accumulator against sparse
   indices (IndexError), parallel calls reusing index=0 (merged args), and
   late-arriving id/name. The post-tool follow-up now loops and executes
   chained tool calls until the model returns plain content, instead of a
   single non-streaming turn that silently dropped the second tool call.

3. Tool output: dict results now enforce a total-size budget after the
   per-field pass, falling back to the bounded head/tail string (with the
   artifact reference) so many medium-sized fields can no longer overflow
   the context window.

Co-authored-by: MervinPraison <[email protected]>
2026-10-01 07:32:53 +00:00
praisonai-triage-agent[bot]andMervin Praison a76ba63a1f fix: task-local agent attribution + narrow video backend error match
- Implement ContextVar-based agent attribution on LLM (#5052) so concurrent
  agents sharing one LLM instance don't clobber each other's token tracking;
  add LLM.__deepcopy__ so cloning an already-built LLM gets a fresh ContextVar
  (fixes pre-existing red tests: concurrent token tracking + deepcopy clone).
- Narrow VideoAgent unsupported-backend detection to LiteLLM's specific
  "video generation is not supported" phrase so a working backend's bad-request
  error (e.g. "video size is not supported") is no longer misclassified
  (Greptile P1). Add regression test.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 18:07:28 +00:00
praisonai-triage-agent[bot]andMervin Praison 3b2a71258e fix: make LLM agent-attribution task-local + deepcopy-safe (#5052)
Restore the ContextVar-based agent attribution contract expected by the
existing token-tracking and agent-clone tests (added in #5204) but never
implemented in llm.py, which still used a plain instance attribute. This
caused concurrent agents sharing one LLM to misattribute tokens and made
deepcopy of a built LLM raise on the non-picklable ContextVar.

- Back current_agent_name with a task-local ContextVar; keep the historical
  attribute-style get/set via a property (no API change).
- Add LLM.__deepcopy__ that gives clones a fresh, empty ContextVar.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 18:07:11 +00:00
praisonai-triage-agent[bot]andMervin Praison 1d6669c92f fix: harden web_crawl Markdown conversion (fences, links, list items) + tests
Address greptile review findings on the dependency-free HTML->Markdown path:
- Dynamic code fence length so inner backtick runs don't close the fence early
- Inline code chooses a delimiter longer than any inner backtick run
- Whitespace cleanup skips fenced code regions (preserves whitespace-sensitive
  examples) via sentinel markers
- Resolve relative link targets against the fetched base_url and percent-encode
  parens/spaces so link destinations stay intact
- Keep block children (e.g. <p>) inside <li> on the marker line
- Add focused, network-free unit tests for headings, lists, links, code fences,
  inline code, and malformed HTML

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

Co-Authored-By: Mervin Praison <[email protected]>
2026-09-30 18:06:31 +00:00
praisonai-triage-agent[bot]andMervinPraison 2da26cd58f fix: return structured Markdown on dependency-free web fetch path (fixes #5309)
Replace the tag-stripping regex in _crawl_with_httpx with a stdlib
html.parser-based HTML->Markdown converter that preserves headings,
lists, links (with targets), and fenced/inline code blocks. Also fixes
the title never being captured (regex previously ran after tags were
stripped). Falls back to the original flat-text extraction on any parse
error so the tool never fails. No new dependencies, no signature change.

Co-authored-by: MervinPraison <[email protected]>
2026-09-30 18:06:31 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy e11c23fa74 fix: clear error for video models with no LiteLLM backend (fixes #5342)
VideoAgent.generate()/agenerate() now translate LiteLLM's opaque
"video generation is not supported for <provider>" error (e.g. for
xai/grok-imagine-video-* ids that are catalogue-listed for chat pricing
only) into an actionable ValueError naming supported video models.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-09-30 18:06:25 +00:00
praisonai-triage-agent[bot]andMervin Praison 8f7ec5beb2 fix: omit response_format for gpt-image-*/dall-e-3 so image_generate works (#5339)
drop_params=True alone does not strip response_format for these models
(LiteLLM's per-model param table lists it as supported), so the wrapper
now drops response_format itself for models that reject it while keeping
it for dall-e-2 and non-OpenAI providers. drop_params stays scoped for
any other unsupported params. Adds regression tests asserting actual
param removal.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-29 09:04:46 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy cb2103a942 fix: image_generate() drops unsupported params so dall-e-3 and gpt-image-* work (fixes #5339)
Scope drop_params=True to each LiteLLM image call so unsupported
parameters (e.g. response_format, quality, style) are stripped for
models like dall-e-3 and gpt-image-2.5-*, instead of failing with
UnsupportedParamsError / HTTP 400. Applies to image_generate,
aimage_generate, image_edit and aimage_edit. Callers can override via
an explicit drop_params argument.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-09-29 09:04:46 +00:00
dajiaohuang fd3510fe61 fix(desktop): log reopen restoration errors 2026-09-29 08:53:36 +00:00
dajiaohuang ed23ebf07e fix(desktop): restore minimized window on reopen 2026-09-29 08:53:36 +00:00
dajiaohuang 1cc34a39a1 fix(desktop): restore main window on macOS reopen 2026-09-29 08:52:53 +00:00
praisonai-triage-agent[bot]andMervin Praison f07561ce4e test: guard realtime_connect/arealtime_connect default = gpt-realtime
Adds sync and async regression coverage so a future change cannot silently
restore the deprecated gpt-4o-realtime-preview default (Greptile feedback,
PR #5344). Asserts both session.model and the session URL model param.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-28 08:11:34 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy dd3c87fe77 fix: default realtime model to non-deprecated gpt-realtime (fixes #5343)
Change realtime_connect/arealtime_connect and the CLI realtime handler
default from the deprecated gpt-4o-realtime-preview to the GA gpt-realtime
id, and update the example. Explicit model strings are unchanged.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-09-28 08:03:42 +00:00
praisonai-triage-agent[bot]andMervin Praison 262d32eae2 fix: harden bare @path mention resolver (token anchor + preserve formatting)
Address two P1 review findings on the shared MentionsParser:

- Security: anchor the bare @path pattern to a token boundary so an embedded
  @ (e.g. [email protected]) can no longer smuggle a workspace file's contents into the
  prompt context. Trailing sentence punctuation is trimmed so @app.py, still
  resolves to app.py.
- UX: stop flattening the whole prompt via ' '.join(split()). A new
  _tidy_whitespace() collapses only intra-line spaces left by removed mentions
  while preserving newlines, indentation and paragraph structure — multiline
  interactive prompts keep their Markdown/code formatting.

has_mentions() mirrors the same anchoring + punctuation trim. Adds regression
tests for embedded-token non-leak, trailing punctuation, and multiline
preservation.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:47:56 +00:00
praisonai-triage-agent[bot]andMervin Praison f0251d62bd fix: inline bare @path file references on non-interactive surfaces (fixes #5158)
Teach the shared core MentionsParser to inline the bare @path form
(@src/app.py) when it resolves to a workspace file, reusing its existing
path-traversal / workspace-containment gates. This instantly gives
praisonai run, YAML and Python the same @file inlining that interactive
chat already had (via direct_prompt.py -> MentionsParser).

Retire the TUI's private @-mention regex so it delegates to the same
shared parser, keeping interactive and non-interactive surfaces from
drifting. Unrelated @handle/@email tokens and @../secret traversal are
left in the prompt untouched.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:58:51 +00:00
510 changed files with 43790 additions and 4835 deletions

No files matched your search

+27 -2
View File
@@ -66,7 +66,31 @@ runs:
steps:
- name: Install Claude Code
shell: bash
run: npm install -g @anthropic-ai/claude-code
run: |
set -euo pipefail
# Opus 5.5+ requires Claude Code >= 2.1.280 (bundled @beta action ships 1.0.x).
npm install -g @anthropic-ai/claude-code@latest
INSTALLED=$(claude --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
export INSTALLED
echo "Claude Code version: ${INSTALLED}"
node -e "
const installed = process.env.INSTALLED;
const min = '2.1.280';
const parts = (v) => v.split('.').map(Number);
const gte = (a, b) => {
const A = parts(a); const B = parts(b);
for (let i = 0; i < 3; i++) {
if (A[i] > B[i]) return true;
if (A[i] < B[i]) return false;
}
return true;
};
if (!installed || !gte(installed, min)) {
console.error('Claude Code ' + installed + ' is below minimum ' + min);
process.exit(1);
}
console.log('Claude Code ' + installed + ' OK (>= ' + min + ')');
"
- name: Install GitHub MCP Server
shell: bash
@@ -270,7 +294,8 @@ runs:
INPUT_DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
CLAUDE_CODE_SUBAGENT_MODEL: inherit
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
ANTHROPIC_DEFAULT_OPUS_MODEL: claude-opus-5-5
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-5-5
run: |
echo "🚀 Running Claude Code with GitHub context..."
+5 -1
View File
@@ -1,5 +1,5 @@
name: Pick Claude OAuth token
description: Probe primary/secondary Claude OAuth tokens and select a working one (dual OAuth Option B).
description: Probe primary/secondary/tertiary Claude OAuth tokens and select a working one.
inputs:
token_primary:
description: Primary Claude OAuth token (CLAUDE_CODE_OAUTH_TOKEN)
@@ -7,6 +7,9 @@ inputs:
token_secondary:
description: Secondary Claude OAuth token (CLAUDE_CODE_OAUTH_TOKEN_B)
required: false
token_tertiary:
description: Tertiary Claude OAuth token (CLAUDE_CODE_OAUTH_TOKEN_C)
required: false
active_slot:
description: Preferred slot — primary or secondary (repo variable CLAUDE_OAUTH_ACTIVE)
required: false
@@ -26,6 +29,7 @@ runs:
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.token_primary }}
CLAUDE_CODE_OAUTH_TOKEN_B: ${{ inputs.token_secondary }}
CLAUDE_CODE_OAUTH_TOKEN_C: ${{ inputs.token_tertiary }}
CLAUDE_OAUTH_ACTIVE: ${{ inputs.active_slot }}
run: |
if [[ -n "${HUB_SCRIPTS:-}" && -f "${HUB_SCRIPTS}/oauth-pick-token.sh" ]]; then
@@ -82,6 +82,46 @@ const skipPending = ciFix.shouldSkipCiFix({
});
assert('skips when ci fix pending label', skipPending.skip && skipPending.reason.includes('pending'));
// A request the App bot posted never reached Claude (claude.yml ignores it);
// its pending label must not block the real request.
const botRequest = {
user: { login: 'praisonai-triage-agent[bot]' },
body: `@claude CI failed on HEAD \`${headSha.slice(0, 8)}\`. Please fix.`,
created_at: '2026-10-13T21:16:49Z',
};
const maintainerRequest = {
user: { login: 'MervinPraison' },
body: '@claude CI failed on HEAD `0d561a42`. Please fix.',
created_at: '2026-10-13T09:16:04Z',
};
const strandedLabel = ciFix.shouldSkipCiFix({
comments: [maintainerRequest, botRequest],
headSha,
labels: [ciFix.CI_FIX_LABEL],
hasFinal: true,
claudeInProgress: false,
failedChecks: [{ name: 'test-core' }],
});
assert('label left by an ignored bot request does not block', !strandedLabel.skip);
const realPending = ciFix.shouldSkipCiFix({
comments: [botRequest, { ...maintainerRequest, created_at: '2026-10-13T22:00:00Z' }],
headSha,
labels: [ciFix.CI_FIX_LABEL],
hasFinal: true,
claudeInProgress: false,
failedChecks: [{ name: 'test-core' }],
});
assert('label still blocks after a real maintainer request', realPending.skip && realPending.reason.includes('pending'));
const otherWriterPending = ciFix.shouldSkipCiFix({
comments: [botRequest, { ...maintainerRequest, user: { login: 'other-writer' }, created_at: '2026-10-13T22:00:00Z' }],
headSha,
labels: [ciFix.CI_FIX_LABEL],
hasFinal: true,
claudeInProgress: false,
failedChecks: [{ name: 'test-core' }],
});
assert('label still blocks after another writer request', otherWriterPending.skip && otherWriterPending.reason.includes('pending'));
const failedRuns = mergeGate.listFailedChecksOnSha([
{ status: 'completed', conclusion: 'success', name: 'smoke' },
{ status: 'completed', conclusion: 'failure', name: 'test-core (cli)' },
+23 -1
View File
@@ -45,9 +45,30 @@ function hasRecentCiFixComment(comments, headSha) {
});
}
/**
* The newest CI-fix request on the PR, from any author. A request posted by an
* bot claude.yml ignores (e.g. the App bot) never reached Claude, so the
* pending label it left behind must not block a real request.
*/
function latestCiFixComment(comments) {
let latest = null;
for (const c of comments || []) {
if (!isCiFixComment(c)) continue;
if (!latest || new Date(c.created_at) > new Date(latest.created_at)) latest = c;
}
return latest;
}
function shouldSkipCiFix({ comments, headSha, labels, hasFinal, claudeInProgress, failedChecks }) {
if (!failedChecks.length) return { skip: true, reason: 'no failed checks' };
if (labels.includes(CI_FIX_LABEL)) return { skip: true, reason: 'ci fix pending' };
if (labels.includes(CI_FIX_LABEL)) {
const latest = latestCiFixComment(comments);
const login = latest?.user?.login || '';
// claude.yml drops comments from bots other than github-actions[bot];
// human writers do reach Claude, so their request still blocks.
const strandedByIgnoredBot = login.endsWith('[bot]') && !AUTO_ACTORS.includes(login);
if (!strandedByIgnoredBot) return { skip: true, reason: 'ci fix pending' };
}
if (!hasFinal) return { skip: true, reason: 'awaiting final claude review trigger' };
if (claudeInProgress) return { skip: true, reason: 'claude in progress' };
if (hasCiFixCommentForSha(comments, headSha)) {
@@ -279,6 +300,7 @@ async function processWorkflowRunFailure(github, owner, repo, workflowRun, core)
module.exports = {
CI_FIX_LABEL,
latestCiFixComment,
COOLDOWN_MS,
ciFixShaMarker,
isCiFixComment,
+338 -3
View File
@@ -35,10 +35,231 @@ const withClaudeReply = [
created_at: '2026-06-12T08:30:00Z',
},
];
assert('not stale when Claude replied after FINAL', !mg.isStaleFinalAfterPush(withClaudeReply, '2026-06-12T09:00:00Z'));
assert(
'not stale when Claude finished on HEAD',
!mg.isStaleFinalAfterPush(withClaudeReply, '2026-06-12T08:25:00Z')
);
assert('claude final reply detected', mg.isClaudeFinalReplyComment(withClaudeReply[1]));
const finishedBeforeHead = [
...finals,
{
user: { login: 'praisonai-triage-agent[bot]' },
body: "**Claude finished @MervinPraison's task**",
created_at: '2026-06-12T08:30:00Z',
},
];
assert(
'stale when head after Claude finished (no reply on HEAD)',
mg.isStaleFinalAfterPush(finishedBeforeHead, '2026-06-12T09:00:00Z')
);
const finishedOnHead = [
...finals,
{
user: { login: 'praisonai-triage-agent[bot]' },
body: "**Claude finished @MervinPraison's task**",
created_at: '2026-06-12T09:30:00Z',
},
];
assert(
'not stale when Claude finished after head',
!mg.isStaleFinalAfterPush(finishedOnHead, '2026-06-12T09:00:00Z')
);
// The local Claude action (since 2026-10-09) posts the review itself rather than
// an "Claude finished" tracking comment. Real reply shapes from open PRs:
const reviewReplies = [
'## Final architecture review — PR #5772 (fixes #5771)\n\n#### Phase 1 — AGENTS.md review\n| Check | Result |',
'## Final architecture review\n\n#### Phase 1 — AGENTS.md review\n| Check | Result |',
'#### Phase 1 — AGENTS.md review\n| Check | Result |\n|---|---|',
'## Final architecture review: PR #5774\n\n#### Phase 1 — AGENTS.md review',
];
for (const body of reviewReplies) {
assert(
`FINAL review reply counts as completion: ${body.slice(0, 40)}`,
mg.isClaudeFinalReplyComment({ user: { login: 'praisonai-triage-agent[bot]' }, body })
);
}
assert(
'a human posting the review format is not a completion',
!mg.isClaudeFinalReplyComment({ user: { login: 'someone' }, body: reviewReplies[0] })
);
assert(
'unrelated triage-bot comment is not a completion',
!mg.isClaudeFinalReplyComment({
user: { login: 'praisonai-triage-agent[bot]' },
body: '## CI fix: `test_service_delete_all` (pushed 52c0fdb1e)',
})
);
assert(
'the FINAL @claude trigger (embeds the Phase 1 heading) is not a completion',
!mg.isClaudeFinalReplyComment({ user: { login: 'MervinPraison' }, body: mg.FINAL_CLAUDE_REVIEW_BODY })
);
const triggerOnly = [
{ user: { login: 'MervinPraison' }, body: mg.FINAL_CLAUDE_REVIEW_BODY, created_at: '2026-10-12T08:00:00Z' },
];
assert('real FINAL trigger alone is not a completed review',
!mg.finalClaudeCompletedOnSha(triggerOnly, '2026-10-12T07:55:00Z'));
// PR #5772 timeline: FINAL 07:49:49, reviewer pushes 07:56:13, review 07:56:28.
const pr5772 = [
{ user: { login: 'MervinPraison' }, body: '@claude You are the FINAL architecture reviewer.', created_at: '2026-10-12T07:49:49Z' },
{ user: { login: 'praisonai-triage-agent[bot]' }, body: reviewReplies[0], created_at: '2026-10-12T07:56:28Z' },
];
assert('PR #5772 shape: FINAL is current on HEAD', !mg.isStaleFinalAfterPush(pr5772, '2026-10-12T07:56:13Z'));
assert('PR #5772 shape: still stale if HEAD moves after the review',
mg.isStaleFinalAfterPush(pr5772, '2026-10-12T09:00:00Z'));
// Starvation guard: a PR the gate already BLOCKed on its current HEAD must not
// be re-assessed (and must not take a candidate slot) until something changes.
{
const t0 = Date.parse('2026-10-13T10:00:00Z');
const isoAt = (ms) => new Date(ms).toISOString();
const head = isoAt(t0 - 60 * 60 * 1000);
const block = { user: { login: 'praisonai-triage-agent[bot]' }, body: 'MERGE_GATE_VERDICT: BLOCK\nP1 open', created_at: isoAt(t0) };
const approve = { user: { login: 'praisonai-triage-agent[bot]' }, body: 'MERGE_GATE_VERDICT: APPROVE', created_at: isoAt(t0) };
const oldChecks = [{ name: 'test-core', completed_at: isoAt(t0 - 30 * 60 * 1000) }];
const newChecks = [...oldChecks, { name: 'smoke', completed_at: isoAt(t0 + 10 * 60 * 1000) }];
const now = t0 + 2 * 60 * 60 * 1000;
assert('BLOCK on unchanged HEAD is skipped', mg.blockedOnUnchangedHead([block], head, oldChecks, now));
assert('BLOCK is re-assessed once a check completes after it', !mg.blockedOnUnchangedHead([block], head, newChecks, now));
assert('BLOCK is re-assessed after 24h', !mg.blockedOnUnchangedHead([block], head, oldChecks, t0 + 25 * 60 * 60 * 1000));
assert('APPROVE never triggers the skip', !mg.blockedOnUnchangedHead([approve], head, oldChecks, now));
assert('BLOCK from an older HEAD does not count', !mg.blockedOnUnchangedHead([block], isoAt(t0 + 60 * 60 * 1000), oldChecks, now));
assert('no verdict, no skip', !mg.blockedOnUnchangedHead([], head, oldChecks, now));
const sha = 'ab3416547aaedf4689eb89534e3724b7ae13b08a';
const body = mg.buildBlockFixRequestBody(sha);
assert('fix request starts with @claude', /^@claude\b/.test(body));
assert('fix request avoids the verdict marker (claude.yml ignores it)', !body.includes('MERGE_GATE_VERDICT'));
assert('fix request is not a FINAL trigger', !mg.isFinalClaudeTriggerComment({ user: { login: 'MervinPraison' }, body }));
assert('fix request due when none posted', mg.blockFixRequestDue([], sha));
const posted = { user: { login: 'MervinPraison' }, body, created_at: isoAt(now) };
assert('fix request not repeated for the same HEAD', !mg.blockFixRequestDue([posted], sha));
const other = (s) => ({ user: { login: 'MervinPraison' }, body: mg.buildBlockFixRequestBody(s), created_at: isoAt(now) });
assert('fix request due again on a new HEAD', mg.blockFixRequestDue([posted], 'f'.repeat(40)));
assert('fix requests capped per PR', !mg.blockFixRequestDue([other('1'.repeat(40)), other('2'.repeat(40)), other('3'.repeat(40))], 'f'.repeat(40)));
// Untrusted commenters cannot forge gate state.
const forgedBlock = { ...block, user: { login: 'random-user' } };
assert('BLOCK from an untrusted commenter is ignored', !mg.blockedOnUnchangedHead([forgedBlock], head, oldChecks, now));
const forgedMarker = { ...posted, user: { login: 'random-user' } };
assert('fix-request marker from an untrusted commenter is ignored', mg.blockFixRequestDue([forgedMarker], sha));
const quoted = { ...posted, body: 'see ' + mg.BLOCK_FIX_MARKER + sha.slice(0, 12) };
assert('bare marker text (not the HTML comment) is ignored', mg.blockFixRequestDue([quoted], sha));
}
{
const t0 = Date.parse('2026-10-13T10:00:00Z');
const isoAt = (ms) => new Date(ms).toISOString();
const forged = { user: { login: 'evil-praisonai-triage-agent' }, body: 'MERGE_GATE_VERDICT: BLOCK', created_at: isoAt(t0) };
assert('look-alike login cannot hold a PR out',
!mg.blockedOnUnchangedHead([forged], isoAt(t0 - 3600e3), [], t0 + 60e3));
assert('only exact gate logins are trusted', mg.GATE_VERDICT_AUTHORS.has('praisonai-triage-agent[bot]') && !mg.GATE_VERDICT_AUTHORS.has('evil-praisonai-triage-agent'));
}
const listingTests = (async () => {
const fakeGithub = (byLabel, openPrs) => ({
rest: {
issues: { listForRepo: async ({ labels }) => ({ data: byLabel[labels] || [] }) },
pulls: { list: async () => ({ data: openPrs }) },
},
});
const pr = (n, labels) => ({ number: n, pull_request: {}, labels: labels.map((name) => ({ name })) });
const onlyHeld = await mg.listPrNumbersForMergeGateScan(
fakeGithub({ 'pipeline/blocked:merge-gate': [pr(10, ['pipeline/blocked:merge-gate'])] }, [{ number: 10 }, { number: 20 }]),
'o', 'r', null);
assert('held-out PRs alone fall back to scanning all open PRs', onlyHeld.includes(20) && onlyHeld.includes(10));
const mixed = await mg.listPrNumbersForMergeGateScan(
fakeGithub({ 'pipeline/merge-ready': [pr(30, ['pipeline/merge-ready'])], 'pipeline/blocked:merge-gate': [pr(10, [])] }, []),
'o', 'r', null);
assert('merge-ready plus held-out PRs are both scanned', mixed.join(',') === '10,30');
})();
assert('cancelled detect-and-trigger does not block', mg.OPTIONAL_CANCELLED_CHECKS.has('detect-and-trigger'));
assert('optional pending recovery check name listed', mg.OPTIONAL_PENDING_WHEN_CORE_GREEN.has('claude-review-recovery'));
assert('ignorable pending when core green', mg.isIgnorablePendingCheck(
{ name: 'claude-review-recovery', status: 'queued', conclusion: null },
[
{ name: 'test-core', status: 'completed', conclusion: 'success' },
{ name: 'test-core (cli)', status: 'completed', conclusion: 'success' },
]
));
assert('non-optional pending still blocks', !mg.isIgnorablePendingCheck(
{ name: 'test-core (root)', status: 'queued', conclusion: null },
[{ name: 'test-core', status: 'completed', conclusion: 'success' }]
));
assert('pending smoke ignorable when test-core green', mg.isIgnorablePendingCheck(
{ name: 'smoke', status: 'in_progress', conclusion: null },
[
{ name: 'test-core', status: 'completed', conclusion: 'success' },
{ name: 'test-core (agents-core)', status: 'completed', conclusion: 'success' },
]
));
assert('pending main (3.11) ignorable when test-core green', mg.isIgnorablePendingCheck(
{ name: 'main (3.11)', status: 'queued', conclusion: null },
[{ name: 'test-core', status: 'completed', conclusion: 'success' }]
));
assert('triage bot is auto-merge author', mg.isAutoMergeAuthor({ login: 'praisonai-triage-agent[bot]' }));
assert('triage bot CONTRIBUTOR association still allowed', mg.maintainerOnlyAuthorReason({
user: { login: 'praisonai-triage-agent[bot]' },
author_association: 'CONTRIBUTOR',
}) === null);
assert('external contributor blocked', mg.maintainerOnlyAuthorReason({
user: { login: 'dajiaohuang' },
author_association: 'CONTRIBUTOR',
})?.includes('maintainer-only author'));
assert('first-time contributor blocked', mg.maintainerOnlyAuthorReason({
user: { login: 'new-dev' },
author_association: 'FIRST_TIME_CONTRIBUTOR',
})?.includes('first-time contributor'));
assert('maintainer login allowed', mg.maintainerOnlyAuthorReason({
user: { login: 'MervinPraison' },
author_association: 'OWNER',
}) === null);
const fxmacroFile = {
status: 'added',
filename: 'src/praisonai-agents/praisonaiagents/tools/fxmacrodata_tools.py',
};
assert(
'new vendor *_tools.py blocked for merge gate',
mg.newVendorToolModuleInCoreReason([fxmacroFile], [])?.includes('PraisonAI-Tools')
);
assert(
'maintainer-accept-core-tools opts in',
mg.newVendorToolModuleInCoreReason([fxmacroFile], [mg.MAINTAINER_ACCEPT_CORE_TOOLS_LABEL]) === null
);
assert(
'modified legacy tool file not blocked',
mg.newVendorToolModuleInCoreReason(
[{ status: 'modified', filename: 'src/praisonai-agents/praisonaiagents/tools/tavily_tools.py' }],
[]
) === null
);
assert(
'FINAL prompt mentions 6c vendor tools rule',
mg.FINAL_CLAUDE_REVIEW_BODY.includes('6c.') &&
mg.FINAL_CLAUDE_REVIEW_BODY.includes('maintainer-accept-core-tools')
);
assert(
'FINAL Phase 3 forbids approve on routing fail',
mg.FINAL_CLAUDE_REVIEW_BODY.includes('Never') &&
mg.FINAL_CLAUDE_REVIEW_BODY.includes('6b/6c')
);
const fx5661Finished = {
user: { login: 'praisonai-triage-agent[bot]' },
body: '**Claude finished @MervinPraison\'s task**\nVerdict: ✅ Approve for merge.',
created_at: '2026-10-07T09:40:28Z',
};
assert(
'#5661-style wrong FINAL approval blocked',
mg.wrongFinalApprovalForVendorToolInCoreReason(
[fx5661Finished],
'2026-10-07T09:00:00Z',
[fxmacroFile],
[]
)?.includes('wrong verdict')
);
const coreGreenRuns = [
{ name: 'test-core', status: 'completed', conclusion: 'success' },
@@ -106,7 +327,7 @@ const firstFinal = {
};
const pushSoonAfter = mg.shouldSkipStaleFinalRecovery(
[firstFinal],
iso(nowMs - 16 * 60 * 1000)
iso(nowMs - 8 * 60 * 1000)
);
assert('debounce push soon after FINAL', pushSoonAfter.skip && pushSoonAfter.reason.includes('soon after'));
@@ -123,11 +344,25 @@ assert('hourly cap blocks third FINAL in window', capped.skip && capped.reason.i
const botPush = mg.shouldSkipStaleFinalRecovery(
[firstFinal],
iso(nowMs - 16 * 60 * 1000),
iso(nowMs - 8 * 60 * 1000),
'praisonai-triage-agent[bot]'
);
assert('skip when automation pushed head', botPush.skip && botPush.reason.includes('automation'));
// The "pushed soon after FINAL" debounce must expire. It compares the push to
// the FINAL trigger, which never changes, so without a clock check a PR whose
// FINAL reviewer pushed a fix within 15 min of the trigger was skipped forever.
const oldFinal = {
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: iso(nowMs - 3 * 60 * 60 * 1000),
};
const settledHead = iso(nowMs - 3 * 60 * 60 * 1000 + 4 * 60 * 1000); // pushed 4 min after FINAL, 2h56m ago
const expired = mg.shouldSkipStaleFinalRecovery([oldFinal], settledHead);
assert('soon-after-FINAL debounce expires once the push has settled', !expired.skip);
const expiredBot = mg.shouldSkipStaleFinalRecovery([oldFinal], settledHead, 'praisonai-triage-agent[bot]');
assert('automation-push debounce expires once the push has settled', !expiredBot.skip);
assert('claude automation login', mg.isClaudeAutomationLogin('praisonai-triage-agent[bot]'));
// Bot CHANGES_REQUESTED then APPROVE
@@ -253,6 +488,40 @@ assert('final trigger alone is incomplete', !mg.finalClaudeCompletedOnSha(
'2026-06-27T09:55:00Z'
));
// FINAL started before HEAD, Claude pushed a fix (new HEAD), then replied on HEAD.
// The reply after HEAD must count as complete even though the FINAL trigger
// predates HEAD by >1min (regression: previously blocked).
const finalThenPushThenReply = [
{ user: { login: 'MervinPraison' }, body: '@claude You are the FINAL architecture reviewer.', created_at: '2026-06-27T10:00:00Z' },
{ user: { login: 'praisonai-triage-agent[bot]' }, body: 'Claude finished', created_at: '2026-06-27T10:40:00Z' },
];
assert(
'final complete when reply follows a later HEAD (fix-commit sequence)',
mg.finalClaudeCompletedOnSha(finalThenPushThenReply, '2026-06-27T10:30:00Z')
);
// Old finished reply follows HEAD but predates a NEW FINAL trigger — must NOT
// count the old completion as satisfying the newer review.
const oldReplyBeforeNewFinal = [
{ user: { login: 'MervinPraison' }, body: '@claude You are the FINAL architecture reviewer.', created_at: '2026-06-27T09:00:00Z' },
{ user: { login: 'praisonai-triage-agent[bot]' }, body: 'Claude finished', created_at: '2026-06-27T09:10:00Z' },
{ user: { login: 'MervinPraison' }, body: '@claude You are the FINAL architecture reviewer.', created_at: '2026-06-27T10:00:00Z' },
];
assert(
'old reply before new FINAL does not count as complete',
!mg.finalClaudeCompletedOnSha(oldReplyBeforeNewFinal, '2026-06-27T08:55:00Z')
);
assert(
'stale-FINAL recovery not suppressed by reply predating HEAD',
mg.isStaleFinalAfterPush(
[
{ user: { login: 'MervinPraison' }, body: '@claude You are the FINAL architecture reviewer.', created_at: '2026-06-27T08:00:00Z' },
{ user: { login: 'praisonai-triage-agent[bot]' }, body: 'Claude finished', created_at: '2026-06-27T08:30:00Z' },
],
'2026-06-27T09:00:00Z'
)
);
assert('recent scan comment detected', mg.hasRecentMergeGateScanComment([
{ body: '**Merge gate scan** — eligible', created_at: new Date().toISOString() },
]));
@@ -379,6 +648,58 @@ assert('substantive dispatches still counted', mg.countSubstantiveMergeGateRuns(
assert('countSubstantiveMergeGateRuns null-safe', mg.countSubstantiveMergeGateRuns(null) === 0);
assert('rate limit threshold is 200', mg.MIN_CORE_RATE_LIMIT_REMAINING === 200);
// --- canBypassRecentClaudeCooldown (PR #5728) ---
const finalTrigger = {
user: { login: 'MervinPraison' },
body: '@claude FINAL architecture reviewer',
created_at: '2026-06-12T08:00:00Z',
};
const finishedOnHeadReply = {
user: { login: 'praisonai-triage-agent[bot]' },
body: "**Claude finished @MervinPraison's task**",
created_at: '2026-06-12T08:20:00Z',
};
// Bypass allowed: FINAL done on HEAD, no later push, no newer trigger.
assert(
'bypass when FINAL completed on HEAD',
mg.canBypassRecentClaudeCooldown([finalTrigger, finishedOnHeadReply], '2026-06-12T08:10:00Z')
);
// No bypass: HEAD pushed after the finished reply (stale reviewed SHA).
assert(
'no bypass when head pushed after finish',
!mg.canBypassRecentClaudeCooldown([finalTrigger, finishedOnHeadReply], '2026-06-12T08:25:00Z')
);
// No bypass: a newer @claude request posted after the finish is still pending.
const newerClaudeRequest = {
user: { login: 'MervinPraison' },
body: '@claude please also handle edge case',
created_at: '2026-06-12T08:30:00Z',
};
assert(
'no bypass when newer @claude trigger after finish',
!mg.canBypassRecentClaudeCooldown(
[finalTrigger, finishedOnHeadReply, newerClaudeRequest],
'2026-06-12T08:10:00Z'
)
);
// Bypass restored once the newer request also completes.
const newerClaudeReply = {
user: { login: 'praisonai-triage-agent[bot]' },
body: "**Claude finished @MervinPraison's task**",
created_at: '2026-06-12T08:40:00Z',
};
assert(
'bypass restored after newer request completes',
mg.canBypassRecentClaudeCooldown(
[finalTrigger, finishedOnHeadReply, newerClaudeRequest, newerClaudeReply],
'2026-06-12T08:10:00Z'
)
);
assert(
'no bypass without any FINAL completion',
!mg.canBypassRecentClaudeCooldown([newerClaudeRequest], '2026-06-12T08:10:00Z')
);
(async () => {
const skipLow = await mg.shouldSkipMergeGateDispatch(
{
@@ -395,6 +716,20 @@ assert('rate limit threshold is 200', mg.MIN_CORE_RATE_LIMIT_REMAINING === 200);
);
assert('rate limit low skips dispatch', skipLow === true);
const byLabel = {
'pipeline/merge-ready': [{ number: 5803, pull_request: {}, labels: [] }],
'pipeline/blocked:merge-gate': [
{ number: 5766, pull_request: {}, labels: [] },
{ number: 5803, pull_request: {}, labels: [] },
],
};
const scanList = await mg.listPrNumbersForMergeGateScan(
{ rest: { issues: { listForRepo: async ({ labels }) => ({ data: byLabel[labels] || [] }) } } },
'owner', 'repo', { info: () => {} }
);
assert('held-out PRs stay in the scan list (deduped)', JSON.stringify(scanList) === '[5766,5803]');
await listingTests;
process.exit(failed ? 1 : 0);
})().catch((err) => {
console.error(err);
+413 -32
View File
@@ -10,6 +10,10 @@ const CLAUDE_ACTIVE_MS = 35 * 60 * 1000;
const STALE_FINAL_RECOVERY_WINDOW_MS = 60 * 60 * 1000;
const STALE_FINAL_MAX_PER_WINDOW = 2;
const PUSH_AFTER_FINAL_DEBOUNCE_MS = 15 * 60 * 1000;
// How long after a push the "pushed soon after FINAL" debounce keeps holding
// stale-FINAL recovery back (long enough for CI / a batch of fixes). After that
// the debounce expires, so a missed completion can no longer block a PR forever.
const PUSH_SETTLE_MS = 30 * 60 * 1000;
const ALLOWED_MERGE_STATES = new Set(['CLEAN', 'UNSTABLE']);
const AGENT_PY_MAX_AUTO_LINES = 100;
const PR_MAX_AUTO_ADDITIONS = 800;
@@ -19,6 +23,9 @@ const WORKFLOW_ONLY_LABEL = 'merge-gate-ci-only';
const EXTERNAL_EXAMPLE_PATH_PREFIX = 'examples/tools/external/';
const EXTERNAL_EXAMPLE_LABEL = 'pipeline/external-example';
const MAINTAINER_ACCEPT_EXAMPLE_LABEL = 'maintainer-accept-example';
const CORE_TOOLS_DIR_PREFIX = 'src/praisonai-agents/praisonaiagents/tools/';
const MAINTAINER_ACCEPT_CORE_TOOLS_LABEL = 'maintainer-accept-core-tools';
const CORE_VENDOR_TOOL_LABEL = 'pipeline/core-vendor-tool';
const CI_ONLY_PATH_PREFIXES = ['.github/workflows/', '.github/actions/', '.github/scripts/merge-gate'];
const SDK_PATH_PREFIXES = ['src/praisonai-agents/', 'src/praisonai/', 'src/praisonai-ts/'];
const TS_SDK_SOURCE_PREFIX = 'src/praisonai-ts/src/';
@@ -60,6 +67,34 @@ const MERGE_READY_LABEL = 'pipeline/merge-ready';
const OPTIONAL_CANCELLED_CHECKS = new Set(['detect-and-trigger']);
/** Cancelled smoke/windows after timeout are non-blocking when core shards passed on HEAD. */
const OPTIONAL_CANCELLED_WHEN_CORE_GREEN = new Set(['smoke', 'test-windows']);
/** Auto PR Comment jobs — queued/pending must not block merge when test-core is green on HEAD. */
const OPTIONAL_PENDING_WHEN_CORE_GREEN = new Set([
'claude-review-recovery',
'claude-after-prior-reviewers',
'claude-fallback-timeout',
'post-missing-claude-final',
'bot-pr-trigger-reviews',
// Optimized suite jobs often outlive the test-core aggregator on busy fleet days.
'smoke',
'main (3.11)',
'openai-live',
'test-summary',
'GitGuardian Security Checks',
]);
/** PR authors eligible for claude merge-gate auto-merge (triage fleet only). */
const AUTO_MERGE_AUTHOR_LOGINS = new Set([
'MervinPraison',
'praisonai-triage-agent',
'praisonai-triage-agent[bot]',
'app/praisonai-triage-agent',
'github-actions[bot]',
]);
/** GitHub author_association values that always require maintainer merge. */
const MAINTAINER_ONLY_ASSOCIATIONS = new Set([
'FIRST_TIME_CONTRIBUTOR',
'CONTRIBUTOR',
'NONE',
]);
const BOT_REVIEWER_PATTERNS = [
'coderabbit',
'qodo',
@@ -70,6 +105,32 @@ const BOT_REVIEWER_PATTERNS = [
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function isAutoMergeAuthor(user) {
const login = (user?.login || '').trim();
if (!login) return false;
if (AUTO_MERGE_AUTHOR_LOGINS.has(login)) return true;
if (login.startsWith('app/') && login.includes('praisonai-triage')) return true;
return login.endsWith('[bot]') && login.toLowerCase().includes('praisonai-triage');
}
function isFirstTimeContributor(pr) {
return pr?.author_association === 'FIRST_TIME_CONTRIBUTOR';
}
/** Human / external authors not on the triage auto-merge allowlist. */
function maintainerOnlyAuthorReason(pr) {
if (!pr?.user?.login) return 'maintainer-only author (missing login)';
// Trusted automation first — GitHub often marks bots as CONTRIBUTOR.
if (isAutoMergeAuthor(pr.user)) return null;
if (isFirstTimeContributor(pr)) {
return `auto-merge disabled: first-time contributor (@${pr.user.login})`;
}
if (MAINTAINER_ONLY_ASSOCIATIONS.has(pr.author_association)) {
return `maintainer-only author (@${pr.user.login}, ${pr.author_association})`;
}
return `maintainer-only author (@${pr.user.login})`;
}
function isFinalClaudeTriggerComment(c) {
const body = (c.body || '').toLowerCase();
if (!AUTO_ACTORS.includes(c.user.login)) return false;
@@ -86,6 +147,16 @@ function isClaudeTriggerNoise(c) {
return false;
}
// Markers of a completed FINAL review. anthropics/claude-code-action posted a
// "Claude finished" tracking comment; the local action used since 2026-10-09
// posts the review itself, which always carries the mandatory Phase 1 table
// (see FINAL_CLAUDE_REVIEW_BODY) and usually a "Final architecture review" title.
const FINAL_REPLY_MARKERS = [
/Claude finished/,
/^#{1,4}\s*Final architecture review\b/im,
/^#{1,4}\s*Phase 1 — AGENTS\.md review/m,
];
function isClaudeFinalReplyComment(c) {
const login = (c.user?.login || '').toLowerCase();
const fromAutomation =
@@ -93,7 +164,10 @@ function isClaudeFinalReplyComment(c) {
login.includes('github-actions') ||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
if (!fromAutomation) return false;
return (c.body || '').includes('Claude finished');
const body = c.body || '';
// The FINAL trigger embeds the Phase 1 heading; an @claude request is never a reply.
if (/^\s*@claude\b/i.test(body)) return false;
return FINAL_REPLY_MARKERS.some((re) => re.test(body));
}
function hasRecentClaudeTrigger(comments, minutes = 35) {
@@ -236,11 +310,16 @@ function isStaleFinalAfterPush(comments, headPushedAt) {
);
const finalTime = new Date(latestFinal.created_at).getTime();
if (headTime <= finalTime + 60000) return false;
const claudeRepliedAfterFinal = comments.some((c) => {
// The finished reply must follow BOTH the latest FINAL trigger and HEAD, so
// a reply predating HEAD (stale completion) cannot suppress recovery.
const finishedFloor = Math.max(finalTime, headTime - 60000);
const claudeFinishedOnHead = comments.some((c) => {
if (!isClaudeFinalReplyComment(c)) return false;
return new Date(c.created_at).getTime() >= finalTime - 60000;
return new Date(c.created_at).getTime() >= finishedFloor;
});
if (claudeRepliedAfterFinal) return false;
if (claudeFinishedOnHead) return false;
const claudeSinceHead = comments.some((c) => {
if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false;
if (isClaudeTriggerNoise(c)) return false;
@@ -292,13 +371,20 @@ function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin =
if (!isStaleFinalAfterPush(comments, headPushedAt)) {
return { skip: true, reason: 'not stale' };
}
if (headPusherLogin && isClaudeAutomationLogin(headPusherLogin)) {
return { skip: true, reason: 'head pushed by Claude automation' };
// The debounces below compare the push to the FINAL trigger, a relation that
// never changes; only hold recovery back while the push is still settling.
const pushSettling =
!!headPushedAt && Date.now() - new Date(headPushedAt).getTime() < PUSH_SETTLE_MS;
if (pushSettling && headPusherLogin && isClaudeAutomationLogin(headPusherLogin) && isPushSoonAfterLatestFinal(comments, headPushedAt)) {
return {
skip: true,
reason: 'head pushed by Claude automation soon after FINAL (wait for CI)',
};
}
if (needsFinalReReviewAfterConflictRebase(comments, headPushedAt)) {
return { skip: false, reason: '' };
}
if (isPushSoonAfterLatestFinal(comments, headPushedAt)) {
if (pushSettling && isPushSoonAfterLatestFinal(comments, headPushedAt)) {
return {
skip: true,
reason: 'head pushed soon after FINAL (wait for CI / batched fixes)',
@@ -315,6 +401,65 @@ function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin =
return { skip: false, reason: '' };
}
function latestClaudeFinishedAtMs(comments) {
let latest = 0;
for (const c of comments) {
if (!isClaudeFinalReplyComment(c)) continue;
latest = Math.max(latest, new Date(c.created_at).getTime());
}
return latest || null;
}
/**
* HEAD moved after the latest "Claude finished" — need another FINAL/CI cycle.
* No slack window here: a push even seconds after the finished reply means the
* reviewed SHA is stale, so the cooldown must NOT be bypassed.
*/
function hasHeadPushAfterLatestClaudeFinish(comments, headPushedAt) {
const finishMs = latestClaudeFinishedAtMs(comments);
if (!finishMs || !headPushedAt) return false;
const headMs = new Date(headPushedAt).getTime();
return headMs > finishMs;
}
/** Latest non-noise @claude trigger timestamp (any flavour), or null. */
function latestClaudeTriggerAtMs(comments) {
let latest = 0;
for (const c of comments || []) {
if (!CLAUDE_TRIGGER_LOGINS.includes(c.user?.login)) continue;
if (isClaudeTriggerNoise(c)) continue;
if (!(c.body || '').includes('@claude')) continue;
latest = Math.max(latest, new Date(c.created_at).getTime());
}
return latest || null;
}
/**
* A newer @claude request (e.g. a maintainer follow-up) was posted after the
* completion we would rely on to bypass. That request may still be queued, so
* the cooldown must stay until it also completes.
*/
function hasClaudeTriggerAfterLatestFinish(comments) {
const finishMs = latestClaudeFinishedAtMs(comments);
if (!finishMs) return false;
const triggerMs = latestClaudeTriggerAtMs(comments);
if (!triggerMs) return false;
return triggerMs > finishMs;
}
/**
* FINAL @claude cooldown is for in-flight bot work, not an extra 35m wait after
* FINAL completed on HEAD with green CI prerequisites. Bypass only when the
* completion is on the current SHA AND no newer @claude request is still
* awaiting its own completion.
*/
function canBypassRecentClaudeCooldown(comments, headPushedAt) {
if (!finalClaudeCompletedOnSha(comments, headPushedAt)) return false;
if (hasHeadPushAfterLatestClaudeFinish(comments, headPushedAt)) return false;
if (hasClaudeTriggerAfterLatestFinish(comments)) return false;
return true;
}
function finalClaudeCompletedOnSha(comments, headPushedAt) {
if (!hasFinalClaudeReviewTrigger(comments)) return false;
if (isStaleFinalAfterPush(comments, headPushedAt)) return false;
@@ -324,14 +469,28 @@ function finalClaudeCompletedOnSha(comments, headPushedAt) {
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const finalTime = new Date(latestFinal.created_at).getTime();
if (!headPushedAt) {
return comments.some((c) => {
if (!isClaudeFinalReplyComment(c)) return false;
return new Date(c.created_at).getTime() >= finalTime - 60000;
});
}
const headTime = new Date(headPushedAt).getTime();
// The Claude "finished" reply must follow BOTH the latest FINAL trigger and
// HEAD. We do NOT reject solely because the FINAL trigger predates HEAD:
// Claude may start a FINAL review, push a fix commit (new HEAD), then reply.
// The reply timestamp is the real completion signal. Requiring the reply to
// follow the latest FINAL also prevents an older reply from satisfying a
// newer FINAL trigger.
const replyFloor = Math.max(finalTime, headTime - 60000);
return comments.some((c) => {
if (!isClaudeFinalReplyComment(c)) return false;
return new Date(c.created_at).getTime() >= finalTime - 60000;
return new Date(c.created_at).getTime() >= replyFloor;
});
}
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: 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**MANDATORY READ (before reviewing):**\n- Always read src/praisonai-agents/AGENTS.md\n- If this PR touches src/praisonai-ts/, also read src/praisonai-ts/AGENTS.md §2.1.2 (TS triage + PR review checklist)\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/\n6b. **External tool examples (`examples/tools/external/**`):** If the diff is confined to that path, mark **SDK value** ❌ for monorepo merge — redirect the contributor to PraisonAI-Tools; do **not** Approve for merge unless the PR has the `maintainer-accept-example` label.\n\n**MANDATORY COMMENT FORMAT — include this Phase 1 table in your review comment:**\n#### Phase 1 — AGENTS.md review\n| Check | Result |\n|---|---|\n| Protocol-driven / no heavy impl in core | ✅ or ❌ + one-line rationale |\n| Backward compatible | ✅ or ❌ + one-line rationale |\n| Performance (hot path) | ✅ or ❌ + one-line rationale |\n| **SDK value** | ✅ or ❌ + one-line rationale (explicitly judge whether the change strengthens the SDK) |\n| No Agent param bloat | ✅ or ❌ + one-line rationale |\n| Repo routing | ✅ or ❌ + one-line rationale |\n\nFor TypeScript PRs (src/praisonai-ts/), also add:\n| TS types / parity / tests | ✅ or ❌ + one-line rationale (npm run build && npm test) |\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**MANDATORY READ (before reviewing):**\n- Always read src/praisonai-agents/AGENTS.md\n- If this PR touches src/praisonai-ts/, also read src/praisonai-ts/AGENTS.md §2.1.2 (TS triage + PR review checklist)\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/\n6b. **External tool examples (`examples/tools/external/**`):** If the diff is confined to that path, mark **SDK value** ❌ for monorepo merge — redirect the contributor to PraisonAI-Tools; do **not** Approve for merge unless the PR has the `maintainer-accept-example` label.\n6c. **New vendor `*_tools.py` in core (`src/praisonai-agents/praisonaiagents/tools/`):** If the PR **adds** a new `*_tools.py` for a third-party API/SaaS/dataset (e.g. FXMacroData), mark **Repo routing** ❌ and **SDK value** ❌ — redirect to MervinPraison/PraisonAI-Tools. Do **not** Approve because legacy bundled tools (Tavily/Exa/You.com/etc.) already in core are **not** precedent for new vendor modules. Request changes or recommend closing unless the PR has `maintainer-accept-core-tools`.\n\n**MANDATORY COMMENT FORMAT — include this Phase 1 table in your review comment:**\n#### Phase 1 — AGENTS.md review\n| Check | Result |\n|---|---|\n| Protocol-driven / no heavy impl in core | ✅ or ❌ + one-line rationale |\n| Backward compatible | ✅ or ❌ + one-line rationale |\n| Performance (hot path) | ✅ or ❌ + one-line rationale |\n| **SDK value** | ✅ or ❌ + one-line rationale (explicitly judge whether the change strengthens the SDK) |\n| No Agent param bloat | ✅ or ❌ + one-line rationale |\n| Repo routing | ✅ or ❌ + one-line rationale |\n\nFor TypeScript PRs (src/praisonai-ts/), also add:\n| TS types / parity / tests | ✅ or ❌ + one-line rationale (npm run build && npm test) |\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. **Never** Approve or write “Approve for merge” if any Phase 1 row is ❌, if rule 6b/6c applies, or if the change belongs in PraisonAI-Tools/PraisonAI-Plugins. Use **Request changes**, redirect to the external repo, and recommend closing the monorepo PR when routing is wrong.\n12. Approve only when every Phase 1 row is ✅ (including **Repo routing** and **SDK value**) and blocking issues are resolved; otherwise request changes with clear action items';
async function getMergeState(github, owner, repo, prNumber) {
const query = `
@@ -451,6 +610,12 @@ function isAcceptableCheckConclusion(run, runs) {
return false;
}
function isIgnorablePendingCheck(run, runs) {
if (!isPendingRun(run)) return false;
if (!OPTIONAL_PENDING_WHEN_CORE_GREEN.has(run.name)) return false;
return coreTestsGreenOnRuns(runs);
}
async function allChecksGreenOnSha(github, owner, repo, sha, core) {
const runs = bestRunsByName(await listChecksOnSha(github, owner, repo, sha));
if (runs.length === 0) {
@@ -459,6 +624,10 @@ async function allChecksGreenOnSha(github, owner, repo, sha, core) {
}
for (const run of runs) {
if (run.status !== 'completed') {
if (isIgnorablePendingCheck(run, runs)) {
core?.info?.(`Ignoring pending optional check ${run.name} — test-core green on HEAD`);
continue;
}
core?.info?.(`Check pending: ${run.name} (${run.status})`);
return false;
}
@@ -536,6 +705,16 @@ async function getCoreRateLimitRemaining(github) {
}
}
async function shouldSkipMergeGateScan(github, core, options = {}) {
const minCoreRemaining = options.minCoreRemaining ?? MIN_CORE_RATE_LIMIT_REMAINING;
const remaining = await getCoreRateLimitRemaining(github);
if (remaining !== null && remaining < minCoreRemaining) {
core?.info?.(`Skip merge gate scan: rate limit low (${remaining} core remaining)`);
return true;
}
return false;
}
async function shouldSkipMergeGateDispatch(github, owner, repo, prNumber, core, options = {}) {
const maxPending = options.maxPending ?? MAX_PENDING_MERGE_GATE_RUNS;
const minCoreRemaining = options.minCoreRemaining ?? MIN_CORE_RATE_LIMIT_REMAINING;
@@ -547,6 +726,11 @@ async function shouldSkipMergeGateDispatch(github, owner, repo, prNumber, core,
return true;
}
const ctx = await loadPrContext(github, owner, repo, prNumber);
const authorBlock = maintainerOnlyAuthorReason(ctx.pr);
if (authorBlock) {
core?.info?.(`Skip merge gate dispatch for PR #${prNumber}: ${authorBlock}`);
return true;
}
const pendingRuns = await countPendingMergeGateRuns(github, owner, repo);
const reason = mergeGateDispatchBlockedReason({
labels: ctx.labels,
@@ -591,6 +775,63 @@ function isExternalExampleOnlyChange(files) {
return files.every((f) => f.filename.startsWith(EXTERNAL_EXAMPLE_PATH_PREFIX));
}
/** New third-party vendor modules must land in PraisonAI-Tools, not core (legacy bundled *_tools.py excepted). */
function listAddedCoreVendorToolModules(files) {
return (files || [])
.filter((f) => f.status === 'added' && f.filename.startsWith(CORE_TOOLS_DIR_PREFIX))
.filter((f) => /\/[^/]+_tools\.py$/.test(f.filename))
.map((f) => f.filename);
}
function shouldTagCoreVendorToolMisroute(files, labels = []) {
return (
listAddedCoreVendorToolModules(files).length > 0 &&
!labels.includes(MAINTAINER_ACCEPT_CORE_TOOLS_LABEL)
);
}
function newVendorToolModuleInCoreReason(files, labels = []) {
if (!shouldTagCoreVendorToolMisroute(files, labels)) return null;
const added = listAddedCoreVendorToolModules(files);
return `new vendor tool module in core (${added.join(', ')}) — belongs in PraisonAI-Tools (add ${MAINTAINER_ACCEPT_CORE_TOOLS_LABEL} to opt in)`;
}
function latestClaudeFinishedCommentOnHead(comments, headPushedAt) {
if (!headPushedAt) return null;
const headTime = new Date(headPushedAt).getTime();
let latest = null;
let latestTime = 0;
for (const c of comments || []) {
if (!isClaudeFinalReplyComment(c)) continue;
const t = new Date(c.created_at).getTime();
if (t < headTime - 60000) continue;
if (t > latestTime) {
latestTime = t;
latest = c;
}
}
return latest;
}
function looksLikeFinalApproveForMerge(body) {
const b = (body || '').toLowerCase();
if (b.includes('request changes')) return false;
if (b.includes('repo routing | ❌') || b.includes('repo routing| ❌')) return false;
return (
b.includes('approve for merge') ||
b.includes('verdict: ✅ approve') ||
b.includes('verdict: approve')
);
}
/** Catches cases like #5661 where FINAL approved despite new vendor *_tools.py in core. */
function wrongFinalApprovalForVendorToolInCoreReason(comments, headPushedAt, files, labels = []) {
if (!newVendorToolModuleInCoreReason(files, labels)) return null;
const finished = latestClaudeFinishedCommentOnHead(comments, headPushedAt);
if (!finished || !looksLikeFinalApproveForMerge(finished.body)) return null;
return 'FINAL approved vendor tool in core (wrong verdict) — re-trigger FINAL after routing rules';
}
function isInternalPullRequestLink(link, owner, repo) {
if (link?.base?.repo?.full_name === `${owner}/${repo}`) return true;
const baseUrl = link?.base?.repo?.url || '';
@@ -909,6 +1150,10 @@ async function loadPrContext(github, owner, repo, prNumber) {
const headCommitDate = await getHeadCommitDate(github, owner, repo, prNumber);
const headPushedAt = headCommitDate || pr.updated_at;
if (issue.author_association && !pr.author_association) {
pr.author_association = issue.author_association;
}
return {
pr,
issue,
@@ -947,12 +1192,16 @@ async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, op
reasons.push('fork PR');
}
const maintainerAuthorReason = maintainerOnlyAuthorReason(ctx.pr);
if (maintainerAuthorReason) reasons.push(maintainerAuthorReason);
if (hasRecentConflictComment(ctx.comments, ctx.headPushedAt)) {
reasons.push('recent merge-conflict @claude');
}
if (!skipRecentClaudeCooldown && hasRecentClaudeTrigger(ctx.comments, 35)) {
const verdictOnHead = findMergeGateVerdict(ctx.comments, null, ctx.headPushedAt) !== null;
if (!verdictOnHead) reasons.push('recent @claude within 35min');
const bypassCooldown = canBypassRecentClaudeCooldown(ctx.comments, ctx.headPushedAt);
if (!verdictOnHead && !bypassCooldown) reasons.push('recent @claude within 35min');
}
if (!skipGlobalClaudeRunCheck && (await hasInProgressClaudeAssistant(github, owner, repo, prNumber))) {
@@ -990,6 +1239,17 @@ async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, op
);
}
const vendorToolReason = newVendorToolModuleInCoreReason(pullFiles, ctx.labels);
if (vendorToolReason) reasons.push(vendorToolReason);
const wrongFinalVendor = wrongFinalApprovalForVendorToolInCoreReason(
ctx.comments,
ctx.headPushedAt,
pullFiles,
ctx.labels
);
if (wrongFinalVendor) reasons.push(wrongFinalVendor);
const agentChange = getAgentPyChangeFromFiles(pullFiles);
const agentManual = manualReviewReasonForAgentPy(agentChange);
if (agentManual) reasons.push(agentManual);
@@ -1006,47 +1266,135 @@ async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, op
if (ctx.pr.mergeable === false) reasons.push('not mergeable');
// Starvation guard (see BLOCKED_ON_HEAD_REASON). Not applied on the merge
// step itself, which only runs right after a fresh verdict.
let blockedOnHead = false;
if (!forMergeStep && latestMergeGateVerdictComment(ctx.comments, ctx.headPushedAt)) {
const runs = await listChecksOnSha(github, owner, repo, ctx.headSha);
if (blockedOnUnchangedHead(ctx.comments, ctx.headPushedAt, runs)) {
blockedOnHead = true;
reasons.push(BLOCKED_ON_HEAD_REASON);
}
}
const ready = reasons.length === 0;
return {
ready,
reasons,
headSha: ctx.headSha,
prNumber,
blockedOnHead,
blockFixDue: blockedOnHead && blockFixRequestDue(ctx.comments, ctx.headSha),
};
}
async function listPrNumbersForMergeGateScan(github, owner, repo, core) {
let mergeReadyIssues;
if (typeof github.paginate === 'function') {
mergeReadyIssues = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: 'open',
labels: MERGE_READY_LABEL,
per_page: 100,
});
} else {
({ data: mergeReadyIssues } = await github.rest.issues.listForRepo({
owner,
repo,
state: 'open',
labels: MERGE_READY_LABEL,
per_page: 100,
}));
// ---------------------------------------------------------------------------
// Starvation guard. Candidates are scanned oldest-first and capped at
// MAX_CANDIDATES, so PRs the gate keeps BLOCKing on an unchanged HEAD used to
// take every slot on every scan (170 identical Opus BLOCKs on three PRs) while
// newer ready PRs were never looked at. A BLOCK on the current HEAD now holds
// the PR out until a commit lands, a check completes after the verdict (e.g. a
// pending job the verdict cited has since passed), or BLOCK_RECHECK_AFTER_MS.
// While held out, Claude is asked (once per HEAD, capped) to fix the findings.
// ---------------------------------------------------------------------------
const BLOCK_RECHECK_AFTER_MS = 24 * 60 * 60 * 1000;
const BLOCK_FIX_MARKER = 'merge-gate-fix-request:';
const MAX_BLOCK_FIX_REQUESTS = 3;
const BLOCKED_ON_HEAD_REASON = 'merge gate blocked this HEAD (waiting for a fix commit)';
const BLOCKED_MERGE_GATE_LABEL = 'pipeline/blocked:merge-gate';
// Only verdicts posted by the gate's own automation may hold a PR out; anyone
// can comment "MERGE_GATE_VERDICT: BLOCK". Exact logins: a substring match would
// trust any account whose name merely contains the bot's name.
const GATE_VERDICT_AUTHORS = new Set(['praisonai-triage-agent[bot]', 'github-actions[bot]']);
function latestMergeGateVerdictComment(comments, headPushedAt) {
const headTime = headPushedAt ? new Date(headPushedAt).getTime() - 60000 : 0;
let latest = null;
for (const c of comments || []) {
if (!GATE_VERDICT_AUTHORS.has(c.user?.login)) continue;
if (!isMergeGateVerdictComment(c.body)) continue;
if (isAutomatedFallbackVerdict(c.body)) continue;
const t = new Date(c.created_at).getTime();
if (headTime && t < headTime) continue;
if (!latest || t > new Date(latest.created_at).getTime()) latest = c;
}
const mergeReady = mergeReadyIssues
return latest;
}
/** True when the newest verdict on HEAD is BLOCK and nothing has changed since. */
function blockedOnUnchangedHead(comments, headPushedAt, checkRuns, nowMs = Date.now()) {
const verdict = latestMergeGateVerdictComment(comments, headPushedAt);
if (!verdict || !/MERGE_GATE_VERDICT:\s*BLOCK\b/.test(verdict.body || '')) return false;
const verdictMs = new Date(verdict.created_at).getTime();
if (nowMs - verdictMs >= BLOCK_RECHECK_AFTER_MS) return false;
const checkCompletedSince = (checkRuns || []).some(
(r) => r.completed_at && new Date(r.completed_at).getTime() > verdictMs
);
return !checkCompletedSince;
}
function buildBlockFixRequestBody(headSha) {
// Must start with @claude and must NOT contain the verdict marker or the
// FINAL-review phrases: claude.yml ignores comments with the marker, and the
// merge gate would mistake the others for a FINAL trigger.
return [
'@claude The merge gate blocked this PR on HEAD `' + headSha.slice(0, 12) + '`.',
'',
'Read the latest merge gate BLOCK verdict comment on this PR and the review threads it cites.',
'Fix every blocking finding on this branch, add or update tests for each fix, and push.',
'Do not post an approval or a verdict yourself; the review chain re-runs after your push.',
'If a finding is wrong, explain why in a reply instead of changing code.',
'',
`<!-- ${BLOCK_FIX_MARKER}${headSha.slice(0, 12)} -->`,
].join('\n');
}
/** One fix request per HEAD, at most MAX_BLOCK_FIX_REQUESTS per PR (trusted posters only). */
function blockFixRequestDue(comments, headSha) {
const requests = (comments || []).filter(
(c) => CLAUDE_TRIGGER_LOGINS.includes(c.user?.login) && (c.body || '').includes(`<!-- ${BLOCK_FIX_MARKER}`)
);
if (requests.length >= MAX_BLOCK_FIX_REQUESTS) return false;
const marker = `<!-- ${BLOCK_FIX_MARKER}${headSha.slice(0, 12)} -->`;
return !requests.some((c) => (c.body || '').includes(marker));
}
async function listOpenIssuesWithLabel(github, owner, repo, label) {
const params = { owner, repo, state: 'open', labels: label, per_page: 100 };
if (typeof github.paginate === 'function') {
return github.paginate(github.rest.issues.listForRepo, params);
}
return (await github.rest.issues.listForRepo(params)).data;
}
async function listPrNumbersForMergeGateScan(github, owner, repo, core) {
const mergeReadyIssues = await listOpenIssuesWithLabel(github, owner, repo, MERGE_READY_LABEL);
// Label sync swaps merge-ready for the merge-gate blocker on held-out PRs;
// keep scanning them (they never take a slot while held) so the fix request
// is still posted and they re-enter once a fix lands.
const heldOut = await listOpenIssuesWithLabel(github, owner, repo, BLOCKED_MERGE_GATE_LABEL);
const eligible = (issues) => issues
.filter((issue) => issue.pull_request)
.filter((issue) => {
const labels = (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
if (labels.includes(EXTERNAL_EXAMPLE_LABEL) && !labels.includes(MAINTAINER_ACCEPT_EXAMPLE_LABEL)) {
return false;
}
if (labels.includes(CORE_VENDOR_TOOL_LABEL) && !labels.includes(MAINTAINER_ACCEPT_CORE_TOOLS_LABEL)) {
return false;
}
return true;
})
.map((issue) => issue.number)
.map((issue) => issue.number);
// Only a real merge-ready PR may short-circuit the scan; held-out PRs alone
// must not hide unlabelled ready PRs from the oldest-open fallback below.
const labelledReady = eligible(mergeReadyIssues);
const mergeReady = [...labelledReady, ...eligible(heldOut)]
.filter((n, i, all) => all.indexOf(n) === i)
.sort((a, b) => a - b);
if (mergeReady.length > 0) {
if (labelledReady.length > 0) {
core?.info?.(
`Merge gate scan: ${mergeReady.length} ${MERGE_READY_LABEL} PR(s) (skipping unlabelled open PRs)`
);
@@ -1087,7 +1435,12 @@ async function selectMergeGateCandidates(github, owner, repo, prNumbers, maxCand
if (result.ready) {
readyList.push({ pr_number: num, head_sha: result.headSha });
} else {
skipped.push({ pr: num, reasons: result.reasons });
skipped.push({
pr: num,
reasons: result.reasons,
head_sha: result.headSha,
block_fix_due: !!result.blockFixDue,
});
}
}
readyList.sort((a, b) => a.pr_number - b.pr_number);
@@ -1140,6 +1493,14 @@ function findMergeGateVerdict(comments, minCreatedAt = null, headPushedAt = null
}
module.exports = {
GATE_VERDICT_AUTHORS,
listPrNumbersForMergeGateScan,
BLOCKED_ON_HEAD_REASON,
BLOCK_FIX_MARKER,
blockedOnUnchangedHead,
blockFixRequestDue,
buildBlockFixRequestBody,
latestMergeGateVerdictComment,
CLAUDE_TRIGGER_LOGINS,
AUTO_ACTORS,
isFinalClaudeTriggerComment,
@@ -1158,6 +1519,12 @@ module.exports = {
getMergeState,
OPTIONAL_CANCELLED_CHECKS,
OPTIONAL_CANCELLED_WHEN_CORE_GREEN,
OPTIONAL_PENDING_WHEN_CORE_GREEN,
AUTO_MERGE_AUTHOR_LOGINS,
MAINTAINER_ONLY_ASSOCIATIONS,
isAutoMergeAuthor,
maintainerOnlyAuthorReason,
isIgnorablePendingCheck,
isCoreTestRun,
coreTestsGreenOnRuns,
isPendingRun,
@@ -1174,6 +1541,14 @@ module.exports = {
EXTERNAL_EXAMPLE_PATH_PREFIX,
EXTERNAL_EXAMPLE_LABEL,
MAINTAINER_ACCEPT_EXAMPLE_LABEL,
listAddedCoreVendorToolModules,
shouldTagCoreVendorToolMisroute,
newVendorToolModuleInCoreReason,
wrongFinalApprovalForVendorToolInCoreReason,
looksLikeFinalApproveForMerge,
CORE_TOOLS_DIR_PREFIX,
MAINTAINER_ACCEPT_CORE_TOOLS_LABEL,
CORE_VENDOR_TOOL_LABEL,
isInternalPullRequestLink,
resolvePrNumberFromLinkedPullRequests,
resolvePrNumberFromHeadBranch,
@@ -1199,6 +1574,11 @@ module.exports = {
shouldSkipFinalRecovery,
shouldSkipStaleFinalRecovery,
isClaudeAutomationLogin,
canBypassRecentClaudeCooldown,
hasHeadPushAfterLatestClaudeFinish,
hasClaudeTriggerAfterLatestFinish,
latestClaudeFinishedAtMs,
latestClaudeTriggerAtMs,
isPushSoonAfterLatestFinal,
countFinalTriggersSince,
STALE_FINAL_RECOVERY_WINDOW_MS,
@@ -1223,4 +1603,5 @@ module.exports = {
countSubstantiveMergeGateRuns,
countPendingMergeGateRuns,
shouldSkipMergeGateDispatch,
shouldSkipMergeGateScan,
};
+32 -17
View File
@@ -1,15 +1,17 @@
#!/usr/bin/env bash
# Pick a working Claude Code OAuth token (primary/secondary with failover).
# Env: CLAUDE_CODE_OAUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN_B, CLAUDE_OAUTH_ACTIVE
# Pick a working Claude Code OAuth token (primary / secondary / tertiary failover).
# Env: CLAUDE_CODE_OAUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN_B, CLAUDE_CODE_OAUTH_TOKEN_C, CLAUDE_OAUTH_ACTIVE
# Out: GITHUB_OUTPUT token, slot
set -euo pipefail
PRIMARY="${CLAUDE_CODE_OAUTH_TOKEN:-}"
SECONDARY="${CLAUDE_CODE_OAUTH_TOKEN_B:-}"
TERTIARY="${CLAUDE_CODE_OAUTH_TOKEN_C:-}"
ACTIVE="${CLAUDE_OAUTH_ACTIVE:-primary}"
ACTIVE_LC=$(echo "$ACTIVE" | tr '[:upper:]' '[:lower:]')
case "$ACTIVE_LC" in
tertiary|c|3) PREFERRED=tertiary ;;
secondary|b|2) PREFERRED=secondary ;;
*) PREFERRED=primary ;;
esac
@@ -63,23 +65,36 @@ try_token() {
SELECTED_SLOT=""
SELECTED_TOKEN=""
if [[ "$PREFERRED" == "primary" ]]; then
try_token primary "$PRIMARY" || try_token secondary "$SECONDARY" || true
else
try_token secondary "$SECONDARY" || try_token primary "$PRIMARY" || true
fi
case "$PREFERRED" in
tertiary)
try_token tertiary "$TERTIARY" || try_token secondary "$SECONDARY" || try_token primary "$PRIMARY" || true
;;
secondary)
try_token secondary "$SECONDARY" || try_token primary "$PRIMARY" || try_token tertiary "$TERTIARY" || true
;;
*)
try_token primary "$PRIMARY" || try_token secondary "$SECONDARY" || try_token tertiary "$TERTIARY" || true
;;
esac
if [[ -z "$SELECTED_TOKEN" ]]; then
if [[ "$PREFERRED" == "primary" && -n "$PRIMARY" ]]; then
SELECTED_TOKEN="$PRIMARY"
SELECTED_SLOT="primary-unprobed"
elif [[ -n "$SECONDARY" ]]; then
SELECTED_TOKEN="$SECONDARY"
SELECTED_SLOT="secondary-unprobed"
elif [[ -n "$PRIMARY" ]]; then
SELECTED_TOKEN="$PRIMARY"
SELECTED_SLOT="primary-unprobed"
else
# Honor the operator-selected preference first, then fall back in
# preference order across the remaining configured tokens.
case "$PREFERRED" in
tertiary) FALLBACK_ORDER=("tertiary:$TERTIARY" "secondary:$SECONDARY" "primary:$PRIMARY") ;;
secondary) FALLBACK_ORDER=("secondary:$SECONDARY" "primary:$PRIMARY" "tertiary:$TERTIARY") ;;
*) FALLBACK_ORDER=("primary:$PRIMARY" "secondary:$SECONDARY" "tertiary:$TERTIARY") ;;
esac
for entry in "${FALLBACK_ORDER[@]}"; do
slot="${entry%%:*}"
token="${entry#*:}"
if [[ -n "$token" ]]; then
SELECTED_TOKEN="$token"
SELECTED_SLOT="${slot}-unprobed"
break
fi
done
if [[ -z "$SELECTED_TOKEN" ]]; then
echo "::error::No Claude OAuth token configured"
exit 1
fi
@@ -55,6 +55,27 @@ const blockers = ps.deriveBlockerLabels({
});
assert('maps ci blocker', blockers.includes('pipeline/blocked:ci'));
assert('maps manual blocker', blockers.includes('pipeline/blocked:manual-review'));
assert(
'maps vendor tool to wrong-repo blocker',
ps.deriveBlockerLabels({
ready: false,
reasons: ['new vendor tool module in core (src/.../fxmacrodata_tools.py) — belongs in PraisonAI-Tools'],
}).includes('pipeline/blocked:wrong-repo')
);
assert(
'maps wrong FINAL vendor approval to wrong-repo',
ps.deriveBlockerLabels({
ready: false,
reasons: ['FINAL approved vendor tool in core (wrong verdict) — re-trigger FINAL'],
}).includes('pipeline/blocked:wrong-repo')
);
assert(
'core vendor tag when added *_tools.py',
mg.shouldTagCoreVendorToolMisroute(
[{ status: 'added', filename: 'src/praisonai-agents/praisonaiagents/tools/fxmacrodata_tools.py' }],
[]
)
);
assert(
'internal link matches upstream base',
+43 -8
View File
@@ -22,6 +22,8 @@ const BLOCKER_LABELS = [
'pipeline/blocked:cooldown',
'pipeline/blocked:stale-final',
'pipeline/blocked:no-final',
'pipeline/blocked:wrong-repo',
'pipeline/blocked:merge-gate',
];
const ALL_PIPELINE_LABELS = [...STAGE_LABELS, ...BLOCKER_LABELS];
@@ -37,11 +39,22 @@ const LABEL_SPECS = [
{ name: 'pipeline/blocked:cooldown', color: 'fef2c0', description: 'Blocked: post-push or @claude cooldown' },
{ name: 'pipeline/blocked:stale-final', color: 'f9d0c4', description: 'Blocked: FINAL stale after new commits' },
{ name: 'pipeline/blocked:no-final', color: 'ededed', description: 'Blocked: no FINAL @claude trigger yet' },
{ name: 'pipeline/blocked:merge-gate', color: 'e4e669', description: 'Blocked: merge gate BLOCK on HEAD; fix requested' },
{
name: 'pipeline/external-example',
color: 'bfd4f2',
description: 'Diff only under examples/tools/external/ — not merge-ready without maintainer-accept-example',
},
{
name: 'pipeline/core-vendor-tool',
color: 'c2e0c6',
description: 'Adds praisonaiagents/tools/*_tools.py vendor module — route to PraisonAI-Tools (maintainer-accept-core-tools to opt in)',
},
{
name: 'pipeline/blocked:wrong-repo',
color: '5319e7',
description: 'Blocked: belongs in PraisonAI-Tools / external repo, not core SDK',
},
];
// PRs with pipeline/external-example cannot reach pipeline/merge-ready unless
@@ -57,6 +70,13 @@ function deriveStage(comments, evalResult) {
function reasonToBlockerLabel(reason) {
const r = (reason || '').toLowerCase();
if (
r.includes('vendor tool module in core') ||
r.includes('wrong verdict') ||
r.includes('external example only')
) {
return 'pipeline/blocked:wrong-repo';
}
if (r.includes('ci not green') || r.includes('sdk code changed but no ci')) return 'pipeline/blocked:ci';
if (
r.includes('conflict') ||
@@ -66,15 +86,18 @@ function reasonToBlockerLabel(reason) {
if (
r.includes('manual') ||
r.includes('requires manual') ||
r.includes('first-time contributor') ||
r.includes('maintainer-only author') ||
r.includes('auto-merge disabled') ||
r.includes('sensitive path') ||
r.includes('no-auto-merge') ||
r.includes('manual-only label') ||
r.includes('without test') ||
r.includes('files changed') ||
r.includes('possible secret') ||
r.includes('agent.py') ||
r.includes('external example only')
r.includes('agent.py')
) return 'pipeline/blocked:manual-review';
if (r.includes('merge gate blocked this head')) return 'pipeline/blocked:merge-gate';
if (r.includes('recent @claude') || r.includes('post-push buffer')) return 'pipeline/blocked:cooldown';
if (r.includes('stale final')) return 'pipeline/blocked:stale-final';
if (r.includes('no final claude')) return 'pipeline/blocked:no-final';
@@ -144,9 +167,13 @@ async function syncPipelineLabels(github, owner, repo, prNumber, core) {
);
const { stage, blockers, all } = computePipelineLabels(ctx.comments, evalResult);
const pullFiles = await mergeGate.listPullFiles(github, owner, repo, prNumber);
const auxiliary = mergeGate.isExternalExampleOnlyChange(pullFiles)
? [mergeGate.EXTERNAL_EXAMPLE_LABEL]
: [];
const auxiliary = [];
if (mergeGate.isExternalExampleOnlyChange(pullFiles)) {
auxiliary.push(mergeGate.EXTERNAL_EXAMPLE_LABEL);
}
if (mergeGate.shouldTagCoreVendorToolMisroute(pullFiles, ctx.labels)) {
auxiliary.push(mergeGate.CORE_VENDOR_TOOL_LABEL);
}
const labelSet = [...all, ...auxiliary];
const current = ctx.labels.filter((l) => l.startsWith(PIPELINE_PREFIX));
@@ -172,12 +199,14 @@ async function syncPipelineLabels(github, owner, repo, prNumber, core) {
core?.info?.(`PR #${prNumber}: stage=${stage} blockers=[${blockers.join(', ')}]`);
// CI-fix requests are posted only by ci-failure-claude.yml, which uses the
// maintainer token. Label sync runs under the App token, whose comments
// claude.yml ignores: a request posted from here never reached Claude, yet
// its pending label blocked every real request for that PR.
const ciNotGreen = (evalResult.reasons || []).some((r) =>
r.toLowerCase().includes('ci not green')
);
if (ciNotGreen || blockers.includes('pipeline/blocked:ci')) {
await ciFix.maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
} else {
if (!ciNotGreen && !blockers.includes('pipeline/blocked:ci')) {
await ciFix.maybeClearCiFixLabel(
github, owner, repo, prNumber, ctx.labels, ctx.headSha, core
);
@@ -221,6 +250,12 @@ function isUpstreamHeadRepo(pr, owner, repo) {
async function syncOpenPullRequests(github, owner, repo, options, core) {
const { maxPrs = 20, dispatchMergeGate = true } = options || {};
const mergeGate = require('./merge-gate.js');
const remaining = await mergeGate.getCoreRateLimitRemaining(github);
if (remaining !== null && remaining < mergeGate.MIN_CORE_RATE_LIMIT_REMAINING) {
core?.warning?.(`Skip pipeline sync: rate limit low (${remaining} core remaining)`);
return 0;
}
await ensurePipelineLabels(github, owner, repo, core);
let prs;
if (maxPrs <= 100) {
+33 -1
View File
@@ -107,5 +107,37 @@ assert(
'claude FINAL not re-posted when already triggered',
!chain.claudeFinalReady([greptile, finalClaude], [], { prCreatedAt: oldPr, requiredWaitMs: 0 }).ready
);
assert(
'stale-FINAL recovery may re-post over an existing trigger',
chain.claudeFinalReady([greptile, finalClaude], [], {
prCreatedAt: oldPr, requiredWaitMs: 0, skipCopilot: true, staleRecovery: true,
}).ready
);
process.exit(failed ? 1 : 0);
// maybeTriggerClaudeFinal actually posts the recovery comment.
(async () => {
const posted = [];
const fakeGithub = {
rest: {
pulls: {
get: async () => ({ data: { created_at: oldPr } }),
listReviews: async () => ({ data: [] }),
},
issues: {
listComments: async () => ({ data: [greptile, finalClaude] }),
createComment: async (args) => { posted.push(args); },
},
},
};
const stale = await chain.maybeTriggerClaudeFinal(
fakeGithub, 'o', 'r', 1, 'BODY', null,
{ skipCopilot: true, optionalWaitMs: 0, requiredWaitMs: 0, staleRecovery: true }
);
assert('stale-FINAL recovery posts the replacement comment', stale.posted && posted.length === 1);
const dup = await chain.maybeTriggerClaudeFinal(
fakeGithub, 'o', 'r', 1, 'BODY', null,
{ skipCopilot: true, optionalWaitMs: 0, requiredWaitMs: 0 }
);
assert('without staleRecovery an existing FINAL is not re-posted', !dup.posted && posted.length === 1);
process.exit(failed ? 1 : 0);
})();
+3 -1
View File
@@ -163,7 +163,9 @@ function claudeFinalAlreadyTriggered(comments) {
}
function claudeFinalReady(comments, reviews = [], options = {}) {
if (claudeFinalAlreadyTriggered(comments)) {
// staleRecovery: the caller already confirmed (shouldSkipStaleFinalRecovery)
// that the existing FINAL is stale on HEAD, so a re-post is intended.
if (!options.staleRecovery && claudeFinalAlreadyTriggered(comments)) {
return { ready: false, reason: 'claude FINAL already triggered', already: true };
}
const prior = priorReviewersReady(comments, reviews, options);
+1 -1
View File
@@ -93,7 +93,7 @@ jobs:
# a CI-shaped environment rather than trusting a developer machine
# where every sibling is already installed.
pip install -e src/praisonai
pip install pytest pytest-asyncio pytest-timeout
pip install pytest pytest-asyncio pytest-timeout aiofiles
- name: Run the unit suite
working-directory: src/praisonai-agents
env:
+6 -3
View File
@@ -16,7 +16,10 @@ on:
pull_request_target:
types: [opened, synchronize]
schedule:
- cron: '30 3,9,15,21 * * *'
# Hourly stale/missing-FINAL sweep. A push is the only other recovery
# trigger, and at push time the "wait for CI" debounce correctly holds back;
# this sweep is what re-posts the FINAL once the push has settled.
- cron: '30 * * * *'
workflow_dispatch:
env:
@@ -163,7 +166,7 @@ jobs:
return;
}
const result = await chain.maybeTriggerClaudeFinal(
github, owner, repo, pr, finalBody, core, opts
github, owner, repo, pr, finalBody, core, { ...opts, staleRecovery: true }
);
if (result.posted) core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`);
return;
@@ -241,7 +244,7 @@ jobs:
const result = await chain.maybeTriggerClaudeFinal(
github, owner, repo, pr.number,
mergeGate.FINAL_CLAUDE_REVIEW_BODY, core,
{ skipCopilot: true, optionalWaitMs: 0 }
{ skipCopilot: true, optionalWaitMs: 0, staleRecovery: hasFinal && isStale }
);
if (result.posted) {
posted++;
+67 -7
View File
@@ -132,6 +132,7 @@ jobs:
outputs:
matrix: ${{ steps.scan.outputs.matrix }}
has_candidates: ${{ steps.scan.outputs.has_candidates }}
fix_requests: ${{ steps.scan.outputs.fix_requests }}
steps:
- uses: actions/checkout@v4
@@ -160,6 +161,12 @@ jobs:
const candidates = [];
const skipped = [];
if (await mergeGate.shouldSkipMergeGateScan(github, core)) {
core.setOutput('matrix', JSON.stringify({ include: [] }));
core.setOutput('has_candidates', 'false');
return;
}
if (inputPr) {
const n = parseInt(inputPr, 10);
if (Number.isNaN(n)) {
@@ -169,7 +176,12 @@ jobs:
if (result.ready) {
candidates.push({ pr_number: n, head_sha: result.headSha });
} else {
skipped.push({ pr: n, reasons: result.reasons });
skipped.push({
pr: n,
reasons: result.reasons,
head_sha: result.headSha,
block_fix_due: !!result.blockFixDue,
});
}
} else {
const prNumbers = await mergeGate.listPrNumbersForMergeGateScan(
@@ -183,6 +195,12 @@ jobs:
}
core.info('Skipped: ' + JSON.stringify(skipped));
// PRs held out because the gate already BLOCKed their HEAD: ask
// Claude (once per HEAD, capped) to fix the findings.
const fixRequests = skipped
.filter((s) => s.block_fix_due && s.head_sha)
.map((s) => ({ pr: s.pr, head_sha: s.head_sha }));
core.setOutput('fix_requests', JSON.stringify(fixRequests));
core.setOutput('matrix', JSON.stringify({ include: candidates }));
core.setOutput('has_candidates', candidates.length > 0 ? 'true' : 'false');
@@ -222,6 +240,45 @@ jobs:
github, owner, repo, { maxPrs: 10, dispatchMergeGate: false }, core
);
- name: Ask Claude to fix merge gate BLOCK findings
# Scheduled scans only: they share one non-cancelling concurrency group,
# so two runs can never race to post the same request.
if: github.event_name == 'schedule' && steps.scan.outputs.fix_requests != '' && steps.scan.outputs.fix_requests != '[]'
uses: actions/github-script@v7
env:
FIX_REQUESTS: ${{ steps.scan.outputs.fix_requests }}
HAS_GH_TOKEN: ${{ secrets.GH_TOKEN != '' }}
with:
# Posted as the maintainer (like FINAL triggers) so claude.yml accepts it;
# comments from the app bot are filtered out there.
github-token: ${{ secrets.GH_TOKEN || github.token }}
script: |
// github.token comments cannot start claude.yml, and posting the
// marker anyway would use up this HEAD's request.
if (process.env.HAS_GH_TOKEN !== 'true') {
core.warning('GH_TOKEN secret missing — not posting merge gate fix requests');
return;
}
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const requests = JSON.parse(process.env.FIX_REQUESTS || '[]');
for (const { pr, head_sha: headSha } of requests) {
// Re-evaluate on fresh state: a commit may have landed since the
// scan, and parallel scans must not double-post.
const fresh = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, pr, core);
if (fresh.headSha !== headSha || !fresh.blockFixDue) {
core.info(`PR #${pr}: fix request no longer due for ${headSha.slice(0, 12)}`);
continue;
}
await github.rest.issues.createComment({
owner, repo, issue_number: pr,
body: mergeGate.buildBlockFixRequestBody(headSha),
});
core.info(`PR #${pr}: asked Claude to fix BLOCK findings on ${headSha.slice(0, 12)}`);
}
claude-assess:
needs: scan-candidates
if: needs.scan-candidates.outputs.has_candidates == 'true'
@@ -269,10 +326,11 @@ jobs:
- name: Pick Claude OAuth token
id: oauth
uses: MervinPraison/github-automation-template/.github/actions/pick-oauth-token@v1.1.9
uses: ./.github/actions/pick-oauth-token
with:
token_primary: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
token_secondary: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_B }}
token_tertiary: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_C }}
active_slot: ${{ vars.CLAUDE_OAUTH_ACTIVE != '' && vars.CLAUDE_OAUTH_ACTIVE || 'primary' }}
- name: Fetch PR branch
@@ -289,11 +347,12 @@ jobs:
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
CLAUDE_CODE_SUBAGENT_MODEL: inherit
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
ANTHROPIC_DEFAULT_OPUS_MODEL: claude-opus-5-5
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-5-5
with:
claude_code_oauth_token: ${{ steps.oauth.outputs.token }}
github_token: ${{ steps.app-token.outputs.token }}
model: claude-opus-4-8
model: claude-opus-5-5
trigger_phrase: '@claude-merge-gate-internal'
direct_prompt: |
You are the MERGE GATE reviewer for PraisonAI PR #${{ matrix.pr_number }}.
@@ -312,12 +371,13 @@ 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 the relevant SDK (Python: praisonaiagents/praisonai; TypeScript: src/praisonai-ts/) — 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. FINAL architecture review must include the Phase 1 table with an explicit **SDK value** row.
4. Repo routing: BLOCK if agent-callable tools (→ PraisonAI-Tools), lifecycle plugins (→ PraisonAI-Plugins), or optional sandbox backends (→ PraisonAI-Plugins via `praisonai.sandbox`) were added to praisonaiagents/ instead of the external repo.
4. Repo routing: BLOCK if 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. BLOCK if the PR adds a new `src/praisonai-agents/praisonaiagents/tools/*_tools.py` vendor module (unless `maintainer-accept-core-tools`); existing Tavily/Exa-style files in core are not precedent.
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.
8. Confirm no CHANGES_REQUESTED reviews and CI green on HEAD ${{ matrix.head_sha }}.
9. If ready to merge: MERGE_GATE_VERDICT: APPROVE. Otherwise: MERGE_GATE_VERDICT: BLOCK with reasons.
8. Confirm no CHANGES_REQUESTED reviews on HEAD ${{ matrix.head_sha }}. CI: the scan already verified the check runs on this HEAD before selecting it (mcp__github__get_pull_request_status returns only legacy commit statuses such as CodeRabbit, so it is not CI evidence). Jobs still running in the Optimized Test Suite (`smoke`, `main (3.11)`) are not blockers once test-core is green; BLOCK on CI only for a check you can see has FAILED.
9. Author policy: MERGE_GATE_VERDICT: BLOCK if the PR author is a first-time contributor (`author_association: FIRST_TIME_CONTRIBUTOR`) or any human not on the auto-merge allowlist (app/praisonai-triage-agent, MervinPraison, github-actions[bot] CI-only). Fork PRs and external contributors always require maintainer manual merge — never APPROVE for gate auto-merge.
10. If ready to merge: MERGE_GATE_VERDICT: APPROVE. Otherwise: MERGE_GATE_VERDICT: BLOCK with reasons.
Do not approve or merge via GitHub UI/API — only post the verdict comment.
disallowed_tools: |
+48 -7
View File
@@ -154,6 +154,14 @@ jobs:
github.actor == 'github-actions[bot]' ||
(github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'claude' && github.actor == 'praisonai-triage-agent[bot]')
) &&
(
github.event_name == 'issues' ||
github.actor == 'github-actions[bot]' ||
(github.event_name == 'pull_request_review' &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association)) ||
((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
) &&
github.actor != 'dependabot[bot]' &&
github.actor != 'cursor[bot]' &&
github.actor != 'renovate[bot]'
@@ -168,6 +176,38 @@ jobs:
actions: read
id-token: write
steps:
# Must stay the first step: nothing below may run (no App token, no OAuth
# token, no agent) for a requester without write access. The job-level
# `if:` already filters on author_association; this re-checks the real
# permission level, which also excludes read-only collaborators.
- name: Authorize requester (write access required)
uses: actions/github-script@v7
with:
script: |
const trustedAutomation = new Set(['github-actions[bot]', 'praisonai-triage-agent[bot]']);
const p = context.payload;
const login = p.comment?.user?.login || p.review?.user?.login || p.sender?.login || context.actor;
if (trustedAutomation.has(login) && login === context.actor) {
core.info(`Trusted repository automation: ${login}`);
return;
}
let permission = 'none';
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: login,
});
permission = data.permission;
} catch (err) {
core.info(`Permission lookup for ${login} failed: ${err.status || err.message}`);
}
if (!['admin', 'write'].includes(permission)) {
core.setFailed(`Claude can only be invoked by users with write access; ${login} has '${permission}'.`);
return;
}
core.info(`${login} has ${permission} access`);
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
@@ -251,10 +291,11 @@ jobs:
- name: Pick Claude OAuth token
id: oauth
uses: MervinPraison/github-automation-template/.github/actions/pick-oauth-token@v1.1.9
uses: ./.github/actions/pick-oauth-token
with:
token_primary: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
token_secondary: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_B }}
token_tertiary: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_C }}
active_slot: ${{ vars.CLAUDE_OAUTH_ACTIVE != '' && vars.CLAUDE_OAUTH_ACTIVE || 'primary' }}
- name: Fetch PR branch and Setup Remote
@@ -278,16 +319,16 @@ jobs:
git remote set-url origin "file://$(pwd)"
fi
- uses: anthropics/claude-code-action@beta
- uses: ./.github/actions/claude-code-action
env:
GH_TOKEN: ${{ steps.check_fork.outputs.can_push_fork == 'true' && secrets.GH_TOKEN || steps.app-token.outputs.token }}
ANTHROPIC_DEFAULT_OPUS_MODEL: claude-opus-5-5
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-5-5
with:
allowed_bots: 'praisonai-triage-agent[bot]'
claude_code_oauth_token: ${{ steps.oauth.outputs.token }}
github_token: ${{ steps.app-token.outputs.token }}
model: claude-opus-4-8
model: claude-opus-5-5
trigger_phrase: "@claude"
label_trigger: "claude"
direct_prompt: |
${{ steps.check_fork.outputs.is_fork == 'true' && steps.check_fork.outputs.can_push_fork != 'true' && 'CRITICAL: THIS IS A PULL REQUEST FROM A FORK WITHOUT MAINTAINER EDITS. YOU DO NOT HAVE PUSH PERMISSIONS. ONLY READ FILES, VALIDATE CODE, AND PROVIDE COMMENTS/FEEDBACK ON THIS PR. DO NOT ATTEMPT TO PUSH OR CREATE PULL REQUESTS.\n\n' || (steps.check_fork.outputs.can_push_fork == 'true' && format('NOTE: Fork PR with maintainer edits allowed. Push directly to fork {0} branch {1}.\n\n', steps.check_fork.outputs.fork_repo, steps.check_fork.outputs.pr_branch) || (steps.check_fork.outputs.is_pr == 'true' && steps.check_fork.outputs.is_fork != 'true' && 'NOTE: The branch is under MervinPraison/PraisonAI (not a fork). You are able to make modifications and push directly to this branch.\n\n' || (steps.check_fork.outputs.is_pr != 'true' && 'NOTE: This is an issue (not a PR). Create a new fix branch per STEP 3 below.\n\n' || ''))) }}
@@ -301,7 +342,7 @@ jobs:
STEP 1 — READ GUIDELINES:
Read AGENTS.md to understand the architecture rules.
Read src/praisonai-agents/AGENTS.md (especially §6.1 vendor tools → PraisonAI-Tools) and root AGENTS.md routing table.
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.
@@ -310,7 +351,7 @@ jobs:
- CORE vs WRAPPER vs TOOLS vs DOCS vs PLUGINS ROUTING:
1. Core SDK (praisonaiagents/): Only core protocols, base classes, decorators. No heavy implementations.
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
3. Tools → MervinPraison/PraisonAI-Tools: agent-callable integrations at runtime (Composio, Slack, API/DB connectors). Do NOT implement in praisonaiagents/ — use STEP 3-ALT.
3. Tools → MervinPraison/PraisonAI-Tools: agent-callable integrations at runtime (Composio, Slack, API/DB connectors, new vendor APIs). Do NOT add new `praisonaiagents/tools/*_tools.py` modules in this repo — legacy bundled search tools (Tavily/Exa/You.com) are not precedent. Use STEP 3-ALT.
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.
+19 -7
View File
@@ -117,13 +117,25 @@ jobs:
const labels = issue.labels.map((l) => l.name);
if (status !== 'DIRTY' && labels.includes(LABEL)) {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: LABEL,
});
core.info(`Removed ${LABEL} from PR #${prNumber}`);
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: LABEL,
});
core.info(`Removed ${LABEL} from PR #${prNumber}`);
} catch (err) {
// Fork PRs may return 403/404 when the token cannot mutate labels on the head repo's issue.
// For upstream PRs a 403/404 is a genuine failure that must surface, otherwise the
// merge-blocking label stays in place while the check reports success.
const isFork = headRepo && headRepo !== baseRepo;
if (isFork && (err.status === 403 || err.status === 404)) {
core.info(`Skip remove ${LABEL} on fork PR #${prNumber}: ${err.status}`);
} else {
throw err;
}
}
}
if (status !== 'DIRTY') return { skipped: true, reason: 'not dirty' };
+1 -1
View File
@@ -38,5 +38,5 @@ jobs:
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
await ps.syncOpenPullRequests(
github, context.repo.owner, context.repo.repo,
{ maxPrs: 30, dispatchMergeGate: true }, core
{ maxPrs: 10, dispatchMergeGate: true }, core
);
+80
View File
@@ -0,0 +1,80 @@
name: Rotate Claude OAuth
# Shift: tertiary ← old secondary, secondary ← old primary, primary ← NEXT.
# Before dispatch: set CLAUDE_CODE_OAUTH_TOKEN_NEXT on this repo (see claude-oauth-token-refresh skill).
on:
workflow_dispatch:
permissions:
contents: read
jobs:
rotate-and-sync:
runs-on: ubuntu-latest
steps:
- name: Rotate hub OAuth chain and sync fleet
env:
GH_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
OLD_PRIMARY: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OLD_SECONDARY: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_B }}
NEW_PRIMARY: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_NEXT }}
CLAUDE_APP_ID: ${{ secrets.CLAUDE_APP_ID }}
CLAUDE_APP_PRIVATE_KEY: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
CLAUDE_CODE_OAUTH_TOKEN_B: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_B }}
CLAUDE_CODE_OAUTH_TOKEN_C: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_C }}
GH_TOKEN_VALUE: ${{ secrets.GH_TOKEN }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "SECRETS_ADMIN_PAT is not configured"
exit 1
fi
if [ -z "${NEW_PRIMARY:-}" ]; then
echo "Set CLAUDE_CODE_OAUTH_TOKEN_NEXT on MervinPraison/PraisonAI first (refresh-claude-oauth.sh --rotate-primary)"
exit 1
fi
HUB=MervinPraison/PraisonAI
echo "Rotating on ${HUB}: C←B, B←P, P←NEXT"
if [ -n "${OLD_SECONDARY:-}" ]; then
echo -n "$OLD_SECONDARY" | gh secret set CLAUDE_CODE_OAUTH_TOKEN_C --repo "$HUB"
fi
if [ -n "${OLD_PRIMARY:-}" ]; then
echo -n "$OLD_PRIMARY" | gh secret set CLAUDE_CODE_OAUTH_TOKEN_B --repo "$HUB"
fi
echo -n "$NEW_PRIMARY" | gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo "$HUB"
gh secret delete CLAUDE_CODE_OAUTH_TOKEN_NEXT --repo "$HUB" 2>/dev/null || true
gh variable set CLAUDE_OAUTH_ACTIVE --body primary --repo "$HUB"
sync_repo() {
local target=$1
echo "Syncing → ${target}"
echo -n "$CLAUDE_APP_ID" | gh secret set CLAUDE_APP_ID --repo "$target"
echo -n "$CLAUDE_APP_PRIVATE_KEY" | gh secret set CLAUDE_APP_PRIVATE_KEY --repo "$target"
# Re-read after rotate — use env from this step (already updated on hub in gh; refresh from NEW chain)
echo -n "$NEW_PRIMARY" | gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo "$target"
if [ -n "${OLD_PRIMARY:-}" ]; then
echo -n "$OLD_PRIMARY" | gh secret set CLAUDE_CODE_OAUTH_TOKEN_B --repo "$target"
fi
if [ -n "${OLD_SECONDARY:-}" ]; then
echo -n "$OLD_SECONDARY" | gh secret set CLAUDE_CODE_OAUTH_TOKEN_C --repo "$target"
fi
echo -n "$GH_TOKEN_VALUE" | gh secret set GH_TOKEN --repo "$target"
gh variable set CLAUDE_OAUTH_ACTIVE --body primary --repo "$target"
}
FLEET=(
MervinPraison/github-automation-template
MervinPraison/PraisonAIUI
MervinPraison/PraisonAIDocs
MervinPraison/PraisonAI-Tools
MervinPraison/PraisonAI-Plugins
MervinPraison/praisonai-js
)
for repo in "${FLEET[@]}"; do
sync_repo "$repo"
done
echo "Rotate + fleet sync complete. CLAUDE_OAUTH_ACTIVE=primary on all targets."
+5 -1
View File
@@ -24,6 +24,7 @@ jobs:
CLAUDE_APP_PRIVATE_KEY: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
CLAUDE_CODE_OAUTH_TOKEN_B: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_B }}
CLAUDE_CODE_OAUTH_TOKEN_C: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_C }}
GH_TOKEN_VALUE: ${{ secrets.GH_TOKEN }}
DOCS_GITHUB_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
run: |
@@ -38,8 +39,11 @@ jobs:
if [ -n "${CLAUDE_CODE_OAUTH_TOKEN_B:-}" ]; then
echo -n "$CLAUDE_CODE_OAUTH_TOKEN_B" | gh secret set CLAUDE_CODE_OAUTH_TOKEN_B --repo "$TARGET"
fi
if [ -n "${CLAUDE_CODE_OAUTH_TOKEN_C:-}" ]; then
echo -n "$CLAUDE_CODE_OAUTH_TOKEN_C" | gh secret set CLAUDE_CODE_OAUTH_TOKEN_C --repo "$TARGET"
fi
echo -n "$GH_TOKEN_VALUE" | gh secret set GH_TOKEN --repo "$TARGET"
if [ -n "${DOCS_GITHUB_TOKEN:-}" ]; then
echo -n "$DOCS_GITHUB_TOKEN" | gh secret set DOCS_GITHUB_TOKEN --repo "$TARGET"
fi
echo "Synced CLAUDE_APP_ID, CLAUDE_APP_PRIVATE_KEY, CLAUDE_CODE_OAUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN_B (if set), GH_TOKEN → $TARGET"
echo "Synced CLAUDE_APP_ID, CLAUDE_APP_PRIVATE_KEY, CLAUDE_CODE_OAUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN_B/C (if set), GH_TOKEN → $TARGET"
+3 -1
View File
@@ -86,7 +86,9 @@ jobs:
name: test-core (${{ matrix.shard }})
needs: test-core-collect
runs-on: ubuntu-latest
timeout-minutes: 30
# agents-core runs the full praisonai-agents unit tree and often nears 30m;
# GitHub cancels at the limit and forces a full rerun (+ stale-FINAL churn).
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
+14 -5
View File
@@ -42,7 +42,7 @@ jobs:
# ============================================
smoke:
runs-on: ubuntu-latest
timeout-minutes: 12
timeout-minutes: 16
steps:
- uses: actions/checkout@v4
with:
@@ -147,7 +147,8 @@ jobs:
done
echo "C7 hot-path import gate ok"
bash "$GITHUB_WORKSPACE/scripts/check_c7_imports.sh"
# Standalone actions run (no wrapper); default run/chat require praisonai
# Standalone actions run (no wrapper); default run renders in-process
# since #5644; chat still requires praisonai
set +e
RUN_OUT=$(timeout 90 /tmp/praisonai-code-standalone/bin/praisonai-code run --output actions "Say hello in one word" 2>&1)
RUN_CODE=$?
@@ -162,11 +163,19 @@ jobs:
DEFAULT_RUN=$(timeout 15 /tmp/praisonai-code-standalone/bin/praisonai-code run "hi" 2>&1)
set -e
echo "$DEFAULT_RUN" | tail -10
if ! echo "$DEFAULT_RUN" | grep -qi 'install praisonai'; then
echo "FAIL: default run should prompt for praisonai wrapper install"
if echo "$DEFAULT_RUN" | grep -q "requires the praisonai wrapper"; then
echo "FAIL: standalone default run still gates on the wrapper (issue #5644)"
exit 1
fi
echo "default run wrapper gate ok"
if echo "$DEFAULT_RUN" | grep -q "No module named 'praisonai'"; then
echo "FAIL: standalone default run hit a wrapper import error"
exit 1
fi
if ! echo "$DEFAULT_RUN" | grep -qi 'praisonai setup'; then
echo "FAIL: standalone default run should reach the in-process credential gate"
exit 1
fi
echo "standalone default run in-process ok"
set +e
CHAT_RUN=$(timeout 10 /tmp/praisonai-code-standalone/bin/praisonai-code chat 2>&1)
set -e
+1 -1
View File
@@ -22,7 +22,7 @@ You are working on the PraisonAI project.
| Python core SDK | `src/praisonai-agents/praisonaiagents/` | `src/praisonai-agents/AGENTS.md` | `pytest` in `src/praisonai-agents/tests/` |
| Python wrapper / CLI | `src/praisonai/`, `src/praisonai-code/`, etc. | `src/praisonai-agents/AGENTS.md` | `pytest` under `src/praisonai/tests/` |
| **TypeScript / JavaScript SDK** | **`src/praisonai-ts/`** | **`src/praisonai-ts/AGENTS.md`** | `cd src/praisonai-ts && npm run build && npm test` |
| Agent-callable tools | [PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools) | — | repo tests |
| Agent-callable tools / new vendor `*_tools.py` | [PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools) — **not** new modules under `praisonaiagents/tools/` (legacy bundled tools are not precedent) | — | repo tests |
| Lifecycle plugins | [PraisonAI-Plugins](https://github.com/MervinPraison/PraisonAI-Plugins) | — | repo tests |
| Documentation | [PraisonAIDocs](https://github.com/MervinPraison/PraisonAIDocs) | — | `nav-check` |
| npm mirror (read-only for fixes) | [praisonai-js](https://github.com/MervinPraison/praisonai-js) | mirror of `src/praisonai-ts/AGENTS.md` | CI on praisonai-js |
+2
View File
@@ -465,6 +465,8 @@ Powered by 100+ LLMs (OpenAI, Anthropic, Gemini & local models).
## 📘 Using Python Code
> Unsure whether a result is a bug or model randomness? Check the [SDK Behavior Matrix](https://github.com/MervinPraison/PraisonAI/blob/main/src/praisonai-agents/docs/sdk-behavior-matrix.md) for `stream`, memory prefetch, structured output, teams and AG-UI.
### 1. Single Agent
```python
+7 -4
View File
@@ -231,8 +231,8 @@ Methods:
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">set_status</a>(new_status: str) -> None</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">store_in_memory</a>(content: str, agent_name: str = None, task_id: str = None)</code>
* <code title="class Task">Task.<a href="./src/praisonai-agents/praisonaiagents/task/task.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">loop</a>(step: Any = None, steps: Optional[List[Any]] = None, over: Optional[str] = None, from_csv: Optional[str] = None, from_file: Optional[str] = None, var_name: str = 'item', parallel: bool = False, max_workers: Optional[int] = None, output_variable: Optional[str] = None) -> Loop</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">parallel</a>(steps: List, max_workers: Optional[int] = None, on_failure: str = 'partial_ok') -> Parallel</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">loop</a>(step: Any = None, steps: Optional[List[Any]] = None, over: Optional[str] = None, from_csv: Optional[str] = None, from_file: Optional[str] = None, var_name: str = 'item', parallel: bool = False, max_workers: Optional[int] = None, output_variable: Optional[str] = None, context: str = 'full') -> Loop</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">parallel</a>(steps: List, max_workers: Optional[int] = None, on_failure: str = 'partial_ok', timeout: Optional[float] = None, context: str = 'full') -> Parallel</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">repeat</a>(step: Any, until: Optional[Callable[[WorkflowContext], bool]] = None, max_iterations: int = 10) -> Repeat</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">route</a>(routes: Dict[str, List], default: Optional[List] = None) -> Route</code>
@@ -947,8 +947,9 @@ export { DeepResearchAgent, Provider, createDeepResearchAgent } from "./agent/re
export { RetryBackoffConfig, interruptibleSleep, jitteredBackoff } from "./agent/retry-utils";
export type { JitteredBackoffOptions, RetryBackoffConfigOptions } from "./agent/retry-utils";
export { RouterAgent, createRouter, routeConditions } from "./agent/router";
export { AGENT_RUN_STATUSES, AgentRunOutcome, PROVIDER_BLOCK_REASONS, RunOutcome, TERMINAL_REASON_PRECEDENCE, TERMINATION_TO_RUN_STATUS, TerminationReason, classifyFinishReason, terminationToRunStatus, termination_to_run_status, validateDecisionString, validate_decision_string } from "./agent/run-outcome";
export type { AgentRunOutcomeFailureOptions, AgentRunOutcomeInit, AgentRunOutcomeOptions, AgentRunStatus, RunOutcomeInit, TerminalReason } from "./agent/run-outcome";
export { // Canonical terminal-outcome contract (praisonaiagents/run_outcome.py)
RunTerminal, AGENT_RUN_STATUSES, AgentRunOutcome, PROVIDER_BLOCK_REASONS, RunOutcome, TERMINAL_REASON_PRECEDENCE, TERMINATION_TO_RUN_STATUS, TerminationReason, classifyFinishReason, collapse, isSticky, is_sticky, mergeRunTerminal, merge_run_terminal, terminationToRunStatus, termination_to_run_status, validateDecisionString, validate_decision_string } from "./agent/run-outcome";
export type { AgentRunOutcomeFailureOptions, AgentRunOutcomeInit, AgentRunOutcomeOptions, AgentRunStatus, RunOutcomeInit, RunTerminalInit, TerminalKind, TerminalReason, TerminalSource } from "./agent/run-outcome";
export { reviewTaskOutput } from "./agent/task-review";
export { // Agent loop
createAgentLoop, // DevTools
@@ -1001,6 +1002,8 @@ export type { DbAdapter, DbConfig, DbMessage, DbRun, DbTrace } from "./db";
export { MemoryPostgresAdapter, NeonPostgresAdapter, PostgresSessionStorage, createMemoryPostgres, createNeonPostgres, createPostgresSessionStorage } from "./db/postgres";
export { MemoryRedisAdapter, UpstashRedisAdapter, createMemoryRedis, createUpstashRedis } from "./db/redis";
export { SQLiteAdapter, createSQLiteAdapter } from "./db/sqlite";
export { KNOWN_DECISION_MODELS, choiceQuestion, defaultTicketTriageQuestions, getChoice, getNoul, getScore, isDecisionModel, noulQuestion, resolveSystemOneBaseUrl, scoreQuestion, systemOne, triagedStart } from "./decisions";
export type { DecisionAgentLike, DecisionRoutePlan, QuestionSpec, RouteTarget, SystemOneAnswer, SystemOneOptions, SystemOneResult } from "./decisions";
export { // Functions
// NB: the unaliased registerDisplayCallback name belongs to the per-type
// callback registry (callbacks module, // Types
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.11" \
"praisonai>=4.7.14" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.11" \
"praisonai>=4.7.14" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.11" \
"praisonai>=4.7.14" \
"praisonai[ui]" \
"praisonai[crewai]"
+1 -1
View File
@@ -9,7 +9,7 @@ from praisonai.capabilities import realtime_connect
# Create a realtime session
print("=== Realtime Session ===")
session = realtime_connect(
model="gpt-4o-realtime-preview",
model="gpt-realtime",
modalities=["text", "audio"],
voice="alloy"
)
@@ -0,0 +1,43 @@
"""Withhold account details at the final bot delivery boundary.
Install praisonaiagents and praisonai-bot, set OPENAI_API_KEY, then run this file.
This gate covers final bot replies; disable progressive drafts when using an
output policy. Direct transport sends do not pass through this hook.
"""
import asyncio
from praisonai_bot.bots import LocalBot
from praisonaiagents import Agent, HooksConfig
from praisonaiagents.hooks import HookRegistry
from praisonaiagents.plugins.manager import PluginManager
from praisonaiagents.plugins.plugin import Plugin, PluginDecision, PluginInfo
class AccountPolicy(Plugin):
@property
def info(self):
return PluginInfo(name="account_policy")
def after_message(self, message):
if "account number" in message["content"].lower():
return PluginDecision.block("Account details must not be sent to this channel")
return message
async def main():
manager = PluginManager()
manager.register(AccountPolicy())
# Conversation access must be explicitly granted to third-party plugins.
manager.set_plugin_options({"account_policy": {"allow_conversation": True}})
registry = HookRegistry()
manager.wire_into_hook_registry(registry)
with Agent(
instructions="Answer briefly.", output="silent", reflection=False,
hooks=HooksConfig(registry=registry),
) as agent:
await LocalBot(agent=agent).start()
if __name__ == "__main__":
asyncio.run(main())
+21
View File
@@ -606,6 +606,27 @@ class MyTool(BaseTool):
> [!IMPORTANT]
> **Agents only recognize parameters.** When creating tools, always expose all options as function parameters—agents cannot discover env vars or config files.
> [!IMPORTANT]
> **Vendor / third-party API tools:** New integrations (external APIs, datasets, SaaS wrappers) belong in **[PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools)** as optional pip plugins — **not** as new `praisonaiagents/tools/*_tools.py` files in this repo. Core `praisonaiagents/tools/` holds SDK primitives (decorator, registry, schedule, shell, trust, subagent) and a small set of **legacy** bundled tools kept for backward compatibility. Do not treat legacy search tools (e.g. Tavily/Exa) as precedent for adding new vendor modules here. See §6.1.1 for the full routing test.
#### 6.1.1 Tool routing — third-party connectors → PraisonAI-Tools (MUST)
New agent-callable third-party connectors (external HTTP/API, SaaS, DB) go to **[PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools)**, NOT `praisonaiagents/tools/`. Core keeps only tool *protocols, base classes, decorator, registry, resolver, trust/fencing* — not connectors.
**Decision test** — keep in core only if **both** left-column conditions hold; route out if **any** right-column condition holds:
| Keep in core `praisonaiagents/tools/` | Route to PraisonAI-Tools |
|---|---|
| Local-state only (filesystem, shell, Python, AST, todo, schedule) **or** framework plumbing (delegation, subagent, session, artifact, memory, clarify) | Calls a third-party/remote API or SaaS |
| …using only stdlib or already-required core deps | Needs service-specific auth or SDK; is a vendor connector (search, data, CRM, DB, messaging) |
A macro/FX data client (e.g. FXMacroData) → **PraisonAI-Tools**, even on stdlib `urllib`, because it depends on an external service + credentials.
> [!WARNING]
> Legacy in-repo connectors (`tavily_tools.py`, `exa_tools.py`, `github_tools.py`, etc.) are backward-compat precedent, **not** a licence to add more. "Same shape as Tavily/Exa" is a *consistency* check, not a *routing* one — do not approve a new connector into core on precedent alone.
Connectors in PraisonAI-Tools are used via `Agent(tools=[...])`. Placement/registration alone does **not** fence results — fencing requires the tool name in `EXTERNAL_TOOL_NAMES` or `ToolTrustLevel.EXTERNAL`. Register with `add_external_tool(tool_name)` to fence untrusted results.
### 6.2 Hooks & Middleware
```python
+37 -37
View File
@@ -17,77 +17,77 @@ This report compares **Python SDK feature categories** against **Python document
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 511 |
| ✅ Agent | 22 | 59 | 19076 |
| ✅ AGUI | 1 | 2 | 577 |
| ✅ Agent | 22 | 62 | 19751 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 701 |
| ✅ Approval | 1 | 8 | 3969 |
| ✅ Approval | 1 | 8 | 4164 |
| ✅ Audio | 2 | 12 | 1000 |
| ✅ Auto Generation | 5 | 12 | 3941 |
| ✅ Autonomy | 3 | 7 | 2805 |
| ✅ Bots | 7 | 38 | 15809 |
| ✅ Autonomy | 3 | 7 | 2808 |
| ✅ Bots | 7 | 39 | 16361 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 124 | 39191 |
| ✅ CLI | 5 | 124 | 39462 |
| ✅ Chunking | 2 | 2 | 435 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 2 | 14 | 5022 |
| ✅ Conditions | 1 | 3 | 1386 |
| ✅ Configuration | 3 | 8 | 3341 |
| ✅ Context Management | 16 | 36 | 14384 |
| ✅ Configuration | 3 | 8 | 3350 |
| ✅ Context Management | 16 | 37 | 14724 |
| ✅ Deep Research | 8 | 2 | 591 |
| ✅ Display | 6 | 3 | 844 |
| ✅ Embeddings | 6 | 23 | 2051 |
| ✅ Evaluation | 1 | 9 | 3637 |
| ✅ Embeddings | 6 | 23 | 2074 |
| ✅ Evaluation | 1 | 9 | 3709 |
| ✅ Events | 1 | 2 | 782 |
| ✅ Execution | 3 | 4 | 1694 |
| ✅ Failover | 2 | 1 | 537 |
| ✅ Execution | 3 | 4 | 1697 |
| ✅ Failover | 2 | 1 | 576 |
| ✅ Files | 2 | 7 | 2376 |
| ✅ Flow | 1 | 3 | 811 |
| ✅ Gateway | 7 | 97 | 37597 |
| ✅ Gateway | 7 | 103 | 39813 |
| ✅ Guardrails | 5 | 4 | 2642 |
| ✅ Handoffs | 11 | 6 | 2748 |
| ✅ Hooks | 2 | 9 | 5230 |
| ✅ Image | 1 | 11 | 1295 |
| ✅ Knowledge | 4 | 20 | 6260 |
| ✅ LLM | 3 | 15 | 5958 |
| ✅ Loops | 4 | 5 | 1478 |
| ✅ MCP | 1 | 64 | 15116 |
| ✅ Memory | 6 | 21 | 8198 |
| ✅ Handoffs | 11 | 6 | 2812 |
| ✅ Hooks | 2 | 9 | 5315 |
| ✅ Image | 1 | 11 | 1346 |
| ✅ Knowledge | 4 | 20 | 6264 |
| ✅ LLM | 3 | 15 | 6142 |
| ✅ Loops | 4 | 5 | 1511 |
| ✅ MCP | 1 | 64 | 15268 |
| ✅ Memory | 6 | 21 | 8484 |
| ✅ OCR | 2 | 1 | 237 |
| ✅ Observability | 2 | 23 | 2859 |
| ✅ Optimizer | 1 | 2 | 792 |
| ✅ Output | 3 | 5 | 1289 |
| ✅ Parallel Execution | 3 | 2 | 528 |
| ✅ Optimizer | 1 | 2 | 794 |
| ✅ Output | 3 | 6 | 1573 |
| ✅ Parallel Execution | 3 | 2 | 546 |
| ✅ Planning | 6 | 7 | 1827 |
| ✅ Plugins | 8 | 5 | 4108 |
| ✅ Prompts | 2 | 9 | 2043 |
| ✅ Providers | 1 | 58 | 8751 |
| ✅ Plugins | 8 | 5 | 4114 |
| ✅ Prompts | 2 | 9 | 2046 |
| ✅ Providers | 1 | 57 | 8661 |
| ✅ Query | 1 | 2 | 736 |
| ✅ RAG | 5 | 15 | 3152 |
| ✅ Realtime | 2 | 5 | 668 |
| ✅ RAG | 5 | 15 | 3284 |
| ✅ Realtime | 2 | 5 | 674 |
| ✅ Reflection | 3 | 3 | 732 |
| ✅ Retrieval | 2 | 5 | 1046 |
| ✅ Retrieval | 2 | 5 | 1068 |
| ✅ Routing | 1 | 2 | 430 |
| ✅ Sandbox | 5 | 7 | 3720 |
| ✅ Security | 1 | 3 | 3642 |
| ✅ Sessions | 4 | 21 | 8388 |
| ✅ Security | 1 | 3 | 3655 |
| ✅ Sessions | 4 | 21 | 8752 |
| ✅ Skills | 6 | 16 | 6045 |
| ✅ Tasks | 2 | 8 | 3266 |
| ✅ Telemetry | 1 | 2 | 660 |
| ✅ Templates | 1 | 9 | 1859 |
| ✅ Tools | 12 | 147 | 45378 |
| ✅ Tools | 12 | 147 | 45489 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Vector Store | 1 | 12 | 1219 |
| ✅ Video | 2 | 10 | 1303 |
| ✅ Video | 2 | 10 | 1383 |
| ✅ Vision | 2 | 1 | 329 |
| ✅ Web | 3 | 10 | 2822 |
| ✅ Workflows | 5 | 21 | 8880 |
| ✅ Web | 3 | 10 | 2895 |
| ✅ Workflows | 5 | 21 | 9251 |
## Documentation Without Features
These docs exist but don't match any implemented feature category:
- ℹ️ Database (44 docs, 8206 lines)
- ℹ️ Database (44 docs, 8451 lines)
- ℹ️ Documents (1 docs, 810 lines)
- ℹ️ Teams (1 docs, 214 lines)
@@ -6,8 +6,13 @@ Compares agent instantiation times across popular AI agent frameworks.
Usage:
python benchmarks/simple_benchmark.py
python benchmarks/simple_benchmark.py --skip-crewai --json
python benchmarks/simple_benchmark.py --json --fail-over-threshold
"""
import json
import os
import sys
import time
from typing import Literal
import argparse
@@ -16,6 +21,14 @@ import argparse
ITERATIONS = 100
WARMUP = 10
THRESHOLDS_PATH = os.path.join(os.path.dirname(__file__), 'thresholds.json')
def load_thresholds(path: str = THRESHOLDS_PATH) -> dict:
"""Load benchmark thresholds from thresholds.json."""
with open(path) as f:
return json.load(f)
def sample_tool(city: Literal['nyc', 'sf']):
"""Sample tool for benchmark testing."""
@@ -36,35 +49,44 @@ def measure_instantiation(create_fn, iterations=ITERATIONS, warmup=WARMUP):
return sum(times) / len(times)
def run_benchmark():
def run_benchmark(skip_crewai: bool = False, quiet: bool = False,
iterations: int = ITERATIONS, warmup: int = WARMUP):
"""Run the benchmark across all available frameworks (without tools)."""
results = {}
print('=' * 60)
print('PraisonAI Agents - Performance Benchmark')
print('=' * 60)
print(f'\nIterations: {ITERATIONS}')
print('Metric: Agent instantiation time (microseconds)\n')
def log(msg: str = ''):
if not quiet:
print(msg)
def measure(create_fn):
return measure_instantiation(create_fn, iterations=iterations, warmup=warmup)
log('=' * 60)
log('PraisonAI Agents - Performance Benchmark')
log('=' * 60)
log(f'\nIterations: {iterations}')
log(f'Warmup: {warmup}')
log('Metric: Agent instantiation time (microseconds)\n')
# PraisonAI (without tools)
print("Testing PraisonAI...")
log("Testing PraisonAI...")
from praisonaiagents import Agent as PraisonAgent
results['PraisonAI'] = measure_instantiation(
results['PraisonAI'] = measure(
lambda: PraisonAgent(name='Test', model='gpt-4o-mini', output="silent")
)
results['PraisonAI (LiteLLM)'] = measure_instantiation(
results['PraisonAI (LiteLLM)'] = measure(
lambda: PraisonAgent(name='Test', model='openai/gpt-4o-mini', output="silent")
)
# Other frameworks for comparison
print("Testing other frameworks...")
log("Testing other frameworks...")
try:
from agno.agent import Agent as AgnoAgent
from agno.models.openai import OpenAIChat
results['Agno'] = measure_instantiation(
results['Agno'] = measure(
lambda: AgnoAgent(model=OpenAIChat(id='gpt-4o-mini'))
)
except ImportError:
@@ -72,7 +94,7 @@ def run_benchmark():
try:
from pydantic_ai import Agent as PydanticAgent
results['PydanticAI'] = measure_instantiation(
results['PydanticAI'] = measure(
lambda: PydanticAgent('openai:gpt-4o-mini')
)
except ImportError:
@@ -80,7 +102,7 @@ def run_benchmark():
try:
from agents import Agent as OpenAIAgent
results['OpenAI Agents SDK'] = measure_instantiation(
results['OpenAI Agents SDK'] = measure(
lambda: OpenAIAgent(name='Test', model='gpt-4o-mini')
)
except ImportError:
@@ -96,47 +118,50 @@ def run_benchmark():
"""Get weather info."""
return 'sunny' if city == 'sf' else 'cloudy'
results['LangGraph'] = measure_instantiation(
results['LangGraph'] = measure(
lambda: create_react_agent(model=ChatOpenAI(model='gpt-4o-mini'), tools=[get_weather_lg])
)
except ImportError:
pass
try:
from crewai.agent import Agent as CrewAgent
from crewai.tools import tool as crewai_tool
@crewai_tool("Weather Tool")
def get_weather_crew(city: Literal['nyc', 'sf']):
"""Get weather info."""
return 'sunny' if city == 'sf' else 'cloudy'
results['CrewAI'] = measure_instantiation(
lambda: CrewAgent(
role='Weather Agent',
goal='Provide weather info',
backstory='A weather expert',
tools=[get_weather_crew],
verbose=False
if skip_crewai:
log("Skipping CrewAI (--skip-crewai): optional framework import can hang on some platforms.")
else:
try:
from crewai.agent import Agent as CrewAgent
from crewai.tools import tool as crewai_tool
@crewai_tool("Weather Tool")
def get_weather_crew(city: Literal['nyc', 'sf']):
"""Get weather info."""
return 'sunny' if city == 'sf' else 'cloudy'
results['CrewAI'] = measure(
lambda: CrewAgent(
role='Weather Agent',
goal='Provide weather info',
backstory='A weather expert',
tools=[get_weather_crew],
verbose=False
)
)
)
except ImportError:
pass
except ImportError:
pass
# Print results
print('\n' + '=' * 60)
print('RESULTS')
print('=' * 60)
log('\n' + '=' * 60)
log('RESULTS')
log('=' * 60)
baseline = results.get('PraisonAI', 1)
print(f"\n{'Framework':<25} {'Avg Time (μs)':<15} {'Relative':<10}")
print('-' * 50)
log(f"\n{'Framework':<25} {'Avg Time (μs)':<15} {'Relative':<10}")
log('-' * 50)
for name, avg in sorted(results.items(), key=lambda x: x[1]):
ratio = avg / baseline
print(f'{name:<25} {avg:<15.2f} {ratio:.2f}x')
log(f'{name:<25} {avg:<15.2f} {ratio:.2f}x')
print('\n' + '=' * 60)
log('\n' + '=' * 60)
return results
@@ -163,8 +188,13 @@ def get_package_versions():
return versions
def save_results(results: dict, filename: str = 'BENCHMARK_RESULTS.md'):
"""Save benchmark results to a markdown file and update README."""
def save_results(results: dict, filename: str = 'BENCHMARK_RESULTS.md', *,
iterations: int = ITERATIONS, log=print):
"""Save benchmark results to a markdown file and update README.
Status messages are emitted via ``log`` (defaults to ``print``). Pass a
stderr logger when stdout must stay machine-readable (e.g. ``--json``).
"""
import os
import re
from datetime import datetime
@@ -191,7 +221,7 @@ def save_results(results: dict, filename: str = 'BENCHMARK_RESULTS.md'):
with open(filepath, 'w') as f:
f.write('# PraisonAI Agents - Benchmark Results\n\n')
f.write(f'**Generated:** {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\n')
f.write(f'**Iterations:** {ITERATIONS}\n')
f.write(f'**Iterations:** {iterations}\n')
f.write('**Test:** Agent instantiation (without tools)\n\n')
f.write('## Results\n\n')
f.write('| Framework | Avg Time (μs) | Relative |\n')
@@ -208,17 +238,17 @@ def save_results(results: dict, filename: str = 'BENCHMARK_RESULTS.md'):
f.write('python benchmarks/simple_benchmark.py\n')
f.write('```\n')
print(f'\nResults saved to: {filepath}')
log(f'\nResults saved to: {filepath}')
# Also update the main README.md
readme_path = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'README.md')
if os.path.exists(readme_path):
update_readme(readme_path, table_rows)
update_readme(readme_path, table_rows, log=log)
return filepath
def update_readme(readme_path: str, table_rows: list):
def update_readme(readme_path: str, table_rows: list, *, log=print):
"""Update the performance section in README.md with latest results."""
import re
@@ -239,17 +269,54 @@ def update_readme(readme_path: str, table_rows: list):
with open(readme_path, 'w') as f:
f.write(content)
print(f'README.md updated: {readme_path}')
log(f'README.md updated: {readme_path}')
def main():
parser = argparse.ArgumentParser(description='PraisonAI Agents - Performance Benchmark')
parser.add_argument('--save', action='store_true', help='Save results to file')
parser.add_argument('--skip-crewai', action='store_true',
help='Skip the optional CrewAI benchmark (its import can hang on some platforms)')
parser.add_argument('--json', action='store_true',
help='Emit results as JSON to stdout (for CI baselines/artifacts)')
parser.add_argument('--fail-over-threshold', action='store_true',
help='Exit non-zero if PraisonAI instantiation exceeds the budget in thresholds.json')
args = parser.parse_args()
thresholds = load_thresholds()
iterations = int(thresholds.get('iterations', ITERATIONS))
warmup = int(thresholds.get('warmup', WARMUP))
results = run_benchmark(
skip_crewai=args.skip_crewai,
quiet=args.json,
iterations=iterations,
warmup=warmup,
)
if args.save:
# Keep stdout machine-readable under --json by sending save status to stderr.
save_log = (lambda msg='': print(msg, file=sys.stderr)) if args.json else print
save_results(results, iterations=iterations, log=save_log)
elif not args.json:
print('\nResults not saved (use --save flag to save results to file)')
exit_code = 0
if args.fail_over_threshold:
max_us = thresholds['praisonai_instantiation_us_max']
actual_us = results.get('PraisonAI')
if actual_us is not None and actual_us > max_us:
print(
f"ERROR: PraisonAI instantiation {actual_us:.2f} us > threshold {max_us} us",
file=sys.stderr,
)
exit_code = 1
if args.json:
print(json.dumps({'results': results, 'iterations': iterations, 'warmup': warmup}))
return exit_code
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='PraisonAI Agents - Performance Benchmark')
parser.add_argument('--save', action='store_true', help='Save results to file')
args = parser.parse_args()
results = run_benchmark()
if args.save:
save_results(results)
else:
print('\nResults not saved (use --save flag to save results to file)')
sys.exit(main())
@@ -0,0 +1,5 @@
{
"praisonai_instantiation_us_max": 450,
"iterations": 100,
"warmup": 10
}
@@ -0,0 +1,91 @@
# SDK Behavior Matrix
Deterministic expectations for the public knobs that integrators most often mistake for
"model randomness". Each row lists a related test. Passing it is a useful check, but mocked
unit tests do not rule out SDK bugs in the paths they replace. If your app disagrees with a
row, check the sharp edges and investigate the real call path.
- **Scope:** `praisonaiagents` Python API (main branch). Paths are relative to
`src/praisonai-agents/`.
- **Source of truth:** code + the listed tests. If a docstring disagrees with this page,
trust the test, then fix whichever is stale.
## How to use
1. Find the knob from the report in the tables below.
2. Run the listed test: `PYTHONPATH=. python -m pytest <test> -q`.
3. **Test fails** → SDK defect; open a bug. **Test passes** but behavior differs → check the
*Sharp edge* column, reproduce on the real call path, then open a bug or update this page.
---
## 1. Chat & streaming
Streaming is configured through `output=`, not a constructor kwarg.
`Agent(stream=True)` raises `TypeError` with the hint
*"streaming moved into output=; use output=OutputConfig(stream=True)."*
`agent.stream` is `False` unless `output="stream"` or `OutputConfig(stream=True)` is used.
| API / knob | Expected behavior | Sharp edge | Test |
| --- | --- | --- | --- |
| `chat(prompt, stream=None)` | Inherits `agent.stream`. Returns the final `str` (or model, see §3). | Never a generator. | `tests/unit/agent/test_execution_api.py` |
| `chat(stream=True)` | Tokens go to `agent.stream_emitter`; the call still returns the full `str`. If the adapter cannot stream (`"Streaming is not supported"`) or the stream attempt fails transiently, it falls back to a non-streaming call. | `None` is **not** a valid "streaming succeeded" result (#5737). `LLMError`, `ToolExecutionError`, other `ValueError`s and post-dispatch tool-turn failures are **not retried** by the fallback. Of these, only `ToolExecutionError` reaches the caller; the rest roll back history and `chat()` returns `None` (see below). | `tests/test_chat_explicit_stream_fallback.py`, `tests/test_streaming_fallback_kwargs.py` |
| `chat(stream=False)` | Single non-streaming completion; no stream events. | — | `tests/unit/test_stream_preset.py` |
| `start(prompt)` | Stream priority: explicit kwarg > `agent.stream` > TTY. When streaming, returns a **generator** of chunks; otherwise a `str`. | Because `agent.stream` is always a bool, the TTY fallback only applies if `agent.stream` is set to `None`. Output guardrails are not applied to streamed chunks. | `tests/unit/agent/test_execution_api.py` |
| `run(prompt)` | Never streams unless `stream=True` is passed; returns `str`. | Ignores TTY. | `tests/unit/agent/test_execution_api.py::TestAgentRunMethod::test_run_ignores_tty_detection` |
| `iter_stream(prompt)` | Always a generator of chunks. | — | `tests/unit/agent/test_execution_api.py::TestAgentIterStreamMethod::test_iter_stream_always_streams` |
| `achat()` / `astart()` | Same `stream` resolution as `chat()`; awaits and returns the final `str`. | Unlike `start()`, `astart()` never returns an (async) generator. | `tests/unit/agent/test_achat_unified_dispatch.py`, `tests/unit/agent/test_execution_api.py::TestAsyncMethods::test_astart_streams_in_tty` |
**When `chat()`/`achat()` return `None`:** input guardrail or `BEFORE_AGENT` hook blocked the
run, or the LLM call failed with anything other than `ToolExecutionError` (history is rolled
back and the error is displayed/logged, not raised). Check for `None` rather than relying on
`try/except` around `chat()`.
An **output** guardrail rejection returns a fixed refusal string, not `None`; see
`agent.last_guardrail_error`.
## 2. Memory prefetch & scope
| API / knob | Expected behavior | Sharp edge | Test |
| --- | --- | --- | --- |
| `MemoryConfig(prefetch=True)` — sync (`_prefetch_memory`) | Before each turn, searches long-term memory with up to 5 terms (verbatim prompt → significant words → salient single words), stopping at the first non-empty hit. Results are injected into the **system prompt** under `## Recalled memories`, capped by `prefetch_limit` (5) and `prefetch_token_budget` (512, truncated with `…`). | Off by default. Errors are swallowed (debug log) and inject nothing. | `tests/unit/memory/test_prefetch.py` |
| Same — async (`_aprefetch_memory`) | Same terms, stop rule, scope and formatting via `asearch_memory`. | Backends with only `.search()` (no `search_long_term`) are searched by the sync path but not the async path. | `tests/unit/memory/test_prefetch.py::test_async_prefetch_falls_back_to_significant_tokens` |
| `MemoryConfig(user_id=...)` | Agents sharing a `user_id` share a store and recall each other's turns. Search is scoped by `user_id` + `session_id`, not `agent_id`. | Without `user_id`, each Agent gets a random `agent-<uuid>` id (warning logged) — memory is **isolated per instance**. There is no `Agent(user_id=...)` kwarg. | `tests/test_memory_cross_session.py` |
## 3. Structured output
`output_pydantic` / `output_json` are per-call `chat()`/`achat()` kwargs (forwarded by
`start()`/`run()`) and `Task` fields — not `Agent` constructor params. There is no
`output_json_schema`. Native `response_format` is used when the model supports structured
outputs; otherwise the schema is appended to the prompt as text.
| API / knob | Expected behavior | Sharp edge | Test |
| --- | --- | --- | --- |
| `chat(..., output_pydantic=Model)` | Returns a `Model` **instance** (tries strict JSON, fenced ```json, then first `{...}` in prose). | On parse failure, logs a warning and returns the **raw string** — no exception, no retry. Check `isinstance`. | `tests/unit/agents/test_chat_output_pydantic_coercion.py` |
| `chat(..., output_json=Model)` | Returns a JSON **string**. | Never a `dict` — call `json.loads` yourself. | `tests/unit/agents/test_chat_output_pydantic_coercion.py::test_chat_output_json_stays_string` |
| `Task(output_pydantic=Model)` in a team | `TaskOutput.pydantic` holds the instance; `output_format="Pydantic"`. | Fails closed: invalid output is a failed task (`Structured output validation failed ...`), which drives the retry loop. Setting both `output_json` and `output_pydantic` raises `ValueError`. | `tests/unit/agents/test_output_pydantic_policy.py` |
| `Task(output_json=Model)` in a team | `TaskOutput.json_dict` holds the parsed `dict`; `output_format="JSON"`. | Same fail-closed rule. | `tests/unit/agents/test_output_pydantic_policy.py::test_output_json_prose_is_not_success` |
| Native vs text schema | `response_format` (strict) for capable models; prompt injection otherwise. | Provider-dependent. | `tests/test_native_structured_outputs.py` |
## 4. Teams & handoff
| API / knob | Expected behavior | Sharp edge | Test |
| --- | --- | --- | --- |
| `AgentTeam(agents, tasks=[Task, ...])` | Accepts a list/tuple of `Task`. Sequential tasks are chained. `start()` returns the last task's `.raw` string (`return_dict=True` → `{"task_status", "task_results"}`). | `tasks="..."` → `TypeError: AgentTeam.tasks must be a list of Task instances, not str. ...`; `tasks=[]` → `ValueError: If tasks are provided, at least one task must be present`; non-`Task` item → `TypeError: tasks[i] must be a Task instance, got <type>`. | `tests/unit/agents/test_agentteam_task_validation.py` |
| `AgentTeam(agents)` (no tasks) | One task auto-generated per agent. | Empty `agents` fails at `start()`, not construction. | `tests/unit/agents/test_agentteam_task_validation.py::test_tasks_none_auto_generates` |
| `Agent(handoffs=[other])` | Adds a `transfer_to_<name>` tool; the model decides whether to call it. Tool returns `"Handoff successful. <name> response: ..."`. | The model may not call the tool — that is a prompt issue, not a crash. Cycles/depth (`max_depth=10`) return an `"Error: ..."` string to the model rather than raising. Timeout (300 s) applies to async handoffs only. | `tests/test_handoff_compatibility.py`, `tests/unit/test_handoff_unified.py` |
## 5. AG-UI
| API / knob | Expected behavior | Sharp edge | Test |
| --- | --- | --- | --- |
| `AGUI(agent=...).get_router()` | Returns a FastAPI `APIRouter`; mount it with `app.include_router(...)`. `POST {prefix}/agui` → SSE (`text/event-stream`); `GET {prefix}/status` → `{"status": "available"}`. | AGUI has no built-in server or default port — you run uvicorn and own startup/shutdown. `AGUI()` with no agent raises `ValueError`. | `tests/agui/test_agui.py`, `tests/agui/test_router.py` |
| SSE events | `RUN_STARTED` → `TEXT_MESSAGE_*` / `TOOL_CALL_*` → `RUN_FINISHED`; exceptions become `RUN_ERROR`. | Multi-agent (`agents=`) sends the whole result as one text message, not token stream. Event order and `RUN_ERROR` emission over the endpoint are **not pinned** by a test; the router test only checks that a failing agent still yields HTTP 200. | `tests/agui/test_streaming.py::TestRunLifecycleEvents` (event construction), `tests/agui/test_router.py::TestAGUIEndpointErrorHandling::test_agui_endpoint_handles_agent_error` (HTTP only) |
---
## Keeping this page in sync
- A PR that changes behavior of any API listed here updates the matching row (and its test)
in the same PR.
- Intentional behavior that looks like a bug: keep it in *Sharp edge* with the issue number.
- Renamed/moved tests: update the *Test* column.
@@ -227,6 +227,20 @@ _LAZY_IMPORTS = {
'aembed': ('praisonaiagents.embedding.embed', 'aembed'),
'EmbeddingResult': ('praisonaiagents.embedding.result', 'EmbeddingResult'),
'get_dimensions': ('praisonaiagents.embedding.dimensions', 'get_dimensions'),
# System One / Jev decision models (Ollama /v1/systemone)
'system_one': ('praisonaiagents.decisions', 'system_one'),
'asystem_one': ('praisonaiagents.decisions', 'asystem_one'),
'SystemOneResult': ('praisonaiagents.decisions', 'SystemOneResult'),
'choice_question': ('praisonaiagents.decisions', 'choice_question'),
'noul_question': ('praisonaiagents.decisions', 'noul_question'),
'score_question': ('praisonaiagents.decisions', 'score_question'),
'is_decision_model': ('praisonaiagents.decisions', 'is_decision_model'),
'DecisionRoutePlan': ('praisonaiagents.decisions', 'DecisionRoutePlan'),
'DecisionTriageRouter': ('praisonaiagents.decisions', 'DecisionTriageRouter'),
'triaged_start': ('praisonaiagents.decisions', 'triaged_start'),
'triage_decision': ('praisonaiagents.decisions', 'triage_decision'),
'default_ticket_triage_questions': ('praisonaiagents.decisions', 'default_ticket_triage_questions'),
# Guardrails
'GuardrailResult': ('praisonaiagents.guardrails', 'GuardrailResult'),
@@ -0,0 +1,31 @@
"""Shared lazy run-lock helper for core orchestration modules.
AgentTeam (``agents/agents.py``) and Workflow/AgentFlow (``workflows/workflows.py``)
both lazily mint a per-instance re-entrancy lock the first time a run begins.
Creation is serialized through a single module-level guard (double-checked
locking) so two threads racing into the first ``start()``/``astart()``/``run()``
observe the *same* lock object rather than each minting and acquiring its own.
"""
import threading
# Guards lazy creation of each instance's per-instance ``_run_lock`` so two
# threads that first reach start()/astart()/run() concurrently observe the same
# lock object (double-checked locking) rather than each minting its own.
_RUN_LOCK_INIT_GUARD = threading.Lock()
def ensure_run_lock(obj):
"""Lazily mint ``obj._run_lock`` under a shared module guard.
``getattr(obj, "_run_lock", None)`` works for both the dataclass-field case
(Workflow) and the plain-attribute case (AgentTeam), so the behaviour is
identical to the per-module implementations it replaces.
"""
lock = getattr(obj, "_run_lock", None)
if lock is None:
with _RUN_LOCK_INIT_GUARD:
lock = getattr(obj, "_run_lock", None)
if lock is None:
lock = threading.Lock()
obj._run_lock = lock
return lock
@@ -267,6 +267,7 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
"rate_limiter": None, # -> execution=ExecutionConfig(rate_limiter=obj)
"verification_hooks": None, # -> autonomy=AutonomyConfig(verification_hooks=[...])
"cli_backend": None, # -> runtime=
"retrieval_config": None, # RAG tuning, merged into the knowledge= config
}
@property
@@ -713,6 +714,9 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
- bool: True enables defaults
- List[str]: File paths, URLs, or text content
- KnowledgeConfig: Custom configuration
retrieval_config: Optional RAG tuning merged on top of the config
derived from ``knowledge=``. Accepts a dict or RetrievalConfig
(e.g. ``retrieval_config={"citations": True, "top_k": 5}``).
planning: Planning mode. Accepts:
- bool: True enables with defaults
- PlanningConfig: Custom configuration
@@ -908,6 +912,7 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
rate_limiter = legacy_kwargs.get("rate_limiter", _legacy_defaults["rate_limiter"])
verification_hooks = legacy_kwargs.get("verification_hooks", _legacy_defaults["verification_hooks"])
cli_backend = legacy_kwargs.get("cli_backend", _legacy_defaults["cli_backend"])
_user_retrieval_config = legacy_kwargs.get("retrieval_config", _legacy_defaults["retrieval_config"])
# ── where does this agent run? ───────────────────────────────────────
# Resolved before anything else is built so a contradiction fails at the
@@ -1745,6 +1750,17 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
knowledge = _knowledge_config
elif knowledge is False:
knowledge = None
# Explicit retrieval_config= (the RAG tuning surface shown in the docs
# and docstring examples) is merged on top of whatever the knowledge=
# param derived. A dict overrides the derived keys; a RetrievalConfig
# instance (or when no knowledge= was given) replaces it wholesale so
# the downstream RetrievalConfig resolver (below) receives it unchanged.
if _user_retrieval_config is not None:
if isinstance(_user_retrieval_config, dict) and isinstance(retrieval_config, dict):
retrieval_config.update(_user_retrieval_config)
else:
retrieval_config = _user_retrieval_config
# ─────────────────────────────────────────────────────────────────────
# Resolve PLANNING param - FAST PATH
@@ -2256,8 +2272,14 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
_wants_embeddings = (retrieval_config is not None
or embedder_config is not None
or memory not in (None, False))
if _wants_embeddings and not embedder_config and not (
retrieval_config or {}).get('embedder_config'):
# retrieval_config may be a dict (knowledge=-derived or user dict) or a
# RetrievalConfig instance (passed explicitly); read embedder_config
# from either shape without assuming .get() exists.
_rc_embedder = (
retrieval_config.get('embedder_config')
if isinstance(retrieval_config, dict)
else getattr(retrieval_config, 'embedder_config', None))
if _wants_embeddings and not embedder_config and not _rc_embedder:
from ..local import (local_embedder_config as _local_embedder,
select_embedding_model as _select_embed)
_embed_model = _select_embed(
@@ -2285,8 +2307,11 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
_cfg["embedding_dims"] = _dims
except Exception: # noqa: BLE001 -- a missing width must not break setup
pass
if retrieval_config is not None:
if isinstance(retrieval_config, dict):
retrieval_config.setdefault('embedder_config', embedder_config)
elif retrieval_config is not None and getattr(
retrieval_config, 'embedder_config', None) is None:
retrieval_config.embedder_config = embedder_config
else:
logging.warning(
"llm=%r resolved a local model, but %s serves no embedding "
@@ -3250,7 +3275,10 @@ Your Goal: {self.goal}
result = cls.__new__(cls)
memo[id(self)] = result
for k, v in self.__dict__.items():
if k in ("_Agent__cache_lock",):
if k in ("_db_run_context", "_db_session_init_lock"):
# A clone starts outside the source agent's active DB turn.
continue
elif k in ("_Agent__cache_lock",):
object.__setattr__(result, k, threading.RLock())
elif k in ("_cost_lock", "_auto_save_lock"):
object.__setattr__(result, k, threading.Lock())
@@ -6230,12 +6258,12 @@ Summary:"""
for extra in extra_files:
self._rules_manager.add_rule_file(extra)
# Discovery gate: if no rules were found, drop the manager so that
# zero-config runs incur no per-run injection cost.
# Keep explicit registrations so an initially empty glob can gain
# matches on reload. Zero-config runs still avoid injection cost.
stats = self._rules_manager.get_stats()
if stats["total_rules"] > 0:
logging.debug(f"RulesManager: Discovered {stats['total_rules']} rules")
else:
elif not extra_files:
self._rules_manager = None
except ImportError:
logging.debug("RulesManager not available")
@@ -7137,6 +7165,7 @@ Answer:"""
it stays auditable without reaching the caller. The rejection is also
exposed programmatically via :attr:`last_guardrail_error`.
"""
self._mark_db_run_error()
reason = str(error) if error else getattr(self, "_last_guardrail_error", None)
if reason:
logging.info(f"Agent {self.name}: guardrail block reason: {reason}")
@@ -8112,16 +8141,34 @@ Answer:"""
# Always set closed flag
self._closed = True
def _release_approval_scope(self) -> None:
"""Drop this agent's approval-registry grants (best-effort)."""
def _release_approval_scope(self, *, from_finalizer: bool = False) -> None:
"""Drop this agent's approval-registry grants (best-effort).
From ``__del__`` only the process-global grants are released, without
blocking on the registry lock: a GC finalizer must neither write a
ContextVar nor wait on a lock (see ``ApprovalRegistry.release_scope``).
"""
scope_id = getattr(self, '_approval_scope_id', None)
if not scope_id:
return
try:
from ..approval import get_approval_registry
get_approval_registry().release_scope(scope_id)
if from_finalizer:
# No locks and no imports here: get_approval_registry() takes a
# non-reentrant module lock that GC may have interrupted on this
# very thread. Read the singleton directly; if no registry was
# ever built, this agent recorded no grants.
import sys as _sys
_approval = _sys.modules.get("praisonaiagents.approval")
registry = getattr(_approval, "_registry", None)
if registry is None:
return
else:
from ..approval import get_approval_registry
registry = get_approval_registry()
registry.release_scope(scope_id, from_finalizer=from_finalizer)
except Exception as e:
logger.warning(f"Approval scope cleanup failed: {e}")
if not from_finalizer:
logger.warning(f"Approval scope cleanup failed: {e}")
async def aclose(self) -> None:
"""Async version of close() for async context managers."""
@@ -8298,8 +8345,9 @@ Answer:"""
# Evict this agent's process-global approval grants so a
# per-request/session agent that is only ever GC'd (never
# close()'d) does not leak registry entries forever.
self._release_approval_scope()
# close()'d) does not leak registry entries forever. Never the
# per-context cache: this may run inside the garbage collector.
self._release_approval_scope(from_finalizer=True)
except Exception as exc: # noqa: BLE001 - finalizers must not raise
import contextlib
with contextlib.suppress(Exception):
@@ -10,9 +10,11 @@ import os
import re
import time
import json
import threading
import logging
from praisonaiagents._logging import get_logger
from ..errors import BudgetExceededError, ToolExecutionError
from .tool_execution import _active_turn_cancel
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
from ._lazy_display import _get_console, _get_live, _get_display_functions
@@ -20,6 +22,37 @@ from ._lazy_display import _get_console, _get_live, _get_display_functions
logger = logging.getLogger(__name__)
_turns_lock = threading.Lock()
def _enter_turn(agent, cancel):
"""Mark a ``chat``/``achat`` turn in flight and bind its cancel token to this context."""
with _turns_lock:
agent._turns_in_flight = getattr(agent, "_turns_in_flight", 0) + 1
return _active_turn_cancel.set((agent, cancel))
def _exit_turn(agent, ctx_token):
"""Undo ``_enter_turn``: restore the context's cancel token and drop the in-flight count."""
if ctx_token is None:
return
with _turns_lock:
agent._turns_in_flight = max(getattr(agent, "_turns_in_flight", 1) - 1, 0)
try:
_active_turn_cancel.reset(ctx_token)
except (ValueError, LookupError):
_active_turn_cancel.set(None)
def _reset_loop_guard_for_turn(agent):
"""Reset per-turn loop-guard limits unless another turn on this agent is still
running (the guard is shared; resetting it would restart that turn's limits).
Never force-creates the guard."""
guard = getattr(agent, "_loop_guard", None)
if guard is not None and getattr(agent, "_turns_in_flight", 0) <= 1:
guard.reset_turn()
from typing import List, Optional, Any, Dict, Union, Generator, TYPE_CHECKING
@@ -27,6 +60,19 @@ if TYPE_CHECKING:
pass
def _sleep_unless_cancelled(seconds: float, cancel_token: Any = None, step: float = 0.2) -> None:
"""Sleep for ``seconds``, raising ``InterruptedError`` as soon as ``cancel_token`` is set."""
deadline = time.monotonic() + seconds
while True:
if cancel_token is not None and getattr(cancel_token, "is_set", lambda: False)():
reason = getattr(cancel_token, "reason", None) or "cancelled during retry backoff"
raise InterruptedError(f"Agent chat cancelled: {reason}")
remaining = deadline - time.monotonic()
if remaining <= 0:
return
time.sleep(min(step, remaining))
class _TurnCancelToken:
"""Track cancellation observed by one call without shared backend state."""
@@ -173,6 +219,51 @@ class ChatMixin:
scope["metadata_filter"] = {"session_id": session_id}
return scope
@staticmethod
def _memory_prefetch_search_queries(query: str) -> List[str]:
"""Build LIKE-friendly search terms from a natural-language user turn.
SQLite and other substring backends match the full query string. A recall
question such as "What is my project codename?" does not LIKE-match a
stored turn that only mentions "ORANGE-PANDA", so we fall back to
significant tokens when the verbatim query returns nothing (#5667).
"""
q = (query or "").strip()
if not q:
return []
seen: set[str] = set()
ordered: List[str] = []
def _add(term: str) -> None:
term = term.strip()
if not term:
return
key = term.casefold()
if key not in seen:
seen.add(key)
ordered.append(term)
_add(q)
stop = frozenset(
{
"what", "is", "my", "the", "a", "an", "your", "please", "tell",
"me", "how", "do", "does", "can", "you", "with", "for", "this",
"that", "are", "was", "were", "should", "would", "could", "have",
"has", "had", "will", "about", "from", "when", "where", "who",
"why", "which", "reply", "only", "exactly", "once", "say",
}
)
words = re.findall(r"[A-Za-z][A-Za-z0-9_-]*", q)
significant = [w for w in words if w.casefold() not in stop and len(w) >= 3]
if significant:
_add(" ".join(significant))
for word in significant:
if len(word) >= 5 or "-" in word or word[:1].isupper():
_add(word)
# Cap search calls so a long pasted prompt cannot trigger many scans.
return ordered[:5]
@staticmethod
def _memory_prefetch_query(prompt: Any) -> str:
"""Return the user-authored text used for turn-start memory lookup."""
@@ -270,12 +361,17 @@ class ChatMixin:
if not limit or not budget:
return ""
scope = self._memory_prefetch_scope()
if hasattr(memory, "search_long_term"):
results = memory.search_long_term(query, limit=limit, **scope)
elif hasattr(memory, "search"):
results = memory.search(query, limit=limit, **scope)
else:
return ""
results: List[Any] = []
for term in self._memory_prefetch_search_queries(query):
if hasattr(memory, "search_long_term"):
batch = memory.search_long_term(term, limit=limit, **scope)
elif hasattr(memory, "search"):
batch = memory.search(term, limit=limit, **scope)
else:
return ""
if batch:
results = list(batch)
break
return self._format_memory_prefetch(results, limit, budget)
except Exception as exc:
logging.debug("Memory prefetch failed; continuing without recalled context: %s", exc)
@@ -298,9 +394,14 @@ class ChatMixin:
budget = max(0, int(getattr(config, "prefetch_token_budget", 512)))
if not limit or not budget:
return ""
results = await self.asearch_memory(
query, memory_type="long_term", limit=limit
)
results: List[Any] = []
for term in self._memory_prefetch_search_queries(query):
batch = await self.asearch_memory(
term, memory_type="long_term", limit=limit
)
if batch:
results = list(batch)
break
return self._format_memory_prefetch(results, limit, budget)
except Exception as exc:
logging.debug("Async memory prefetch failed; continuing without recalled context: %s", exc)
@@ -392,11 +493,8 @@ class ChatMixin:
cached_prompt = self._cache_get(self._system_prompt_cache, cache_key)
if cached_prompt is not None:
# Path-scoped glob rules are per-turn (they depend on the files
# touched so far) so they are appended after the cache, never
# baked into the cached base prompt.
return self._append_system_prompt_suffix(
self._append_glob_rules_context(cached_prompt)
return self._append_system_prompt_turn_context(
cached_prompt, tools=tools,
)
else:
cache_key = None # Don't cache when memory is enabled
@@ -479,6 +577,11 @@ Your Goal: {self.goal}"""
# Session context is per-turn and should not be cached
if cache_key:
self._cache_put(self._system_prompt_cache, cache_key, system_prompt)
return self._append_system_prompt_turn_context(system_prompt, tools=tools)
def _append_system_prompt_turn_context(self, system_prompt, tools=None):
"""Append current-turn context to either a fresh or cached base prompt."""
# Add session context (platform awareness) if available - AFTER caching
try:
@@ -751,6 +854,34 @@ Your Goal: {self.goal}"""
return None
def _raise_if_stream_replay_unsafe(self, error: BaseException, tools) -> None:
"""Re-raise ``error`` if falling back to non-streaming could re-run a tool."""
if not (tools or getattr(self, "tools", None)):
return
from ..llm.error_classifier import is_replay_unsafe_chain
if is_replay_unsafe_chain(error):
raise error
def _structured_output_from_message(self, message, schema_model, response_text: str, original_text=None):
"""Resolve a Pydantic ``output_pydantic`` from an LLM message or JSON text.
When ``original_text`` is given, ``message.parsed`` is only trusted if the
guardrail left the response unchanged; otherwise the guardrail-approved
text wins.
"""
if schema_model is None:
return response_text
parsed = getattr(message, "parsed", None) if message is not None else None
if parsed is not None and (original_text is None or response_text == original_text):
if isinstance(parsed, schema_model):
return parsed
if hasattr(schema_model, "model_validate"):
try:
return schema_model.model_validate(parsed)
except Exception:
pass
return self._coerce_structured_output(response_text, schema_model)
def _coerce_structured_output(self, response_text, schema_model):
"""Parse a string response into the requested Pydantic model.
@@ -764,21 +895,43 @@ Your Goal: {self.goal}"""
"""
if not schema_model or not isinstance(response_text, str):
return response_text
if not hasattr(schema_model, 'model_validate_json'):
if not hasattr(schema_model, "model_validate_json"):
return response_text
candidate = response_text.strip()
try:
return schema_model.model_validate_json(candidate)
except Exception:
pass
def _try_validate(payload: str):
try:
return schema_model.model_validate_json(payload)
except Exception:
pass
if hasattr(schema_model, "model_validate"):
try:
return schema_model.model_validate(json.loads(payload))
except Exception:
pass
return None
validated = _try_validate(candidate)
if validated is not None:
return validated
# Strip a surrounding markdown code fence (```json ... ```) if present
fenced = re.search(r"```(?:json)?\s*(.*?)\s*```", candidate, re.DOTALL)
if fenced:
validated = _try_validate(fenced.group(1).strip())
if validated is not None:
return validated
# Last resort: first complete JSON object embedded in prose
start = candidate.find("{")
if start != -1:
try:
return schema_model.model_validate_json(fenced.group(1).strip())
except Exception:
obj, _ = json.JSONDecoder().raw_decode(candidate, start)
validated = _try_validate(json.dumps(obj))
if validated is not None:
return validated
except ValueError:
pass
logging.warning(
@@ -1854,6 +2007,35 @@ Your Goal: {self.goal}"""
except Exception:
logging.debug("MODEL_FALLBACK stream event failed", exc_info=True)
def _shrink_after_context_overflow(self, messages, model_name):
"""Shrink ``messages`` after the provider rejected them as too long.
Uses the configured ContextManager when present; otherwise falls back
to ``ContextCompactor.shrink_to_fit`` so agents without ``context=``
still recover. Each call targets at most half the current estimate, so
bounded retries shrink progressively even when the estimate was wrong.
"""
from ..context.budgeter import get_model_limit
from ..compaction import ContextCompactor
compactor = ContextCompactor()
target = min(
int(get_model_limit(model_name) * 0.7), # 70% of limit for safety
compactor.count_total_tokens(messages) // 2,
)
if self.context_manager:
return self.context_manager.emergency_truncate(messages, target)
return compactor.shrink_to_fit(messages, target)
def _can_restart_after_overflow(self, tools) -> bool:
"""Whether an overflow may restart the whole turn with shrunk messages.
The restart replays the turn from its outer messages, so without a
ContextManager it is limited to tool-free turns: a tool turn would
rerun already-completed tool calls. LiteLLM tool turns recover inside
the request loop instead (``LLM._shrink_on_context_overflow``).
"""
return bool(self.context_manager) or not (tools or self.tools)
def _max_retry_depth(self) -> int:
"""Maximum LLM retry depth honoured by the recovery loop.
@@ -1974,8 +2156,10 @@ Your Goal: {self.goal}"""
# Smart fallback for streaming: try streaming first, fall back to non-streaming if unsupported
streaming_response = None
if stream is None:
# Auto-detect: prefer streaming for better UX, fallback if adapter doesn't support it
if stream is None or stream is True:
# Auto-detect (stream=None) and explicit stream=True both attempt streaming
# first; sync adapters raise "Streaming is not supported", which must not
# surface as chat(stream=True) -> None (#5737).
try:
# First attempt: try with streaming enabled for better user experience
stream_callback = self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
@@ -2010,6 +2194,8 @@ Your Goal: {self.goal}"""
# Don't retry if it's an LLMError that has exhausted retries
if isinstance(e, LLMError):
raise # Re-raise LLMErrors immediately to avoid double retry
# Never replay a tool turn after a post-dispatch failure (#3860)
self._raise_if_stream_replay_unsafe(e, formatted_tools)
# For any other exception, fall back to non-streaming
logging.debug(f"{self.name}: Streaming attempt failed, falling back to non-streaming")
stream = False # Set for the main execution below
@@ -2023,20 +2209,44 @@ Your Goal: {self.goal}"""
if streaming_response is not None:
final_response = streaming_response
else:
final_response = self._chat_completion_with_retry(
cancel_token=cancel_token,
messages=messages,
temperature=temperature,
tools=formatted_tools,
stream=stream,
reasoning_steps=reasoning_steps,
task_name=task_name,
task_description=task_description,
task_id=task_id,
response_format=response_format,
stream_callback=stream_callback,
emit_events=True,
)
try:
final_response = self._chat_completion_with_retry(
cancel_token=cancel_token,
messages=messages,
temperature=temperature,
tools=formatted_tools,
stream=stream,
reasoning_steps=reasoning_steps,
task_name=task_name,
task_description=task_description,
task_id=task_id,
response_format=response_format,
stream_callback=stream_callback,
emit_events=True,
)
except ValueError as e:
# Explicit stream=True against a sync adapter that cannot
# stream must not bubble up as None (see issue #5737). Fall
# back to non-streaming so chat() still returns text, the
# same way the stream=None auto-detect path above does.
if stream and "Streaming is not supported" in str(e):
logging.debug(f"{self.name}: Streaming not supported by adapter, falling back to non-streaming")
final_response = self._chat_completion_with_retry(
cancel_token=cancel_token,
messages=messages,
temperature=temperature,
tools=formatted_tools,
stream=False,
reasoning_steps=reasoning_steps,
task_name=task_name,
task_description=task_description,
task_id=task_id,
response_format=response_format,
stream_callback=stream_callback,
emit_events=True,
)
else:
raise
# Emit LLM response trace event with token usage
_duration_ms = (time.time() - start_time) * 1000
@@ -2158,14 +2368,9 @@ Your Goal: {self.goal}"""
)
# Execute recovery actions based on classification
if classification.should_compress_context and self.context_manager:
if classification.should_compress_context and self._can_restart_after_overflow(tools):
try:
from ..context.budgeter import get_model_limit
model_limit = get_model_limit(model_name)
target = int(model_limit * 0.7) # Target 70% of limit for safety
# Apply emergency truncation
truncated_messages = self.context_manager.emergency_truncate(messages, target)
truncated_messages = self._shrink_after_context_overflow(messages, model_name)
logging.info(f"[{self.name}] {classification.user_message}")
@@ -2210,7 +2415,10 @@ Your Goal: {self.goal}"""
# Apply backoff if suggested
if classification.backoff_seconds and classification.backoff_seconds > 0:
time.sleep(classification.backoff_seconds)
_sleep_unless_cancelled(
classification.backoff_seconds,
cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None),
)
# Temporarily override the model and clear dispatcher cache
# for this call. Serialise the mutate/restore window so a
@@ -2239,7 +2447,10 @@ Your Goal: {self.goal}"""
elif classification.is_retryable and classification.backoff_seconds > 0:
if _retry_depth < self._max_retry_depth(): # Bounded by configured RetryBackoffConfig.max_retries
logging.info(f"[{self.name}] {classification.user_message} (waiting {classification.backoff_seconds:.1f}s)")
time.sleep(classification.backoff_seconds)
_sleep_unless_cancelled(
classification.backoff_seconds,
cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None),
)
return self._chat_completion(
messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
@@ -2336,14 +2547,9 @@ Your Goal: {self.goal}"""
)
# Execute recovery actions based on classification (async-compatible)
if classification.should_compress_context and self.context_manager:
if classification.should_compress_context and self._can_restart_after_overflow(tools):
try:
from ..context.budgeter import get_model_limit
model_limit = get_model_limit(model_name)
target = int(model_limit * 0.7) # Target 70% of limit for safety
# Apply emergency truncation
truncated_messages = self.context_manager.emergency_truncate(messages, target)
truncated_messages = self._shrink_after_context_overflow(messages, model_name)
logging.info(f"[{self.name}] {classification.user_message}")
@@ -2512,8 +2718,9 @@ Your Goal: {self.goal}"""
self._unified_dispatcher = dispatcher
# Smart fallback for streaming: try streaming first, fall back to non-streaming if unsupported
if stream is None:
# Auto-detect: prefer streaming for better UX, fallback if adapter doesn't support it
if stream is None or stream is True:
# Auto-detect (stream=None) and explicit stream=True both attempt streaming
# first; sync adapters raise "Streaming is not supported", which must fall back.
try:
# First attempt: try with streaming enabled for better user experience
if stream_callback is None and hasattr(self, 'stream_emitter'):
@@ -2536,6 +2743,7 @@ Your Goal: {self.goal}"""
emit_events=emit_events,
verbose=self.verbose,
max_iterations=self._resolve_max_steps(),
output_continuation=getattr(getattr(self, "execution", None), "output_continuation", True),
reasoning_steps=reasoning_steps,
task_name=task_name,
task_description=task_description,
@@ -2554,7 +2762,9 @@ Your Goal: {self.goal}"""
stream = False # Set for the main execution below
else:
raise # Re-raise if it's a different ValueError
except Exception:
except Exception as e:
# Never replay a tool turn after a post-dispatch failure (#3860)
self._raise_if_stream_replay_unsafe(e, tools)
# For any other exception, fall back to non-streaming
logging.debug(f"Agent: Streaming attempt failed, falling back to non-streaming")
stream = False # Set for the main execution below
@@ -2582,6 +2792,7 @@ Your Goal: {self.goal}"""
emit_events=emit_events,
verbose=self.verbose,
max_iterations=self._resolve_max_steps(),
output_continuation=getattr(getattr(self, "execution", None), "output_continuation", True),
reasoning_steps=reasoning_steps,
task_name=task_name,
task_description=task_description,
@@ -2732,6 +2943,7 @@ Your Goal: {self.goal}"""
emit_events=emit_events,
verbose=self.verbose,
max_iterations=self._resolve_max_steps(),
output_continuation=getattr(getattr(self, "execution", None), "output_continuation", True),
reasoning_steps=reasoning_steps,
task_name=task_name,
task_description=task_description,
@@ -3215,6 +3427,12 @@ Your Goal: {self.goal}"""
_trace_emitter.agent_start(self.name, {"role": self.role, "goal": self.goal})
durable_context = None
durable_token = None
previous_run_state = self._get_db_run_state()
previous_run_id = self._current_run_id
db_started = time.perf_counter()
db_output, db_status = None, "error"
_cancel = None
_cancel_ctx = None
try:
# C2 - cooperative cancellation: abort early if a pre-set token is given
cancel_source = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
@@ -3222,6 +3440,7 @@ Your Goal: {self.goal}"""
cancel_source, explicit=cancel_token is not None
)
self._active_turn_token = _cancel
_cancel_ctx = _enter_turn(self, _cancel)
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
reason = getattr(_cancel, "reason", None) or "cancelled before LLM call"
raise InterruptedError(f"Agent chat cancelled: {reason}")
@@ -3232,7 +3451,16 @@ Your Goal: {self.goal}"""
durable_context, durable_token = begin_durable_run(
self, prompt if isinstance(prompt, str) else str(prompt)
)
if self._db is not None:
self._init_db_session()
self._start_run(prompt if isinstance(prompt, str) else str(prompt))
result = self._chat_impl(prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice, seed=seed, cancel_token=_cancel)
db_output = str(result) if result is not None else None
# A guardrail-blocked turn returns a non-None refusal string; it must
# be recorded as "error", not "completed" (the answer was rejected).
db_status = "cancelled" if _cancel is not None and _cancel.was_cancelled() else (
"error" if result is None else "completed"
)
if durable_context is not None:
outcome = (
"cancelled"
@@ -3248,11 +3476,17 @@ Your Goal: {self.goal}"""
self.execution.resume_run_id = None
raise
except InterruptedError:
db_status = "cancelled"
if durable_context is not None:
durable_context.finalize("cancelled")
self.execution.resume_run_id = None
raise
finally:
try:
if self._current_run_id is not None and self._current_run_id != previous_run_id:
self._end_run(db_output, db_status, {"duration_ms": (time.perf_counter() - db_started) * 1000})
finally:
self._restore_db_run_state(previous_run_state)
if durable_context is not None:
from .durable import end_durable_run
@@ -3263,6 +3497,7 @@ Your Goal: {self.goal}"""
finally:
if getattr(self, "_active_turn_token", None) is _cancel:
self._active_turn_token = None
_exit_turn(self, _cancel_ctx)
if _cancel is not None:
_cancel.close()
@@ -3271,6 +3506,10 @@ Your Goal: {self.goal}"""
# Reset the per-turn tool buffer so the self-improve review policy only
# sees tools used in this turn (not during a nested skill-review turn).
self._reset_turn_tools()
# Per-turn loop-guard limits (time/count/no-progress) must not accumulate
# across turns of a long-lived agent.
_reset_loop_guard_for_turn(self)
self._last_stop_reason = "completed"
# Apply rate limiter if configured (before any LLM call)
if self._rate_limiter is not None:
self._rate_limiter.acquire()
@@ -3299,10 +3538,10 @@ Your Goal: {self.goal}"""
# Initialize session store for JSON-based persistence (lazy)
# This enables automatic session persistence when session_id is provided
self._init_session_store()
# Start a new run for this chat turn
# The DB run is now started/ended by the public chat() boundary, but the
# prompt string is still needed below for hook input and memory.
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
self._start_run(prompt_str)
# Trigger BEFORE_AGENT hook (only build the input if a hook is actually registered)
from ..hooks import HookEvent, BeforeAgentInput
@@ -3555,6 +3794,7 @@ Your Goal: {self.goal}"""
),
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
max_iterations=self._resolve_max_steps(),
output_continuation=getattr(getattr(self, "execution", None), "output_continuation", True),
max_tool_calls_per_turn=self._resolve_max_tool_calls(),
reasoning_steps=reasoning_steps,
stream=stream
@@ -3605,7 +3845,9 @@ Your Goal: {self.goal}"""
# must stay a string for backward-compatible downstream
# JSON parsing (task parsing, workflow results).
if output_pydantic:
validated_response = self._coerce_structured_output(validated_response, output_pydantic)
validated_response = self._coerce_structured_output(
validated_response, output_pydantic
)
# Execute callback and display after validation
self._execute_callback_and_display(prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return self._trigger_after_agent_hook(prompt, validated_response, start_time)
@@ -3617,6 +3859,7 @@ Your Goal: {self.goal}"""
except ToolExecutionError:
raise
except Exception as e:
self._last_stop_reason = "error"
# Rollback chat history if LLM call fails
self._rollback_chat_history_to(chat_history_length)
_get_display_functions()['display_error'](f"Error in LLM chat: {e}")
@@ -3624,6 +3867,7 @@ Your Goal: {self.goal}"""
except ToolExecutionError:
raise
except Exception as e:
self._last_stop_reason = "error"
_get_display_functions()['display_error'](f"Error in LLM chat: {e}")
return None
finally:
@@ -3648,6 +3892,10 @@ Your Goal: {self.goal}"""
use_native_format=use_native_format,
memory_prefetch_context=memory_prefetch_context,
)
if attachments:
# Model content includes ephemeral files; history and after-turn
# memory must receive only the user's text, including hook edits.
original_prompt = prompt
# Track messages THIS turn appends so a failure rolls back only our
@@ -3724,8 +3972,18 @@ Your Goal: {self.goal}"""
return None
# Handle None content (can happen with tool calls or empty responses)
content = response.choices[0].message.content
message = response.choices[0].message
content = message.content
response_text = content.strip() if content else ""
if output_pydantic and not response_text:
parsed_only = getattr(message, "parsed", None)
if parsed_only is not None:
if hasattr(parsed_only, "model_dump_json"):
response_text = parsed_only.model_dump_json()
elif isinstance(parsed_only, (dict, list)):
response_text = json.dumps(parsed_only)
else:
response_text = str(parsed_only)
# Handle output_json or output_pydantic if specified
if output_json or output_pydantic:
@@ -3741,7 +3999,9 @@ Your Goal: {self.goal}"""
# get a validated instance, not a raw JSON string.
# output_json intentionally stays a string (backward compat).
if output_pydantic:
validated_response = self._coerce_structured_output(validated_response, output_pydantic)
validated_response = self._structured_output_from_message(
message, output_pydantic, validated_response, response_text
)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
@@ -3768,7 +4028,10 @@ Your Goal: {self.goal}"""
return self._trigger_after_agent_hook(original_prompt, validated_reasoning, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed for reasoning content: {e}")
# Rollback chat history on guardrail failure
# Rollback chat history on guardrail failure.
# The public chat()/achat() finalizer records the
# blocked turn as "error" (see _guardrail_blocked_message),
# so the run keeps its output and duration_ms.
self._rollback_chat_history_to(chat_history_length)
return self._guardrail_blocked_message(e)
# Apply guardrail to regular response
@@ -3842,13 +4105,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=messages)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
self._end_run(validated_response, "completed", {"duration_ms": (time.time() - start_time) * 1000})
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection: {e}")
# Rollback chat history on guardrail failure
self._rollback_chat_history_to(chat_history_length)
self._end_run(None, "error", {"error": str(e)})
return self._guardrail_blocked_message(e)
# Check if we've hit max reflections
@@ -3909,6 +4170,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
except ToolExecutionError:
raise
except Exception as e:
self._last_stop_reason = "error"
# Catch any exceptions that escape the while loop
_get_display_functions()['display_error'](f"Unexpected error in chat: {e}", console=self.console)
# Rollback chat history
@@ -4005,11 +4267,16 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
_trace_emitter.agent_start(self.name, {"role": self.role, "goal": self.goal})
durable_context = None
durable_token = None
previous_run_state = self._get_db_run_state()
previous_run_id = self._current_run_id
db_started = time.perf_counter()
db_output, db_status = None, "error"
cancel_source = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
_cancel = self._turn_cancel_token(
cancel_source, explicit=cancel_token is not None
)
self._active_turn_token = _cancel
_cancel_ctx = _enter_turn(self, _cancel)
try:
if getattr(getattr(self, "execution", None), "durable", False):
from .durable import abegin_durable_run
@@ -4017,6 +4284,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
durable_context, durable_token = await abegin_durable_run(
self, prompt if isinstance(prompt, str) else str(prompt)
)
if self._db is not None:
await self._ainit_db_session()
await self._astart_run(prompt if isinstance(prompt, str) else str(prompt))
result = await self._achat_impl(
prompt=prompt, temperature=temperature, tools=tools,
output_json=output_json, output_pydantic=output_pydantic,
@@ -4026,6 +4296,12 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
attachments=attachments, _trace_emitter=_trace_emitter, tool_choice=tool_choice,
seed=seed, cancel_token=_cancel
)
db_output = str(result) if result is not None else None
# A guardrail-blocked turn returns a non-None refusal string; record
# it as "error", not "completed" (see sync chat()).
db_status = "cancelled" if _cancel is not None and _cancel.was_cancelled() else (
"error" if result is None else "completed"
)
if durable_context is not None:
outcome = (
"cancelled"
@@ -4041,23 +4317,32 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self.execution.resume_run_id = None
raise
except (InterruptedError, asyncio.CancelledError):
db_status = "cancelled"
if durable_context is not None:
await durable_context.afinalize("cancelled")
self.execution.resume_run_id = None
raise
finally:
if durable_context is not None:
from .durable import end_durable_run
end_durable_run(durable_token)
await durable_context.aclose()
try:
_trace_emitter.agent_end(self.name)
if self._current_run_id is not None and self._current_run_id != previous_run_id:
await self._aend_run(db_output, db_status, {"duration_ms": (time.perf_counter() - db_started) * 1000})
finally:
if getattr(self, "_active_turn_token", None) is _cancel:
self._active_turn_token = None
if _cancel is not None:
_cancel.close()
self._restore_db_run_state(previous_run_state)
try:
if durable_context is not None:
from .durable import end_durable_run
end_durable_run(durable_token)
await durable_context.aclose()
finally:
try:
_trace_emitter.agent_end(self.name)
finally:
if getattr(self, "_active_turn_token", None) is _cancel:
self._active_turn_token = None
_exit_turn(self, _cancel_ctx)
if _cancel is not None:
_cancel.close()
async def _achat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None, seed=None, cancel_token=None):
"""Internal async chat implementation (extracted for trace wrapping)."""
@@ -4067,6 +4352,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Reset the per-turn tool buffer so the self-improve review policy only
# sees tools used in this turn (not during a nested skill-review turn).
self._reset_turn_tools()
# Per-turn loop-guard limits (time/count/no-progress) must not accumulate
# across turns of a long-lived agent.
_reset_loop_guard_for_turn(self)
self._last_stop_reason = "completed"
# Apply rate limiter if configured (before any LLM call) - async parity
# with the sync path in _chat_impl; without this, async bursts (astart,
# async_execution=True) would fire with zero throttling.
@@ -4238,6 +4527,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
),
'parallel_tool_calls': getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
'max_iterations': self._resolve_max_steps(),
'output_continuation': getattr(getattr(self, "execution", None), "output_continuation", True),
'max_tool_calls_per_turn': self._resolve_max_tool_calls(),
'reasoning_steps': reasoning_steps,
'stream': stream
@@ -4293,6 +4583,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
except ToolExecutionError:
raise
except Exception as e:
self._last_stop_reason = "error"
# Rollback chat history if LLM call fails
self._rollback_chat_history_to(chat_history_length)
_get_display_functions()['display_error'](f"Error in LLM chat: {e}")
@@ -4790,6 +5081,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
except ToolExecutionError:
raise
except Exception as e:
self._last_stop_reason = "error"
_get_display_functions()['display_error'](f"Error in chat completion: {e}")
if get_logger().getEffectiveLevel() == logging.DEBUG:
total_time = time.time() - start_time
@@ -4802,6 +5094,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
except ToolExecutionError:
raise
except Exception as e:
self._last_stop_reason = "error"
_get_display_functions()['display_error'](f"Error in achat: {e}")
if get_logger().getEffectiveLevel() == logging.DEBUG:
total_time = time.time() - start_time
@@ -5071,6 +5364,12 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Temporarily disable verbose mode to prevent console output conflicts during streaming
original_verbose = self.verbose
self.verbose = False
# Initialize persistence so streamed turns reach the session store
# incrementally (Issue #5407). chat()/achat() do this already; the
# streaming path previously skipped it, so an interrupted stream
# persisted nothing at all.
self._init_db_session()
self._init_session_store()
memory_prefetch_context = self._prefetch_memory(prompt)
# Ephemeral attachments (images / data URIs) are folded into a
@@ -5134,6 +5433,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self.chat_history[-1].get("role") == "user" and
self.chat_history[-1].get("content") == normalized_content):
self._append_to_chat_history({"role": "user", "content": normalized_content})
# Persist the user turn so a recovered partial assistant turn
# is paired with its prompt on resume (Issue #5407).
self._persist_message("user", normalized_content)
try:
# Use the new streaming generator from LLM class
@@ -5213,12 +5515,23 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
**stream_sampling_kwargs
):
response_content += chunk
# Flush the growing partial turn so an interrupt mid
# stream retains it in the store (Issue #5407, debounced).
self._persist_assistant_delta(response_content)
yield chunk
# Add complete response to chat history
if response_content:
self._append_to_chat_history({"role": "assistant", "content": response_content})
# Finalize the streamed turn (promotes the partial record or
# falls back to a single terminal write).
self._finalize_assistant_turn(response_content)
except GeneratorExit:
# Consumer stopped iterating (e.g. Ctrl-C). Keep whatever
# was produced so --continue can recover it (Issue #5407).
self._persist_assistant_delta(response_content, force=True)
raise
except ToolExecutionError:
self._rollback_chat_history_to(chat_history_length)
raise
@@ -5292,6 +5605,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self.chat_history[-1].get("role") == "user" and
self.chat_history[-1].get("content") == normalized_content):
self._append_to_chat_history({"role": "user", "content": normalized_content})
# Persist the user turn so a recovered partial assistant turn
# is paired with its prompt on resume (Issue #5407).
self._persist_message("user", normalized_content)
try:
# Check if OpenAI client is available
@@ -5367,6 +5683,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
tool_calls_data = []
first_token_emitted = False
last_content_time = None
# Track the text of the phase currently streaming so a
# GeneratorExit (consumer Ctrl-C) recovers *that* phase's
# answer. Before tools this is response_text; after tools it
# becomes the post-tool follow-up answer. Without this the
# interrupt handler would force a stale (usually empty)
# pre-tool response_text over the real answer (Issue #5407).
active_stream = {"text": "", "after_tools": False}
for chunk in completion:
delta = chunk.choices[0].delta
@@ -5393,6 +5716,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
content=chunk_content
))
# Flush the growing partial turn so an interrupt
# mid stream retains it in the store (Issue #5407,
# debounced).
active_stream["text"] = response_text
self._persist_assistant_delta(response_text)
yield chunk_content
# Handle tool calls (accumulate but don't yield as chunks)
@@ -5428,6 +5756,16 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# append exactly the assistant tool-call and tool-result
# messages onto the original request context below.
tool_turn_start = len(self.chat_history)
# Drop any partial record for this turn before writing
# the authoritative tool-call assistant turn below so the
# store never keeps a stale partial (Issue #5407).
self._discard_partial_assistant_turn()
# From here the post-tool answer is the recoverable
# phase; reset the interrupt tracker so a Ctrl-C during
# the follow-up persists that answer, not the pre-tool
# text we just discarded (Issue #5407).
active_stream["after_tools"] = True
active_stream["text"] = ""
# Add assistant message with tool calls to chat history
assistant_message = {"role": "assistant", "content": response_text}
if tool_calls_data:
@@ -5628,6 +5966,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if piece:
followup_text += piece
followup_last_content_time = time_module.perf_counter()
# Flush the post-tool answer incrementally too so
# an interrupt during the follow-up keeps it
# (Issue #5407, debounced).
active_stream["text"] = followup_text
self._persist_assistant_delta(followup_text)
yield piece
if followup_last_content_time:
self.stream_emitter.emit(StreamEvent(
@@ -5643,6 +5986,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._append_to_chat_history(
{"role": "assistant", "content": followup_text}
)
# Finalize the post-tool answer so resume sees a
# completed turn, not a dangling partial (Issue
# #5407).
self._finalize_assistant_turn(followup_text)
else:
# Tools ran and the model then said nothing. Simply
# returning here is indistinguishable from a model
@@ -5658,7 +6005,19 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Add complete response to chat history (text-only response)
if response_text:
self._append_to_chat_history({"role": "assistant", "content": response_text})
# Finalize the streamed turn in the store (Issue #5407).
self._finalize_assistant_turn(response_text)
except GeneratorExit:
# Consumer stopped iterating (e.g. Ctrl-C). Keep whatever
# the *current* phase produced so --continue can recover it.
# After a tool round the recoverable text is the post-tool
# follow-up answer, not the pre-tool response_text that was
# already discarded (Issue #5407).
self._persist_assistant_delta(
active_stream["text"], force=True
)
raise
except ToolExecutionError:
self._rollback_chat_history_to(chat_history_length)
raise
@@ -5717,6 +6076,29 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
"""
import inspect
# Replay-safety gate (#3860): reissuing chat() after a streaming failure
# is still a provider replay. When the turn exposes tools and the
# streaming failure is replay-unsafe (a post-dispatch read timeout /
# connection reset, possibly wrapped by the LLM path as the cause of a
# generic error), a tool call may already have run server-side, so the
# turn must not be re-issued. Surface the original failure instead. A
# tool-less turn (or a pre-dispatch failure) keeps falling back.
request_tools = kwargs.get('tools')
instance_tools = getattr(self, 'tools', None)
side_effecting = bool(request_tools) or bool(instance_tools)
if side_effecting:
try:
from praisonaiagents.llm.error_classifier import is_replay_unsafe_chain
except Exception: # noqa: BLE001 - never let classification break fallback
is_replay_unsafe_chain = None
if is_replay_unsafe_chain is not None and is_replay_unsafe_chain(streaming_error):
logging.warning(
"Not falling back to non-streaming chat() on a tool turn: the "
"streaming failure is replay-unsafe (post-dispatch); surfacing "
"the original error to avoid re-executing the turn."
)
raise streaming_error
try:
parameters = inspect.signature(self.chat).parameters
except (TypeError, ValueError):
@@ -483,29 +483,12 @@ class ExecutionMixin:
def _execute_backend_sync(self, prompt: str, **kwargs) -> Optional[str]:
"""Execute backend in sync mode, handling async backends."""
try:
# Try to run in existing event loop
loop = asyncio.get_running_loop()
# If we're already in an async context, we can't use asyncio.run()
# Create a new task instead
import concurrent.futures
import threading
def run_async():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(self.backend.execute(prompt, **kwargs))
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_async)
return future.result()
except RuntimeError:
# No event loop running, safe to use asyncio.run()
return asyncio.run(self.backend.execute(prompt, **kwargs))
from ..utils.async_bridge import run_coroutine_from_any_context
# Single shared bridge: copies contextvars and surfaces backend errors
# unchanged, whether or not an event loop is already running.
return run_coroutine_from_any_context(
self.backend.execute(prompt, **kwargs), timeout=None
)
def _delegate_streaming_to_backend(self, prompt: str, **kwargs):
"""Delegate to backend's streaming method."""
@@ -1606,7 +1589,11 @@ Write the complete compiled report:"""
def _run_chain():
return manager.execute_tool_call(request, _final_handler)
response = await loop.run_in_executor(None, _run_chain)
# run_in_executor does not copy ContextVars (unlike asyncio.to_thread);
# run the chain in a copy of the caller's context so hooked async tools
# still see session identity, cancel state and the handoff chain.
import contextvars
response = await loop.run_in_executor(None, contextvars.copy_context().run, _run_chain)
return response.result if isinstance(response, ToolResponse) else response
async def _execute_tool_async_with_retry(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None, tools_override: Optional[List] = None) -> Any:
@@ -1115,10 +1115,10 @@ class Handoff:
except HandoffCycleError as e:
logger.error(f"Handoff cycle detected: {e}")
return f"Error: Handoff cycle detected - {e}"
return {"error": f"Handoff cycle detected - {e}", "handoff_failed": True}
except HandoffDepthError as e:
logger.error(f"Handoff depth exceeded: {e}")
return f"Error: Maximum handoff depth exceeded - {e}"
return {"error": f"Maximum handoff depth exceeded - {e}", "handoff_failed": True}
except Exception as e:
logger.error(f"Error during handoff to {self.agent.name}: {str(e)}")
result = HandoffResult(
@@ -1129,7 +1129,9 @@ class Handoff:
error=str(e),
)
self._execute_callback(self.config.on_error, source_agent, kwargs, result)
return f"Error during handoff to {self.agent.name}: {str(e)}"
# Structured error so tool-failure handling (telemetry, hooks,
# run status) sees a failed call rather than a success string
return {"error": f"Error during handoff to {self.agent.name}: {e}", "handoff_failed": True}
finally:
if pushed:
_pop_handoff()
@@ -11,7 +11,7 @@ import inspect
import logging
import threading
import contextvars
from typing import Optional, Dict
from typing import Any, Awaitable, Callable, Optional, Dict
# Per-turn ownership of appended chat-history messages. Each chat()/achat() turn
# runs in its own thread (sync) or task (async); both get an isolated copy of
@@ -31,6 +31,7 @@ _active_turn_owned: "contextvars.ContextVar[Optional[list]]" = contextvars.Conte
# _live_turns_lock because turns on different threads mutate it concurrently.
_live_turn_owners: "Dict[int, list]" = {}
_live_turns_lock = threading.Lock()
_auto_memory_init_lock = threading.Lock()
def _register_live_turn(owned: list) -> None:
@@ -72,6 +73,38 @@ if TYPE_CHECKING:
class MemoryMixin:
"""Mixin providing memory methods for the Agent class."""
@property
def _current_run_id(self) -> Optional[str]:
state = self._get_db_run_state()
return state["run_id"] if state is not None else None
def _get_db_run_state(self) -> Optional[dict]:
run_context = getattr(self, "_db_run_context", None)
return run_context.get() if run_context is not None else None
def _restore_db_run_state(self, state: Optional[dict]) -> None:
run_context = getattr(self, "_db_run_context", None)
if run_context is not None:
run_context.set(state)
def _mark_db_run_error(self) -> None:
state = self._get_db_run_state()
if state is not None:
state["status"] = "error"
@_current_run_id.setter
def _current_run_id(self, value: Optional[str]) -> None:
run_context = getattr(self, "_db_run_context", None)
if run_context is None:
if value is None:
return
# Each agent has its own context; concurrent tasks and threads must
# not finalize one another's runs. Allocate only for configured DBs.
run_context = self.__dict__.setdefault(
"_db_run_context", contextvars.ContextVar("praisonai_db_run", default=None)
)
run_context.set({"run_id": value, "status": None} if value is not None else None)
def _cache_put(self, cache_dict, key, value):
"""Thread-safe LRU cache put operation.
@@ -372,7 +405,31 @@ class MemoryMixin:
"""Initialize DB session if db adapter is provided (lazy, first chat only)."""
if self._db is None or self._db_initialized:
return
with self._get_db_session_init_lock().sync():
if self._db_initialized:
return
self._ensure_db_session_id()
try:
history = self._db.on_agent_start(
agent_name=self.name,
session_id=self._session_id,
user_id=self.user_id,
metadata={"role": self.role, "goal": self.goal}
)
self._restore_db_history(history)
except Exception as e:
logging.warning(f"Failed to initialize DB session: {e}")
self._db_initialized = True
self._current_run_id = None
def _get_db_session_init_lock(self):
from .async_safety import DualLock
return self.__dict__.setdefault("_db_session_init_lock", DualLock())
def _ensure_db_session_id(self) -> None:
"""Share session allocation between sync and async adapter callbacks."""
# Generate session_id if not provided: default to per-hour ID (YYYYMMDDHH-agentname)
# Protected by history lock to prevent race condition between concurrent chat() calls
if self._session_id is None:
@@ -398,42 +455,57 @@ class MemoryMixin:
agent_hash = hashlib.sha256((self.name or "agent").encode()).hexdigest()[:6]
# per-instance suffix so same-named agents can never collide
self._session_id = f"{hour_str}-{agent_hash}-{uuid.uuid4().hex[:8]}"
# Call db adapter's on_agent_start to get previous messages
try:
history = self._db.on_agent_start(
agent_name=self.name,
session_id=self._session_id,
user_id=self.user_id,
metadata={"role": self.role, "goal": self.goal}
)
# Restore chat history from previous session. Rebuild the full LLM
# message shape so a resumed tool-using session hands the model the
# same transcript it saw before — assistant turns keep their
# ``tool_calls`` and tool-result turns keep their ``tool_call_id``
# (Issue #3089 parity for the DB path). Plain turns stay minimal.
if history:
for msg in history:
entry = {"role": msg.role, "content": msg.content}
tool_calls = getattr(msg, "tool_calls", None)
if tool_calls:
# ``DbMessage.tool_calls`` may arrive as provider-shaped
# dicts (OpenAI format, the way _persist_message stores
# them) or as ``DbToolCall`` dataclasses from an adapter
# that follows the declared field type. Normalise to the
# provider shape so the next model request always gets a
# valid, serialisable tool-call list (Issue #5075).
entry["tool_calls"] = self._normalize_restored_tool_calls(tool_calls)
if getattr(msg, "tool_call_id", None):
entry["tool_call_id"] = msg.tool_call_id
self.chat_history.append(entry)
logging.info(f"Resumed session {self._session_id} with {len(history)} messages")
except Exception as e:
logging.warning(f"Failed to initialize DB session: {e}")
self._db_initialized = True
self._current_run_id = None # Track current run
def _restore_db_history(self, history) -> None:
# Restore chat history from previous session. Rebuild the full LLM
# message shape so a resumed tool-using session hands the model the
# same transcript it saw before — assistant turns keep their
# ``tool_calls`` and tool-result turns keep their ``tool_call_id``
# (Issue #3089 parity for the DB path). Plain turns stay minimal.
if history:
for msg in history:
entry = {"role": msg.role, "content": msg.content}
tool_calls = getattr(msg, "tool_calls", None)
if tool_calls:
# ``DbMessage.tool_calls`` may arrive as provider-shaped
# dicts (OpenAI format, the way _persist_message stores
# them) or as ``DbToolCall`` dataclasses from an adapter
# that follows the declared field type. Normalise to the
# provider shape so the next model request always gets a
# valid, serialisable tool-call list (Issue #5075).
entry["tool_calls"] = self._normalize_restored_tool_calls(tool_calls)
if getattr(msg, "tool_call_id", None):
entry["tool_call_id"] = msg.tool_call_id
self.chat_history.append(entry)
logging.info(f"Resumed session {self._session_id} with {len(history)} messages")
def _async_db_hook(self, name: str) -> Optional[Callable[..., Awaitable[Any]]]:
"""Use adapter-provided async hooks without changing sync-only adapters."""
callback = getattr(self._db, f"a{name}", None)
return callback if inspect.iscoroutinefunction(callback) else None
async def _ainit_db_session(self) -> None:
"""Await session reads when the adapter supplies an async callback."""
if self._db is None or self._db_initialized:
return
callback = self._async_db_hook("on_agent_start")
if callback is None:
self._init_db_session()
return
async with self._get_db_session_init_lock().async_lock():
if self._db_initialized:
return
self._ensure_db_session_id()
try:
history = await callback(
agent_name=self.name, session_id=self._session_id,
user_id=self.user_id, metadata={"role": self.role, "goal": self.goal},
)
self._restore_db_history(history)
except Exception as exc:
logging.warning(f"Failed to initialize DB session: {exc}")
return
self._db_initialized = True
@staticmethod
def _normalize_restored_tool_calls(tool_calls):
@@ -516,44 +588,97 @@ class MemoryMixin:
self._session_store_initialized = True
def _prepare_db_run(self, input_content: str) -> dict:
import uuid
self._current_run_id = f"run-{uuid.uuid4().hex[:12]}"
return {
"session_id": self._session_id,
"run_id": self._current_run_id,
"input_content": input_content,
"metadata": {"agent_name": self.name},
}
def _start_run(self, input_content: str):
"""Start a new run (turn) for persistence tracking."""
if self._db is None:
return
import uuid
self._current_run_id = f"run-{uuid.uuid4().hex[:12]}"
kwargs = self._prepare_db_run(input_content)
try:
if hasattr(self._db, 'on_run_start'):
self._db.on_run_start(
session_id=self._session_id,
run_id=self._current_run_id,
input_content=input_content,
metadata={"agent_name": self.name}
)
except Exception as e:
logging.warning(f"Failed to start run: {e}")
if hasattr(self._db, "on_run_start"):
self._db.on_run_start(**kwargs)
except Exception as exc:
logging.warning(f"Failed to start run: {exc}")
async def _astart_run(self, input_content: str) -> None:
callback = self._async_db_hook("on_run_start")
if callback is None:
self._start_run(input_content)
return
kwargs = self._prepare_db_run(input_content)
try:
await callback(**kwargs)
except Exception as exc:
logging.warning(f"Failed to start run: {exc}")
def _db_run_end_kwargs(self, output_content, status, metrics) -> Optional[dict]:
if self._db is None or self._current_run_id is None:
return None
state = self._get_db_run_state()
if status == "completed" and state is not None and state.get("status") == "error":
status = "error"
return {
"session_id": self._session_id,
"run_id": self._current_run_id,
"output_content": output_content,
"status": status,
"metrics": metrics or {},
"metadata": {"agent_name": self.name},
}
def _end_run(self, output_content: str, status: str = "completed", metrics: dict = None):
"""End the current run (turn)."""
if self._db is None or self._current_run_id is None:
kwargs = self._db_run_end_kwargs(output_content, status, metrics)
if kwargs is None:
return
try:
if hasattr(self._db, 'on_run_end'):
self._db.on_run_end(
session_id=self._session_id,
run_id=self._current_run_id,
output_content=output_content,
status=status,
metrics=metrics or {},
metadata={"agent_name": self.name}
)
except Exception as e:
logging.warning(f"Failed to end run: {e}")
self._current_run_id = None
if hasattr(self._db, "on_run_end"):
self._db.on_run_end(**kwargs)
except Exception as exc:
logging.warning(f"Failed to end run: {exc}")
finally:
self._current_run_id = None
async def _aend_run(self, output_content: Optional[str], status: str = "completed", metrics: Optional[dict] = None) -> None:
callback = self._async_db_hook("on_run_end")
if callback is None:
self._end_run(output_content, status, metrics)
return
kwargs = self._db_run_end_kwargs(output_content, status, metrics)
if kwargs is None:
return
import asyncio
async def save():
try:
await callback(**kwargs)
except Exception as exc:
logging.warning(f"Failed to end run: {exc}")
pending = asyncio.create_task(save())
try:
await asyncio.shield(pending)
except asyncio.CancelledError:
# Finish the terminal write before propagating caller cancellation.
while not pending.done():
try:
await asyncio.shield(pending)
except asyncio.CancelledError:
continue
pending.result()
raise
finally:
self._current_run_id = None
def _persist_message(
self,
@@ -659,6 +784,84 @@ class MemoryMixin:
self._persist_message, role, content, tool_calls, tool_call_id
)
def _persist_assistant_delta(self, content: str, *, force: bool = False) -> None:
"""Persist the in-progress streamed assistant turn (Issue #5407).
Streamed output is only persisted after a turn completes, so an
interrupt mid-generation loses the whole partial turn. This flushes the
latest partial text to the session store via
``upsert_partial_assistant_message`` so the store always reflects the
current turn. Writes are debounced (time-based) so per-token streaming
does not hammer the disk; ``force=True`` bypasses the debounce.
Degrades to a no-op when the store does not support partial upserts
(the terminal :meth:`_persist_message` write then preserves existing
per-turn behaviour).
"""
store = getattr(self, "_session_store", None)
session_id = getattr(self, "_session_id", None)
if store is None or session_id is None:
return
upsert = getattr(store, "upsert_partial_assistant_message", None)
if upsert is None:
return
if not force:
import time as _time
now = _time.monotonic()
last = getattr(self, "_partial_persist_last", 0.0)
# ~5 writes/sec ceiling keeps disk I/O bounded for long turns.
if now - last < 0.2:
return
self._partial_persist_last = now
try:
upsert(session_id, content)
except Exception as e:
logging.debug(f"Partial assistant persist skipped: {e}")
def _finalize_assistant_turn(self, content: str) -> None:
"""Finalize the streamed assistant turn, promoting the partial record.
Clears the partial flag on the trailing assistant message so resume
sees a normal completed turn. When the store has no partial-upsert
support this falls back to :meth:`_persist_message` so the assistant
turn is still persisted once at the end (backward compatible).
"""
store = getattr(self, "_session_store", None)
session_id = getattr(self, "_session_id", None)
self._partial_persist_last = 0.0
if store is not None and session_id is not None:
upsert = getattr(store, "upsert_partial_assistant_message", None)
if upsert is not None:
try:
upsert(session_id, content, finalize=True)
self._persist_session_stats()
return
except Exception as e:
logging.debug(f"Finalize assistant turn skipped: {e}")
# Fallback: no partial support — persist once terminally.
if content:
self._persist_message("assistant", content)
def _discard_partial_assistant_turn(self) -> None:
"""Drop any in-progress partial assistant record for this session.
Used when the authoritative assistant turn is persisted through another
path (e.g. a streamed tool-call turn) so a stale partial does not linger
in the store (Issue #5407). No-op for stores without partial support.
"""
store = getattr(self, "_session_store", None)
session_id = getattr(self, "_session_id", None)
self._partial_persist_last = 0.0
if store is None or session_id is None:
return
discard = getattr(store, "discard_partial_assistant_message", None)
if discard is None:
return
try:
discard(session_id)
except Exception as e:
logging.debug(f"Discard partial assistant turn skipped: {e}")
def _add_message_with_tool_fields(self, role, content, **tool_fields):
"""Add a message carrying tool fields, tolerating stores without them.
@@ -751,12 +954,13 @@ class MemoryMixin:
try:
from ..memory.auto_memory import AutoMemory
# Lazy-create AutoMemory wrapper on first use
if not hasattr(self, '_auto_memory_instance') or self._auto_memory_instance is None:
self._auto_memory_instance = AutoMemory(
self._memory_instance,
enabled=True,
verbose=1 if getattr(self, 'verbose', False) else 0
)
with _auto_memory_init_lock:
if not hasattr(self, '_auto_memory_instance') or self._auto_memory_instance is None:
self._auto_memory_instance = AutoMemory(
self._memory_instance,
enabled=True,
verbose=1 if getattr(self, 'verbose', False) else 0
)
self._auto_memory_instance.process_interaction(
user_message=str(user_message),
assistant_response=str(assistant_response),
@@ -767,10 +971,18 @@ class MemoryMixin:
def _persist_memory_turn(self, user_message: str, assistant_response: str):
"""Persist a raw conversation turn to the memory store.
Called after each response when ``_memory_instance`` is set but
``auto_memory`` extraction is off. Stores the turn to short-term memory
so a later Agent sharing the same store (e.g. ``memory={"user_id": uid}``)
recalls it via ``get_memory_context()``. Best-effort: never raises.
Called after each response when ``_memory_instance`` is set. Stores the
turn so a later Agent sharing the same store (e.g. a second ``Agent``
with the same ``user_id``) recalls it. Best-effort: never raises.
The turn is written to **long-term** memory because turn-start prefetch
(``_prefetch_memory``) queries ``search_long_term``; a short-term-only
write was invisible to prefetch, so cross-instance recall returned empty
(issue #5667). ``user_id`` is stamped into the record metadata so the
prefetch scope filter — which post-filters on ``metadata.user_id`` —
matches records written here (they otherwise carry no ``user_id`` and
were silently dropped). A short-term copy is kept so the inline
``get_memory_context()`` path still sees the turn.
Async-safe: a memory store write is blocking file/DB I/O. When this runs
inside a live event loop (the async ``achat``/``astart`` after-agent path),
@@ -780,17 +992,41 @@ class MemoryMixin:
memory = getattr(self, "_memory_instance", None)
if memory is None or not assistant_response:
return
store = getattr(memory, "store_short_term", None) or getattr(memory, "add_short_term", None)
if store is None:
long_store = getattr(memory, "store_long_term", None) or getattr(memory, "add_long_term", None)
short_store = getattr(memory, "store_short_term", None) or getattr(memory, "add_short_term", None)
if long_store is None and short_store is None:
return
text = f"User: {user_message}\nAssistant: {assistant_response}"
metadata = {"agent_id": getattr(self, "agent_id", getattr(self, "name", None))}
# Stamp the scoping identity so turn-start prefetch can find this record
# on a different Agent instance. Prefetch (_memory_prefetch_scope) sends
# user_id AND, when configured, a session_id metadata_filter; both are
# post-filtered against record metadata by Memory.search_long_term, so a
# turn missing either key is silently dropped on recall.
config = getattr(self, "_memory_config", None)
user_id = getattr(config, "user_id", None)
if user_id:
metadata["user_id"] = user_id
session_id = getattr(config, "session_id", None)
if session_id:
metadata["session_id"] = session_id
def _do_store():
try:
store(text, metadata=metadata)
except Exception as e:
logging.debug(f"Memory turn persistence failed: {e}")
# Write each tier under its own guard. The two tiers are independent
# stores; a long-term failure (e.g. a locked DB) must not skip the
# short-term copy that powers the inline get_memory_context() path,
# and vice versa. Sharing one try would have removed the existing
# short-term fallback the moment durable recall was added.
if long_store is not None:
try:
long_store(text, metadata=metadata)
except Exception as e:
logging.debug(f"Long-term memory turn persistence failed: {e}")
if short_store is not None:
try:
short_store(text, metadata=metadata)
except Exception as e:
logging.debug(f"Short-term memory turn persistence failed: {e}")
try:
import asyncio
@@ -1024,4 +1260,3 @@ class MemoryMixin:
logging.warning(f"Failed to save output to file '{self._output_file}': {e}")
print(f"⚠️ Failed to save output to {self._output_file}: {e}")
return False
@@ -29,6 +29,15 @@ logger = logging.getLogger(__name__)
# timeouts so they cannot exhaust process resources.
_MAX_ORPHANED_TOOL_EXECUTORS = 4
# Effective cancel token of the turn running in *this* context, as
# ``(agent, token)``. ``self._active_turn_token`` is a single slot overwritten by
# whichever concurrent turn started last; this ContextVar (copied into tool
# executor threads and asyncio tasks) lets each turn's tools/approvals see their
# own token. Set/reset by ``chat``/``achat``.
_active_turn_cancel: "contextvars.ContextVar[Optional[tuple]]" = contextvars.ContextVar(
"praisonai_active_turn_cancel", default=None
)
# Worker count for the per-agent tool ThreadPoolExecutor. Used both when the
# pool is created and to decide (with the per-executor in-flight counter)
# whether a fresh call would have to queue behind hung workers.
@@ -152,6 +161,17 @@ class BackoffPolicy:
MULTIMODAL_IMAGE_BYTE_LIMIT = 5_000_000
def _memory_prompt_text(prompt: Any) -> str:
"""Match text-only conversation history without retaining attachment parts."""
if isinstance(prompt, list):
return "\n".join(
part["text"] for part in prompt
if isinstance(part, dict) and part.get("type") == "text"
and isinstance(part.get("text"), str)
)
return prompt if isinstance(prompt, str) else str(prompt)
def _content_part_to_data_uri(part: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Convert a structured content part into an OpenAI-style message part.
@@ -547,6 +567,15 @@ class ToolExecutionMixin:
so disabling or unregistering a plugin revokes its tools from this
agent's discovery and execution paths.
"""
# Hot-path fast exit: when no plugins are registered (the common case)
# skip constructing/scanning the manager entirely on every Agent init.
try:
from ..plugins.manager import has_registered_plugins
if not has_registered_plugins():
return
except Exception: # pragma: no cover - defensive plugin boundary
pass
try:
from ..plugins import get_plugin_manager
@@ -673,6 +702,15 @@ class ToolExecutionMixin:
prevent the Agent from constructing. Zero overhead when no such plugin
is enabled (each collector returns an empty list).
"""
# Hot-path fast exit: when no plugins are registered (the common case)
# skip constructing/scanning the manager entirely on every Agent init.
try:
from ..plugins.manager import has_registered_plugins
if not has_registered_plugins():
return
except Exception: # pragma: no cover - defensive plugin boundary
pass
try:
from ..plugins import get_plugin_manager
@@ -915,7 +953,11 @@ class ToolExecutionMixin:
Returns ``None`` when neither is attached, which keeps today's behaviour
(the request is treated as always live).
"""
token = getattr(self, '_active_turn_token', None)
current = _active_turn_cancel.get()
if current is not None and current[0] is self:
token = current[1]
else:
token = getattr(self, '_active_turn_token', None)
if token is not None:
is_set = getattr(token, 'is_set', None)
if callable(is_set):
@@ -1701,8 +1743,18 @@ class ToolExecutionMixin:
if isinstance(result, dict):
max_field_chars = getattr(self, 'tool_output_limit', DEFAULT_TOOL_OUTPUT_LIMIT) if not self.context_manager else None
result = self._truncate_dict_fields(result, function_name, max_field_chars, tool_call_id)
# Add artifact reference to dict result if available
if artifact_ref:
# Per-field truncation only shortens individual fields
# that each exceed the field limit; a dict of many
# medium-sized fields (e.g. 50 search snippets each
# just under the limit) stays unbounded and can still
# overflow the context. Enforce a total-size budget:
# if the serialised dict is still over the limit, fall
# back to the already-computed head/tail string (which
# carries the artifact reference for full retrieval).
if len(str(result)) > limit:
result = truncated
elif artifact_ref:
# Add artifact reference to dict result if available
result["_artifact_ref"] = artifact_ref.to_dict()
else:
result = truncated
@@ -1922,7 +1974,7 @@ class ToolExecutionMixin:
execution_time_ms=(time.time() - start_time) * 1000
)
def _after_agent_side_effects(self, prompt, response):
def _after_agent_side_effects(self, prompt, response, *, process_auto_memory=True):
"""Run loop-agnostic after-agent side effects (no ``await`` inside).
Covers auto-memory extraction, auto-learning extraction, and the
@@ -1931,18 +1983,18 @@ class ToolExecutionMixin:
boundary.
"""
# Auto-memory extraction (opt-in via MemoryConfig(auto_memory=True))
if response:
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
if response and process_auto_memory:
prompt_str = _memory_prompt_text(prompt)
if getattr(self, "_auto_memory", False):
self._process_auto_memory(prompt_str, str(response))
else:
# When memory is enabled but auto_memory extraction is off, still
# persist the raw turn so a later Agent sharing the same memory
# (e.g. memory={"user_id": uid}) can recall it. Without this the
# sync chat/run/start path wrote nothing and cross-session recall
# silently returned empty (issue #5595). Mirrors the write already
# done on the async unified path.
self._persist_memory_turn(prompt_str, str(response))
# Persist the raw turn whenever memory is enabled so a later Agent
# sharing the same store (e.g. memory={"user_id": uid}) can recall it.
# Runs even with auto_memory on: the pattern extractor is best-effort
# and misses arbitrary facts (e.g. "codename ORANGE-PANDA"), so it
# must augment — not replace — durable turn storage. Without this,
# cross-instance recall silently returned empty (issues #5595, #5667).
# No-op when _memory_instance is None.
self._persist_memory_turn(prompt_str, str(response))
# Auto-learning extraction (opt-in via LearnConfig(mode=LearnMode.AGENTIC))
self._process_auto_learning()
@@ -2039,7 +2091,13 @@ class ToolExecutionMixin:
after_agent_input = self._build_after_agent_input(prompt, response, start_time, tools_used)
await self._hook_runner.execute(HookEvent.AFTER_AGENT, after_agent_input)
self._after_agent_side_effects(prompt, response)
if response and getattr(self, '_memory_instance', None):
prompt_str = _memory_prompt_text(prompt)
if getattr(self, '_auto_memory', False):
await asyncio.to_thread(self._process_auto_memory, prompt_str, str(response))
else:
await asyncio.to_thread(self._persist_memory_turn, prompt_str, str(response))
self._after_agent_side_effects(prompt, response, process_auto_memory=False)
# Autonomous skill self-improvement loop (opt-in via self_improve=True).
# In "background" mode the review runs off the hot path on the core
@@ -308,6 +308,36 @@ class VideoAgent:
params["extra_headers"] = headers
return params
# Models with a working LiteLLM video backend, surfaced in the error
# message when a provider has no video route (e.g. xai/grok-imagine-video-*).
SUPPORTED_MODEL_HINTS = (
"openai/sora-2",
"gemini/veo-3.1-generate-preview",
"runwayml/gen4_turbo",
)
def _raise_if_unsupported_video_backend(self, error: Exception) -> None:
"""Re-raise LiteLLM 'not supported' video errors with a clear message.
LiteLLM's cost/model catalogue lists chat-priced ids (e.g.
``xai/grok-imagine-video-1.5``) that have no video-generation backend,
so ``video_generation()`` fails with ``... is not supported for <provider>``.
Turn that opaque provider error into an actionable ValueError naming
models that do have a video route. Any other error is left untouched.
"""
# Match LiteLLM's specific missing-backend phrasing
# ("video generation is not supported for <provider>") so a working
# backend rejecting a bad request (e.g. "video size is not supported")
# is NOT misclassified as a missing backend and left to propagate.
message = str(error).lower()
if "video generation is not supported" in message:
supported = ", ".join(self.SUPPORTED_MODEL_HINTS)
raise ValueError(
f"'{self.llm}' has no LiteLLM video-generation backend "
f"(it may be listed for chat pricing only). "
f"Use a supported video model, e.g.: {supported}"
) from error
def _provider_headers(self) -> Dict[str, str]:
"""Optional attribution headers for gateway providers.
@@ -406,6 +436,7 @@ class VideoAgent:
progress.update(task, completed=True)
return response
except Exception as e:
self._raise_if_unsupported_video_backend(e)
if self.verbose:
self.console.print(f"[red]Error generating video: {e}[/red]")
raise
@@ -435,7 +466,11 @@ class VideoAgent:
params.pop("poll_interval", None)
params.pop("max_wait_time", None)
return await self.video_module["avideo_generation"](**params)
try:
return await self.video_module["avideo_generation"](**params)
except Exception as e:
self._raise_if_unsupported_video_backend(e)
raise
def status(self, video_id: str, **kwargs) -> Any:
"""
@@ -2,11 +2,12 @@ import os
import time
import json
import logging
import collections
import contextlib
import contextvars
import threading
from praisonaiagents._logging import get_logger
from typing import Any, Dict, Optional, List, Tuple, Callable
from typing import Any, Deque, Dict, Optional, List, Tuple, Callable
from ..main import display_error, TaskOutput
from ..agent.agent import Agent
from ..task.task import Task
@@ -24,10 +25,7 @@ try:
except ImportError:
get_token_collector = None
# Guards lazy creation of each AgentTeam's per-instance _run_lock so two threads
# that first reach start()/astart() concurrently observe the same lock object
# (double-checked locking) rather than each minting and acquiring its own.
_RUN_LOCK_INIT_GUARD = threading.Lock()
from .._run_lock import ensure_run_lock
# Task status constants
class TaskStatus(Enum):
@@ -297,22 +295,12 @@ def _resolve_coroutine_sync(coro):
``astart`` dispatches sync-marked tasks through the sync ``run_task`` path
(in an executor), so an ``async def`` handler still lands here. Rather than
stringifying the coroutine, drive it to completion on a private loop.
stringifying the coroutine, drive it to completion via the shared bridge,
which copies contextvars and surfaces the coroutine's errors unchanged.
"""
import asyncio
from ..utils.async_bridge import run_coroutine_from_any_context
try:
asyncio.get_running_loop()
except RuntimeError:
# No running loop in this thread — safe to use asyncio.run.
return asyncio.run(coro)
# A loop is already running in this thread; run the coroutine on a fresh
# loop in a worker thread so we don't re-enter the active loop.
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, coro).result()
return run_coroutine_from_any_context(coro, timeout=None)
def _execute_task_handler(agents_instance, task_id):
@@ -1073,6 +1061,18 @@ class AgentTeam(SpawnAnnounceProtocol):
"Please install with: pip install \"praisonaiagents[memory]\""
)
# Validate the tasks container before anything iterates it.
if isinstance(tasks, str):
raise TypeError(
"AgentTeam.tasks must be a list of Task instances, not str. "
"Wrap the description in Task(description=...) or pass tasks=None "
"to auto-generate tasks from agents."
)
if tasks is not None and not isinstance(tasks, (list, tuple)):
raise TypeError(
f"AgentTeam.tasks must be a list of Task instances, not {type(tasks).__name__}"
)
if not agents:
# Handler-only tasks (task.handler with no agent) don't need an agent,
# mirroring the Workflow engine. Allow a team built solely from them.
@@ -1151,7 +1151,8 @@ class AgentTeam(SpawnAnnounceProtocol):
# Spawn-announce pattern support
self._spawned_agents: Dict[str, SpawnedSubAgent] = {}
self._completion_callbacks: Dict[str, Callable[[SubAgentCompletionEvent], Any]] = {}
self._completion_events: List[SubAgentCompletionEvent] = []
# Bounded so long-lived teams don't retain every sub-agent result forever
self._completion_events: Deque[SubAgentCompletionEvent] = collections.deque(maxlen=1000)
self._event_bus: Optional[EventBus] = None
self._spawn_lock = threading.RLock() # Thread-safe spawn operations (reentrant)
# Strong references to fire-and-forget async spawn tasks. The event loop
@@ -1194,8 +1195,25 @@ class AgentTeam(SpawnAnnounceProtocol):
tasks.append(task)
logger.info(f"Auto-generated {len(tasks)} tasks from agents")
else:
if isinstance(tasks, (str, bytes)):
raise TypeError(
"tasks must be a sequence of Task instances, not "
f"{type(tasks).__name__}. Pass tasks=None to auto-generate from "
"agents, or tasks=[Task(...)]."
)
try:
tasks = list(tasks)
except TypeError:
raise TypeError(
f"tasks must be a sequence of Task instances, got {type(tasks).__name__}"
)
if not tasks:
raise ValueError("If tasks are provided, at least one task must be present")
for i, item in enumerate(tasks):
if not isinstance(item, Task):
raise TypeError(
f"tasks[{i}] must be a Task instance, got {type(item).__name__}"
)
logger.info(f"Using {len(tasks)} provided tasks")
# Add tasks and set their status
@@ -2088,14 +2106,7 @@ class AgentTeam(SpawnAnnounceProtocol):
guard (double-checked locking) so two threads racing into start()/astart()
observe the same lock object.
"""
lock = getattr(self, '_run_lock', None)
if lock is None:
with _RUN_LOCK_INIT_GUARD:
lock = getattr(self, '_run_lock', None)
if lock is None:
lock = threading.Lock()
self._run_lock = lock
return lock
return ensure_run_lock(self)
def _execution_identity(self):
"""Identity of the current execution for re-entrancy checks.
@@ -4459,6 +4470,9 @@ class AgentTeam(SpawnAnnounceProtocol):
for event in self._completion_events:
if event.agent_id in target_agents:
completed_agents.add(event.agent_id)
# Finished agents leave _spawned_agents; their event may have
# aged out of the bounded history, so don't wait on it forever
completed_agents.update(a for a in target_agents if a not in self._spawned_agents)
# Return if all target agents are completed
if completed_agents >= set(target_agents):
@@ -4681,6 +4695,8 @@ class AgentTeam(SpawnAnnounceProtocol):
"""Check if all target agents are completed - THREAD-SAFE version."""
with self._spawn_lock:
events_snapshot = list(self._completion_events)
# Finished agents whose event aged out of the bounded history
completed_agents.update(a for a in target_agents if a not in self._spawned_agents)
for event in events_snapshot:
if event.agent_id in target_agents:
completed_agents.add(event.agent_id)
@@ -17,6 +17,7 @@ Usage (no Agent param needed)::
from __future__ import annotations
import asyncio
import collections
import contextvars
import threading
from praisonaiagents._logging import get_logger
@@ -98,6 +99,9 @@ class ApprovalRegistry:
:func:`get_approval_registry`.
"""
# Upper bound on per-context approval-cache entries (see ``mark_approved``).
_MAX_CONTEXT_APPROVALS = 1024
def __init__(self) -> None:
# Backends
self._global_backend = None # type: ignore[assignment]
@@ -122,6 +126,17 @@ class ApprovalRegistry:
# agent's grant that is being written at the same time. Only wraps these
# small in-memory mutations, so it adds no hot-path cost.
self._scope_lock = threading.Lock()
# Scope ids whose release a GC finalizer could not apply because
# ``_scope_lock`` was held (possibly by the very frame the collector
# interrupted). Drained by the next ``release_scope``.
self._pending_scope_releases: List[str] = []
# Scope ids of agents that have been closed or collected. A finalizer
# appends here (deque.append is atomic, so no lock); the next ordinary
# ``mark_approved`` drains it and prunes those agents' keys from the
# per-context cache it is already copying. Bounded: if a dead scope
# falls off the end its keys are still bounded by the cache cap below.
self._dead_scope_ids: collections.deque = collections.deque(maxlen=4096)
self._dead_scopes: Dict[str, None] = {}
# Per-agent, per-tool auto-approval (G-A fix)
self._agent_tool_auto_approve: Dict[tuple[str, str], bool] = {}
@@ -133,8 +148,10 @@ class ApprovalRegistry:
self._session_scoped_targets: Set[tuple[Optional[str], str]] = set()
# Context variables (per-coroutine / per-thread)
self._approved_context: contextvars.ContextVar[Set[str]] = contextvars.ContextVar(
"approved_context", default=set()
# Per-context approval cache: an insertion-ordered ``{cache_key: scope}``
# mapping, replaced (never mutated) on every write. ``None`` = empty.
self._approved_context: contextvars.ContextVar[Optional[Dict[str, str]]] = contextvars.ContextVar(
"approved_context", default=None
)
self._yaml_approved_tools: contextvars.ContextVar[Set[str]] = contextvars.ContextVar(
"yaml_approved_tools", default=set()
@@ -235,6 +252,30 @@ class ApprovalRegistry:
return level
return self._risk_levels.get(tool_name)
def list_requirements(self) -> List[Dict[str, Optional[str]]]:
"""Public, stable view of every approval requirement.
Returns one entry per (tool, agent) pair -- ``agent`` is ``None`` for a
global requirement -- so callers (e.g. an HTTP ``/approvals`` surface)
can enumerate requirements without reaching into private fields. Keeps
the internal storage shape free to change without breaking consumers.
"""
requirements: List[Dict[str, Optional[str]]] = []
for tool_name in sorted(self._required_tools):
requirements.append({
"tool": tool_name,
"agent": None,
"risk_level": self.get_risk_level(tool_name),
})
for agent_name, tools in self._agent_required_tools.items():
for tool_name in sorted(tools):
requirements.append({
"tool": tool_name,
"agent": agent_name,
"risk_level": self.get_risk_level(tool_name, agent_name),
})
return requirements
# ── Per-tool auto-approval (G-A fix) ─────────────────────────────────
def auto_approve_tool(self, tool_name: str, agent_name: str) -> None:
@@ -280,9 +321,47 @@ class ApprovalRegistry:
agent_name: Optional[str] = None,
scope_id: Optional[str] = None,
) -> None:
approved = self._approved_context.get(set())
approved.add(self._approval_cache_key(tool_name, arguments or {}, agent_name, scope_id))
self._approved_context.set(approved)
# Copy-on-write: ``contextvars.copy_context()`` copies the mapping, not
# the cache object, so mutating it in place would leak an approval
# granted in a child context (worker thread, task) into its parent and
# siblings. The copy also prunes keys of agents that have since died
# and keeps at most ``_MAX_CONTEXT_APPROVALS`` (oldest dropped first;
# a dropped entry only means a human may be asked again).
key = self._approval_cache_key(tool_name, arguments or {}, agent_name, scope_id)
scope = scope_id or agent_name or '*'
dead = self._drain_dead_scopes()
current = self._approved_context.get(None) or {}
if key in current and not dead:
return
if isinstance(current, dict):
fresh = {k: s for k, s in current.items() if s not in dead} if dead else dict(current)
else: # tolerate a plain set written by older code or tests
# Keys are "{scope}:{tool}:{hash}" and scope ids themselves contain
# a colon ("{name}:{uuid}"), so strip the last two fields.
fresh = {k: k.rsplit(":", 2)[0] for k in current}
fresh.pop(key, None)
fresh[key] = scope
overflow = len(fresh) - self._MAX_CONTEXT_APPROVALS
if overflow > 0:
for old in list(fresh)[:overflow]:
del fresh[old]
self._approved_context.set(fresh)
def _drain_dead_scopes(self) -> Dict[str, None]:
"""Move finalizer-reported dead scopes into the bounded lookup map.
Called only from ordinary (non-finalizer) code. Returns the map so the
caller can prune with an O(1) membership test per cached key.
"""
if self._dead_scope_ids:
with self._scope_lock:
while self._dead_scope_ids:
self._dead_scopes[self._dead_scope_ids.popleft()] = None
overflow = len(self._dead_scopes) - self._dead_scope_ids.maxlen
if overflow > 0:
for old in list(self._dead_scopes)[:overflow]:
del self._dead_scopes[old]
return self._dead_scopes
def is_already_approved(
self,
@@ -293,7 +372,7 @@ class ApprovalRegistry:
) -> bool:
# Honour an explicit mark_approved() from the agent approval path even
# for critical tools (e.g. execute_command after AutoApproveBackend).
if self._approval_cache_key(tool_name, arguments or {}, agent_name, scope_id) in self._approved_context.get(set()):
if self._approval_cache_key(tool_name, arguments or {}, agent_name, scope_id) in (self._approved_context.get(None) or ()):
return True
return False
@@ -415,11 +494,11 @@ class ApprovalRegistry:
)
def clear_approved(self) -> None:
self._approved_context.set(set())
self._approved_context.set(None)
with self._scope_lock:
self._session_scoped_targets.clear()
def release_scope(self, scope_id: str) -> None:
def release_scope(self, scope_id: str, *, from_finalizer: bool = False) -> None:
"""Drop all grants recorded under a per-instance approval scope id.
``auto_approve_tool`` (skill ``allowed-tools`` pre-approval),
@@ -431,6 +510,14 @@ class ApprovalRegistry:
``Agent.close()``/``aclose()``/``__del__`` to reclaim a dead agent's
entries without touching any other agent's grants.
``from_finalizer=True`` must be used from a garbage-collection
finalizer, which runs in whichever thread and context the collector
fires in, possibly in the middle of a ContextVar update or while this
thread already holds ``_scope_lock``. It therefore never writes the
ContextVar (the "current" context there is not the agent's anyway) and
never blocks on the lock: if the lock is busy the release is queued and
applied by the next ``release_scope`` call.
Mutates the shared collections **in place under ``_scope_lock``** (rather
than rebuilding-and-replacing) so a concurrent writer's grant for a
*different* scope can never be lost to a read-modify-write race, and
@@ -438,11 +525,24 @@ class ApprovalRegistry:
"""
if not scope_id:
return
with self._scope_lock:
for key in [k for k in self._agent_tool_auto_approve if k[0] == scope_id]:
# Lets every context prune this agent's cache keys on its next write.
self._dead_scope_ids.append(scope_id)
if from_finalizer:
if not self._scope_lock.acquire(blocking=False):
self._pending_scope_releases.append(scope_id)
return
else:
self._scope_lock.acquire()
try:
scopes = {scope_id}
while self._pending_scope_releases:
scopes.add(self._pending_scope_releases.pop())
for key in [k for k in self._agent_tool_auto_approve if k[0] in scopes]:
self._agent_tool_auto_approve.pop(key, None)
for entry in [t for t in self._session_scoped_targets if t[0] == scope_id]:
for entry in [t for t in self._session_scoped_targets if t[0] in scopes]:
self._session_scoped_targets.discard(entry)
finally:
self._scope_lock.release()
# Evict this scope's per-context approval-cache keys. ``mark_approved``
# stores ``"{scope_id}:{tool}:{hash}"`` entries in the ``_approved_context``
@@ -450,10 +550,16 @@ class ApprovalRegistry:
# agents would otherwise retain one key per approved call forever. This
# only touches the *current* context's set (ContextVars are per
# coroutine/thread), which is the context the closing agent ran in.
prefix = f"{scope_id}:"
if from_finalizer:
return
try:
approved = self._approved_context.get(set())
if approved:
approved = self._approved_context.get(None)
if isinstance(approved, dict) and approved:
remaining = {k: s for k, s in approved.items() if s != scope_id}
if len(remaining) != len(approved):
self._approved_context.set(remaining)
elif approved: # a plain set written by older code or tests
prefix = f"{scope_id}:"
remaining = {k for k in approved if not k.startswith(prefix)}
if len(remaining) != len(approved):
self._approved_context.set(remaining)
@@ -5,8 +5,6 @@ Provides reusable async-to-sync bridging logic to prevent code duplication
across the approval system.
"""
import asyncio
import concurrent.futures
import hashlib
import json
from typing import Any, Awaitable, Callable, Dict, Optional, TypeVar
@@ -238,34 +236,10 @@ def run_coroutine_safely(
TimeoutError: If the operation times out
Any exception raised by the coroutine
"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
# We're in an async context - use thread pool to avoid RuntimeError
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
# Wrap the coroutine with timeout handling inside the thread
def run_with_timeout():
if timeout is not None and timeout > 0:
return asyncio.run(asyncio.wait_for(coro, timeout=timeout))
else:
return asyncio.run(coro)
future = pool.submit(run_with_timeout)
try:
# Don't use timeout on Future.result() since we handle timeout
# inside the coroutine via asyncio.wait_for
result = future.result(timeout=None if timeout is None or timeout == 0 else timeout)
return result
finally:
# Properly shut down the executor without waiting for threads
pool.shutdown(wait=False, cancel_futures=True)
else:
# No running event loop - use asyncio.run directly
if timeout is not None and timeout > 0:
return asyncio.run(asyncio.wait_for(coro, timeout=timeout))
else:
return asyncio.run(coro)
# Delegate to the single shared bridge, which copies contextvars across the
# worker thread, surfaces the coroutine's errors unchanged, and enforces the
# timeout consistently. ``timeout <= 0`` here means "no timeout".
from ..utils.async_bridge import run_coroutine_from_any_context
effective_timeout = timeout if (timeout is not None and timeout > 0) else None
return run_coroutine_from_any_context(coro, timeout=effective_timeout)
@@ -32,6 +32,8 @@ Usage:
result = await task.wait()
"""
from .._lazy import create_lazy_getattr
__all__ = [
# Core classes
"BackgroundRunner",
@@ -47,30 +49,13 @@ __all__ = [
]
def __getattr__(name: str):
"""Lazy load module components to avoid import overhead."""
if name == "BackgroundRunner":
from .runner import BackgroundRunner
return BackgroundRunner
if name == "BackgroundTask":
from .task import BackgroundTask
return BackgroundTask
if name == "TaskStatus":
from .task import TaskStatus
return TaskStatus
if name == "BackgroundConfig":
from .config import BackgroundConfig
return BackgroundConfig
if name == "get_background_runner":
from .runner import get_background_runner
return get_background_runner
if name == "SqliteBackgroundJobStore":
from .sqlite_store import SqliteBackgroundJobStore
return SqliteBackgroundJobStore
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
_LAZY_IMPORTS = {
"BackgroundRunner": ("praisonaiagents.background.runner", "BackgroundRunner"),
"BackgroundTask": ("praisonaiagents.background.task", "BackgroundTask"),
"TaskStatus": ("praisonaiagents.background.task", "TaskStatus"),
"BackgroundConfig": ("praisonaiagents.background.config", "BackgroundConfig"),
"get_background_runner": ("praisonaiagents.background.runner", "get_background_runner"),
"SqliteBackgroundJobStore": ("praisonaiagents.background.sqlite_store", "SqliteBackgroundJobStore"),
}
__getattr__ = create_lazy_getattr(_LAZY_IMPORTS, __name__)
@@ -11,7 +11,7 @@ import uuid
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
from dataclasses import dataclass, field
from concurrent.futures import ThreadPoolExecutor, Future
from concurrent.futures import CancelledError, ThreadPoolExecutor, Future
from praisonaiagents._logging import get_logger
@@ -137,6 +137,7 @@ class BackgroundJobManager:
self._jobs: Dict[str, JobInfo] = {}
self._futures: Dict[str, Future] = {}
self._lock = threading.Lock()
self._last_gc = time.time()
def _persist(self, job: JobInfo) -> None:
"""Best-effort persist of a job's current state to the store.
@@ -194,6 +195,13 @@ class BackgroundJobManager:
"""
job_id = job_id or str(uuid.uuid4())[:8]
# Opportunistically evict old terminal jobs so long-lived hosts
# (gateways, bots, servers) don't retain every finished result.
now = time.time()
if now - self._last_gc > self._GC_INTERVAL:
self._last_gc = now
self.cleanup_completed()
job_info = JobInfo(
job_id=job_id,
status=JobStatus.PENDING,
@@ -357,9 +365,17 @@ class BackgroundJobManager:
RuntimeError: If job failed
"""
with self._lock:
if job_id not in self._futures:
future = self._futures.get(job_id)
if future is None:
# Evicted from memory: answer terminal outcomes from the durable store
job = self._store.get(job_id) if self._store else None
if job is None or job.status not in self._EVICTABLE_STATES:
raise KeyError(f"Job {job_id} not found")
future = self._futures[job_id]
if job.status == JobStatus.COMPLETED:
return job.result
if job.status == JobStatus.CANCELLED:
raise CancelledError(f"Job {job_id} was cancelled")
raise RuntimeError(job.error or f"Job {job_id} {job.status.value}")
try:
return future.result(timeout=timeout)
@@ -516,6 +532,9 @@ class BackgroundJobManager:
JobStatus.LOST,
)
# Minimum seconds between opportunistic cleanups triggered by start_job
_GC_INTERVAL = 60.0
def cleanup_completed(self, max_age: float = 3600.0) -> int:
"""
Remove terminal jobs older than max_age seconds.
@@ -18,7 +18,7 @@ recorded ``reason_code``.
from __future__ import annotations
from dataclasses import dataclass
from typing import FrozenSet, Iterable, Optional
from typing import FrozenSet, Iterable, List, Mapping, Optional
# Machine-readable reason codes. Kept as module constants so callers can record
# and compare them without re-typing string literals (which is exactly how the
@@ -131,6 +131,55 @@ def _contains(collection: Optional[Iterable[str]], value: Optional[str]) -> bool
return value in collection
# Prefix marking a reference to a named access group (Issue #5775), e.g.
# ``allowed_users: ["group:admins"]``.
GROUP_PREFIX = "group:"
def expand_access_groups(
entries: Optional[Iterable[str]],
groups: Optional[Mapping[str, Iterable[str]]],
) -> List[str]:
"""Expand ``group:<name>`` references into concrete sender ids.
Lets an operator define a set of senders once (``access_groups:``) and
reference it from any channel's ``allowed_users``/``allowlist``/
``blocklist``, so editing or revoking membership happens in one place.
Groups may include other groups. Order is preserved and duplicates removed;
comma-separated strings (env-expanded ``${VAR}``) are split into ids,
whether given bare or as list items.
Raises:
ValueError: on an unknown group or a cycle, so a bad reference fails
closed at config load instead of silently widening/narrowing access.
"""
groups = groups or {}
out: List[str] = []
seen = set()
def _walk(items: Iterable[str], stack: tuple) -> None:
if isinstance(items, str):
items = (items,)
# Split every item on commas: an env-expanded value like "${ADMINS}"
# -> "111,222" may arrive bare or inside a list (["${ADMINS}"]).
for entry in (p.strip() for raw in items or () for p in str(raw).split(",")):
if not entry.startswith(GROUP_PREFIX):
if entry and entry not in seen:
seen.add(entry)
out.append(entry)
continue
name = entry[len(GROUP_PREFIX):].strip()
if name in stack:
chain = " -> ".join(stack + (name,))
raise ValueError(f"Access group cycle: {chain}")
if name not in groups:
raise ValueError(f"Unknown access group: {name!r}")
_walk(groups[name], stack + (name,))
_walk(entries or (), ())
return out
def resolve_ingress_admission(
*,
chat_type: Optional[str],
@@ -125,6 +125,13 @@ class BotConfig:
# Edit interval for streaming responses in milliseconds (default: 700ms)
stream_edit_interval_ms: int = 700
# Opt-in privacy-safe runtime footer on the final reply (default: False).
# When True, a short trailing line (e.g. ``— gpt-4o · 4.2s``) reporting the
# model and turn latency is appended to the final reply only. Privacy-safe:
# never surfaces tool args, URLs, paths or message content. Unavailable
# fields are omitted rather than shown as placeholders.
footer: bool = False
# Intentional silence support
allow_silence: bool = False
@@ -185,6 +192,7 @@ class BotConfig:
"owner_user_id": "***" if self.owner_user_id else None,
"streaming": self.streaming,
"stream_edit_interval_ms": self.stream_edit_interval_ms,
"footer": self.footer,
"allow_silence": self.allow_silence,
"silence_token": self.silence_token,
"allow_bots": self.allow_bots,
@@ -312,7 +312,14 @@ class CheckpointService:
"""Run a git command in the shadow repository."""
env = self._get_sanitized_env()
cmd = ["git", f"--git-dir={self.git_dir}", f"--work-tree={self.config.workspace_dir}"]
# Housekeeping is driven by _maybe_gc. Stop git from spawning detached
# `maintenance run --auto` after commits: it holds maintenance.lock in
# the shadow dir and races delete_all(). Passed per-command so shadow
# repos created by older versions are covered too.
cmd = [
"git", "-c", "maintenance.auto=false", "-c", "gc.autoDetach=false",
f"--git-dir={self.git_dir}", f"--work-tree={self.config.workspace_dir}",
]
cmd.extend(args)
process = await asyncio.create_subprocess_exec(
@@ -6,6 +6,7 @@ Manages context window by compacting messages when needed.
import re
import threading
import logging
from typing import List, Dict, Any, Optional, Callable, Awaitable, Tuple
import asyncio
@@ -14,6 +15,8 @@ from .strategy import CompactionStrategy
from .result import CompactionResult
from .protocols import ToolResultPrunerProtocol, MessageFormatterProtocol, SummaryBuilderProtocol
logger = logging.getLogger(__name__)
# Cache for the one-time offline-safe tiktoken probe (see estimate_tokens).
# None = not yet probed; True/False = probe result. Guarded by a lock so a
@@ -45,12 +48,11 @@ def _accurate_tokeniser_available() -> bool:
def _probe() -> None:
try:
from ..context.tokens import estimate_tokens_accurate
# Force real tokeniser init; heuristic fallback returns >0 too,
# but a network download would block here (bounded by the thread
# timeout below rather than hanging the caller).
estimate_tokens_accurate("probe", "gpt-4")
result.append(True)
from ..context.tokens import _get_tiktoken_encoder
# Force real tokeniser init; a network download would block
# here (bounded by the thread timeout below rather than
# hanging the caller). None means the load failed.
result.append(_get_tiktoken_encoder("gpt-4") is not None)
except Exception:
result.append(False)
@@ -337,18 +339,28 @@ class ContextCompactor:
compacted = self._prune(processed_messages)
elif self.strategy == CompactionStrategy.LLM_SUMMARIZE:
if self.llm_summarize_fn:
# For sync calls with LLM function, we need to run async
# Route through the canonical sync-to-async bridge so the
# user-configured LLM summariser runs identically whether or not
# a loop is already running (e.g. an agent hosted in FastAPI,
# aiohttp, Jupyter or a bot handler). The naive fallback is kept
# only for genuine failures, and is now logged so a silent
# degradation in context quality is visible.
#
# Keep the bridge's bounded default timeout rather than
# ``timeout=None`` so a summariser that never completes (a hung
# network call that ignores cancellation) falls back to the
# naive summary below instead of pinning the caller's thread
# indefinitely. ``TimeoutError`` is caught by the ``except`` here.
from ..utils.async_bridge import run_coroutine_from_any_context
try:
# Check if we're already in an async context
try:
loop = asyncio.get_running_loop()
# If in async context, fallback to naive summarization
compacted = self._summarize(processed_messages)
except RuntimeError:
# No running loop, safe to create one
compacted = asyncio.run(self._llm_summarize_async(processed_messages, focus_topic))
compacted = run_coroutine_from_any_context(
self._llm_summarize_async(processed_messages, focus_topic),
)
except Exception:
# Fallback to naive summarization if async fails
logger.warning(
"LLM summarisation failed; using naive summary",
exc_info=True,
)
compacted = self._summarize(processed_messages)
else:
compacted = self._llm_summarize(processed_messages, focus_topic)
@@ -561,6 +573,63 @@ class ContextCompactor:
return cut_index
def shrink_to_fit(
self, messages: List[Dict[str, Any]], target_tokens: int
) -> List[Dict[str, Any]]:
"""Shrink ``messages`` toward ``target_tokens`` after a provider overflow.
Reactive counterpart to ``compact``: used once a provider has rejected
a request as too long. It never drops the user message that opened
the in-flight turn or anything after it (its tool calls and results). It first
drops the oldest turns before that message, snapped so tool-call pairs
stay together, then trims oversized tool results to head + tail.
Returns a new list; the input is not mutated.
"""
system = [m for m in messages if m.get("role") == "system"]
rest = [m for m in messages if m.get("role") != "system"]
anchor = max(len(rest) - 1, 0)
# Tool loops add their own user messages (media follow-ups, steering,
# repair prompts) after tool results or other user messages. The turn
# opens at the latest user message that follows a plain assistant
# reply (or nothing), so walk back past the synthesized ones.
for i in reversed(range(len(rest))):
if rest[i].get("role") != "user":
continue
anchor = i
prev = rest[i - 1] if i else None
if prev is None or (prev.get("role") == "assistant" and not prev.get("tool_calls")):
break
total = self.count_total_tokens(system + rest)
cut = 0
while cut < anchor and total > target_tokens:
total -= self.count_message_tokens(rest[cut])
cut += 1
kept = rest[self._snap_to_pair_boundary(rest, cut):]
if self.count_total_tokens(system + kept) > target_tokens:
n_tools = sum(1 for m in kept if m.get("role") == "tool")
# ~4 chars/token: tool results share roughly half the target budget
cap = max(500, target_tokens * 2 // max(n_tools, 1))
trimmed = []
for m in kept:
content = m.get("content")
if m.get("role") == "tool" and isinstance(content, str) and len(content) > cap:
marker = (
f"\n...[truncated {len(content):,} chars to fit the "
f"context window]...\n"
)
# Keep the marker inside the cap so the result never grows.
keep = max(cap - len(marker), 0)
head, tail = content[: keep * 2 // 3], content[len(content) - keep // 3:]
shortened = f"{head}{marker}{tail}"
if len(shortened) < len(content):
m = {**m, "content": shortened}
trimmed.append(m)
kept = trimmed
return system + kept
def preview_older_slice(
self, messages: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
@@ -295,6 +295,29 @@ class MemoryConfig:
def __post_init__(self):
# Reject a backend we cannot provide instead of substituting another.
self.backend = validate_memory_backend(self.backend)
# Fail fast on a non-adapter ``db``. The memory bridge drives this object
# through the ``DbAdapter`` lifecycle hooks
# (``on_agent_start``/``on_user_message``/``on_agent_message``). A bare
# path string or other non-adapter used to flow through, where each hook
# raised ``'str' object has no attribute 'on_agent_start'`` and was
# swallowed as a warning -- memory looked enabled while every message was
# silently dropped (Issue #5668). A valid ``db`` is a DbAdapter or a
# ``db(...)`` backend instance; both implement these hooks. Require the
# hooks themselves (callable), not a weaker signal like ``database_url``
# that a half-built object could expose while still dropping messages.
if self.db is not None and not all(
callable(getattr(self.db, hook, None))
for hook in ("on_agent_start", "on_user_message", "on_agent_message")
):
raise TypeError(
"MemoryConfig.db must be a DbAdapter instance (e.g. db(...) / "
"db.SQLiteDB(path=...)) implementing the on_agent_start/"
"on_user_message/on_agent_message hooks, not "
f"{type(self.db).__name__}. For a SQLite file use "
'db(database_url="sqlite:///path/to.db") or '
'MemoryConfig(backend="sqlite", config={"short_db": "short.db", '
'"long_db": "long.db"}).'
)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -949,6 +972,11 @@ class ExecutionConfig:
# and reject a conflicting ToolConfig(parallel=True) instead of silently winning.
parallel_tool_calls: Optional[bool] = None
# Auto-continue a final answer cut off by the output-token limit
# (finish_reason="length"): True = up to 3 continuations (default),
# False/0 = disabled (surface ``length_truncated`` immediately), int = cap.
output_continuation: Union[bool, int] = True
# Durable tool-loop replay. Default-off keeps the execution hot path free
# of journal construction and SQLite writes.
durable: bool = False
@@ -1078,6 +1106,7 @@ class ExecutionConfig:
"compaction_strategy": self.compaction_strategy.value if self.compaction_strategy and hasattr(self.compaction_strategy, 'value') else (str(self.compaction_strategy) if self.compaction_strategy else None),
"max_budget": self.max_budget,
"parallel_tool_calls": self.parallel_tool_calls,
"output_continuation": self.output_continuation,
"durable": self.durable,
"journal_path": self.journal_path,
"resume_run_id": self.resume_run_id,
@@ -1123,6 +1152,7 @@ class ExecutionConfig:
compaction_strategy=compaction_strategy,
max_budget=data.get("max_budget", None),
parallel_tool_calls=data.get("parallel_tool_calls", None),
output_continuation=data.get("output_continuation", True),
durable=data.get("durable", False),
journal_path=data.get("journal_path", None),
resume_run_id=data.get("resume_run_id", None),
@@ -1818,30 +1848,6 @@ def resolve_autonomy(value: AutonomyParam) -> Optional["AutonomyConfig"]:
return _resolve(value, AutonomyConfig)
def resolve_tool_search(value: ToolSearchParam) -> Optional["ToolSearchConfig"]:
"""
Resolve tool_search= parameter following precedence ladder.
NOTE: This resolver has zero references in the codebase but is kept
for backward compatibility. Consider removing in a future version.
"""
# Simple implementation since it's unused
if value is None or value is False:
return None
# Lazy-load ToolSearchConfig only when tool search is actually configured,
# so the tools subsystem is not imported on the `import Agent` path.
ToolSearchConfig = _resolve_tool_search_config()
if value is True:
return ToolSearchConfig()
if isinstance(value, str):
return ToolSearchConfig(enabled=value)
if isinstance(value, dict):
return ToolSearchConfig(**value)
if isinstance(value, ToolSearchConfig):
return value
return value
def resolve_tools(value: ToolParam) -> Optional[ToolConfig]:
"""Resolve tools= parameter following precedence ladder."""
if value is None or value is False:
@@ -2062,7 +2068,6 @@ __all__ = [
"resolve_web",
"resolve_caching",
"resolve_autonomy",
"resolve_tool_search",
"resolve_tools",
"resolve_runtime",
"canonical_runtime_name",
@@ -169,6 +169,18 @@ def resolve(
# Array Resolution
# =============================================================================
def _match_preset_key(value: str, presets: Dict[str, Any]) -> Optional[str]:
"""Canonical preset lookup for the array forms (``[preset]`` /
``[preset, {overrides}]``), mirroring the string form in ``_resolve_string``
and the closed-set guard in ``validate_preset_string``: case-insensitive,
whitespace-tolerant, ``-``/``_`` interchangeable.
"""
from .parse_utils import canonical_preset_key
key = canonical_preset_key(value)
return next((k for k in presets if canonical_preset_key(k) == key), None)
def _resolve_array(
value: Union[list, tuple],
param_name: str,
@@ -231,10 +243,26 @@ def _resolve_array(
if scheme and scheme in url_schemes:
return _resolve_url(single_value, config_class, url_schemes)
# Check if single item is a preset
if isinstance(single_value, str) and presets and single_value in presets:
return _apply_preset(single_value, presets, config_class)
matched = (
_match_preset_key(single_value, presets)
if isinstance(single_value, str) and presets
else None
)
if matched:
return _apply_preset(matched, presets, config_class)
# Single non-preset string - try as URL or return as-is
if isinstance(single_value, str):
# Mirror the string branch: an unsupported scheme must raise
# rather than fall through to config_class(). For memory= that
# default is the local file backend, so ["redis://..."] used to
# silently store to a file the caller never asked for.
scheme = detect_url_scheme(single_value)
if scheme and url_schemes and scheme not in url_schemes:
valid_schemes = ", ".join(sorted(url_schemes.keys()))
raise ValueError(
f"Unsupported URL scheme '{scheme}' for {param_name}. "
f"Supported: {valid_schemes}"
)
if config_class:
return config_class()
return single_value
@@ -254,9 +282,10 @@ def _resolve_array(
# First item should be a preset string
if isinstance(first, str):
matched = _match_preset_key(first, presets) if presets else None
# Get base config from preset
if presets and first in presets:
base_config = _apply_preset(first, presets, config_class)
if matched:
base_config = _apply_preset(matched, presets, config_class)
elif presets:
# Invalid preset
raise make_preset_error(param_name, first, presets.keys())
@@ -292,26 +292,13 @@ class ContextAggregator:
Returns:
AggregatedContext with merged results
"""
try:
# Check if we're in an async context
try:
asyncio.get_running_loop()
# We're in an async context - need to run in thread
import concurrent.futures
def _run_async_in_thread():
"""Run async method in new event loop within thread."""
return asyncio.run(self.aggregate(query, sources, max_tokens))
with concurrent.futures.ThreadPoolExecutor() as pool:
future = pool.submit(_run_async_in_thread)
return future.result()
except RuntimeError:
# No running event loop - safe to use asyncio.run
return asyncio.run(self.aggregate(query, sources, max_tokens))
except Exception:
# Fallback - create new event loop
return asyncio.run(self.aggregate(query, sources, max_tokens))
# Shared bridge: copies contextvars and surfaces errors unchanged,
# whether or not an event loop is already running on this thread.
from ..utils.async_bridge import run_coroutine_from_any_context
return run_coroutine_from_any_context(
self.aggregate(query, sources, max_tokens), timeout=None
)
def create_aggregator_from_config(
memory = None,
@@ -84,6 +84,8 @@ class ContextCompressor:
)
"""
_MAX_SESSION_HISTORY = 100
def __init__(
self,
llm: Optional[LLMProviderProtocol] = None,
@@ -190,6 +192,8 @@ class ContextCompressor:
summary_text=summary_text,
)
self._session_history.append(compression_session)
# Bounded so reused compressors don't retain every summary forever
del self._session_history[:-self._MAX_SESSION_HISTORY]
return CompressResult(
messages=compressed_messages,
@@ -200,22 +200,11 @@ class ParallelExecutor:
Returns:
List of results
"""
try:
# Try to get the running loop - if this succeeds, we're in async context
loop = asyncio.get_running_loop()
# If already in async context, create new loop
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as pool:
future = pool.submit(asyncio.run, self.execute(tasks))
return future.result()
except RuntimeError:
# No running loop, safe to create/get one and run
try:
loop = asyncio.get_event_loop()
return loop.run_until_complete(self.execute(tasks))
except RuntimeError:
# No event loop, create one
return asyncio.run(self.execute(tasks))
# Shared bridge: copies contextvars and surfaces errors unchanged,
# whether or not an event loop is already running on this thread.
from ...utils.async_bridge import run_coroutine_from_any_context
return run_coroutine_from_any_context(self.execute(tasks), timeout=None)
def close(self) -> None:
"""Close the executor and release resources."""
@@ -308,20 +297,13 @@ class ParallelSearchCoordinator:
Returns:
List of results
"""
try:
# Try to get the running loop - if this succeeds, we're in async context
loop = asyncio.get_running_loop()
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as pool:
future = pool.submit(asyncio.run, self.execute_turn(batch))
return future.result()
except RuntimeError:
# No running loop, safe to create/get one and run
try:
loop = asyncio.get_event_loop()
return loop.run_until_complete(self.execute_turn(batch))
except RuntimeError:
return asyncio.run(self.execute_turn(batch))
# Shared bridge: copies contextvars and surfaces errors unchanged,
# whether or not an event loop is already running on this thread.
from ...utils.async_bridge import run_coroutine_from_any_context
return run_coroutine_from_any_context(
self.execute_turn(batch), timeout=None
)
def close(self) -> None:
"""Close resources."""
@@ -673,17 +673,17 @@ class LLMContextCompressorOptimizer(BaseOptimizer):
try:
# Use configured compressor with LLM (if available)
import asyncio
from ..utils.async_bridge import run_coroutine_from_any_context
try:
# Try async compression with configured LLM
result = asyncio.run(self.compressor.compress(
messages=messages,
target_tokens=target_tokens,
# Works both with and without a running event loop
result = run_coroutine_from_any_context(self.compressor.compress(
messages,
summary_target_tokens=self.summary_target_tokens,
protect_last_n_tokens=self.protect_last_n_tokens,
))
except:
# Fallback to sync compression if async fails
except Exception as e:
# Fallback to deterministic compression if LLM compression fails
logger.warning("LLM compression failed, using deterministic fallback: %s", e)
result = self._sync_compress(messages, target_tokens)
optimized_tokens = estimate_messages_tokens(result.messages)
@@ -4,6 +4,7 @@ Token Estimation for PraisonAI Agents.
Provides fast, offline token estimation with optional accurate tokenizer support.
"""
from functools import lru_cache
from typing import List, Dict, Any, Optional
import json
@@ -31,17 +32,40 @@ def estimate_tokens_heuristic(text: str) -> int:
if not text:
return 0
total = 0.0
for char in text:
code_point = ord(char)
if code_point <= 127:
total += ASCII_TOKENS_PER_CHAR
else:
total += NON_ASCII_TOKENS_PER_CHAR
# C-speed counting instead of a per-character Python loop
if text.isascii():
return max(1, int(len(text) * ASCII_TOKENS_PER_CHAR))
ascii_count = len(text.encode("ascii", "ignore"))
total = (ascii_count * ASCII_TOKENS_PER_CHAR
+ (len(text) - ascii_count) * NON_ASCII_TOKENS_PER_CHAR)
return max(1, int(total))
def _get_tiktoken_encoder(model: str):
"""Return a cached tiktoken encoder for ``model``, or None if unavailable.
Failures (tiktoken missing, vocab download blocked) are negatively cached
so we never retry a failing load on every message.
"""
cache_key = model.lower()
if cache_key in _tokenizer_cache:
return _tokenizer_cache[cache_key]
enc = None
try:
import tiktoken
try:
enc = tiktoken.encoding_for_model(model)
except Exception:
# Unknown model (or failed load): cl100k_base is a close match for
# GPT-4/3.5/Claude-style tokenisation
enc = tiktoken.get_encoding("cl100k_base")
except Exception:
enc = None
_tokenizer_cache[cache_key] = enc
return enc
def estimate_tokens_accurate(text: str, model: str = "gpt-4") -> int:
"""
Estimate tokens using tiktoken if available, else fallback to heuristic.
@@ -53,33 +77,23 @@ def estimate_tokens_accurate(text: str, model: str = "gpt-4") -> int:
Returns:
Token count
"""
try:
import tiktoken
# Cache tokenizer with improved model mapping
cache_key = model.lower()
if cache_key not in _tokenizer_cache:
try:
_tokenizer_cache[cache_key] = tiktoken.encoding_for_model(model)
except KeyError:
# Enhanced fallback mapping for common models
if any(model_name in model.lower() for model_name in
["gpt-4", "gpt-4o", "gpt-4-turbo"]):
_tokenizer_cache[cache_key] = tiktoken.get_encoding("cl100k_base")
elif any(model_name in model.lower() for model_name in
["gpt-3.5", "text-davinci", "text-curie"]):
_tokenizer_cache[cache_key] = tiktoken.get_encoding("cl100k_base")
elif "claude" in model.lower():
# Claude models use similar tokenization to GPT-4
_tokenizer_cache[cache_key] = tiktoken.get_encoding("cl100k_base")
else:
# Ultimate fallback
_tokenizer_cache[cache_key] = tiktoken.get_encoding("cl100k_base")
enc = _tokenizer_cache[cache_key]
return len(enc.encode(text))
except ImportError:
if _get_tiktoken_encoder(model) is None:
return estimate_tokens_heuristic(text)
if len(text) > _COUNT_CACHE_MAX_CHARS:
# Don't pin large tool outputs in the cache
return _count_tiktoken_tokens.__wrapped__(text, model)
return _count_tiktoken_tokens(text, model)
# Bounds the memo to ~16M chars so it can't retain unbounded text
_COUNT_CACHE_MAX_CHARS = 16_384
@lru_cache(maxsize=1024)
def _count_tiktoken_tokens(text: str, model: str) -> int:
# History messages are re-counted several times per turn; memoise them.
try:
return len(_get_tiktoken_encoder(model).encode(text))
except Exception:
# Fallback on any tokenization error
return estimate_tokens_heuristic(text)
@@ -0,0 +1,39 @@
"""Jev / System One typed decision models (Ollama ``/v1/systemone``, TypeSafe API)."""
from .routing import (
DecisionRoutePlan,
DecisionTriageRouter,
default_ticket_triage_questions,
triage_decision,
triaged_start,
)
from .system_one import (
KNOWN_DECISION_MODELS,
SystemOneResult,
asystem_one,
choice_question,
is_decision_model,
noul_question,
resolve_system_one_base_url,
score_question,
system_one,
system_one_via_typesafe_sdk,
)
__all__ = [
"DecisionRoutePlan",
"DecisionTriageRouter",
"default_ticket_triage_questions",
"triage_decision",
"triaged_start",
"KNOWN_DECISION_MODELS",
"SystemOneResult",
"asystem_one",
"choice_question",
"is_decision_model",
"noul_question",
"resolve_system_one_base_url",
"score_question",
"system_one",
"system_one_via_typesafe_sdk",
]
@@ -0,0 +1,168 @@
"""Route user input through System One decision models before chat agents."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Any, Callable, Mapping, Optional, Protocol, Union
from .system_one import (
SystemOneResult,
choice_question,
system_one,
)
class AgentLike(Protocol):
def start(self, prompt: str, **kwargs: Any) -> Any: ...
RouteTarget = Union[AgentLike, str, Callable[[SystemOneResult], Any]]
@dataclass
class DecisionRoutePlan:
"""Configuration for decision-first routing."""
route_question: str = "team"
decision_model: str = field(
default_factory=lambda: os.environ.get("OLLAMA_DECISION_MODEL", "nimble")
)
api_base: Optional[str] = None
state_key: str = "message"
min_confidence: Optional[float] = None
questions: Mapping[str, Mapping[str, Any]] = field(default_factory=dict)
routes: Mapping[str, RouteTarget] = field(default_factory=dict)
fallback_route: Optional[str] = None
def __post_init__(self) -> None:
if not self.questions:
raise ValueError("DecisionRoutePlan requires a non-empty questions mapping")
if self.route_question not in self.questions:
raise ValueError(
f"route_question {self.route_question!r} missing from questions keys "
f"{list(self.questions.keys())}"
)
route_q = self.questions[self.route_question]
if route_q.get("type") != "choice":
raise ValueError(
f"route_question {self.route_question!r} must be a choice question, "
f"got type {route_q.get('type')!r}"
)
def default_ticket_triage_questions() -> dict[str, dict[str, Any]]:
"""Preset for support ticket routing (Ollama blog example)."""
return {
"team": choice_question(
"Which team should handle this ticket?",
{
"billing": "Payments and refunds",
"technical": "Bugs and integrations",
"other": "None of the above",
},
),
"refund": {
"type": "noul",
"instructions": "Does the customer explicitly ask for a refund?",
},
"urgency": {
"type": "score",
"instructions": "How urgent is this ticket?",
"criteria": ["Routine", "Soon", "Urgent"],
},
}
def triage_decision(
message: str,
*,
questions: Mapping[str, Mapping[str, Any]],
decision_model: str = "nimble",
api_base: Optional[str] = None,
state_key: str = "message",
extra_state: Optional[Mapping[str, Any]] = None,
) -> SystemOneResult:
"""Run System One on *message* without invoking a chat LLM."""
state = dict(extra_state or {})
state[state_key] = message
return system_one(
state=state,
questions=questions,
model=decision_model,
api_base=api_base,
)
def resolve_route_key(result: SystemOneResult, route_question: str) -> Optional[str]:
return result.choice(route_question)
def _invoke_target(target: RouteTarget, prompt: str, decision: SystemOneResult) -> Any:
if isinstance(target, str):
from praisonaiagents import Agent
return Agent(instructions="You are a helpful assistant.", llm=target).start(prompt)
if callable(target):
return target(decision)
return target.start(prompt)
def triaged_start(
prompt: str,
plan: DecisionRoutePlan,
*,
extra_state: Optional[Mapping[str, Any]] = None,
) -> dict[str, Any]:
"""
Triage with a decision model, then delegate to the mapped chat agent/model.
Returns a dict with ``decision``, ``route``, ``confidence``, and ``response``.
"""
decision = triage_decision(
prompt,
questions=plan.questions,
decision_model=plan.decision_model,
api_base=plan.api_base,
state_key=plan.state_key,
extra_state=extra_state,
)
route_key = resolve_route_key(decision, plan.route_question)
conf = decision.confidence(plan.route_question)
if plan.min_confidence is not None and (conf is None or conf < plan.min_confidence):
route_key = plan.fallback_route or route_key
if not route_key or route_key not in plan.routes:
route_key = plan.fallback_route or next(iter(plan.routes), None)
if not route_key or route_key not in plan.routes:
raise RuntimeError(
f"No route for decision choice {route_key!r}; "
f"available routes: {list(plan.routes.keys())}"
)
target = plan.routes[route_key]
response = _invoke_target(target, prompt, decision)
return {
"decision": decision,
"route": route_key,
"confidence": conf,
"response": response,
}
class DecisionTriageRouter:
"""Object-oriented wrapper for repeated triage + chat routing."""
def __init__(self, plan: DecisionRoutePlan):
self.plan = plan
def decide(self, message: str, **extra_state: Any) -> SystemOneResult:
return triage_decision(
message,
questions=self.plan.questions,
decision_model=self.plan.decision_model,
api_base=self.plan.api_base,
state_key=self.plan.state_key,
extra_state=extra_state or None,
)
def start(self, prompt: str, **extra_state: Any) -> dict[str, Any]:
return triaged_start(prompt, self.plan, extra_state=extra_state or None)
@@ -0,0 +1,289 @@
"""Call Ollama / TypeSafe System One decision endpoint (``POST /v1/systemone``).
See: https://ollama.com/blog/ollama-now-supports-jev-style-decision-models
"""
from __future__ import annotations
import ipaddress
import json
import os
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from typing import Any, Mapping, MutableMapping, Optional
from urllib.parse import urlparse
KNOWN_DECISION_MODELS = frozenset({"nimble", "tev1", "tev1:0.8b"})
def is_decision_model(model: str) -> bool:
"""Return True if *model* looks like a known Ollama decision model id."""
name = (model or "").strip().split("/")[-1]
if name in KNOWN_DECISION_MODELS:
return True
return name.startswith("tev1")
def _base_source(api_base: Optional[str] = None) -> tuple[str, Optional[str]]:
"""Return ``(base_url, source)`` where *source* names the env var selected.
*source* is ``None`` when ``api_base`` is explicit, ``"TYPESAFE_BASE_URL"`` or
``"OLLAMA_HOST"`` when resolved from the environment, or ``"default"`` for the
loopback fallback. The source lets callers scope credentials to the host that
was actually chosen (so a TypeSafe key is never sent to an Ollama host).
"""
if api_base:
return api_base.rstrip("/"), None
for key in ("TYPESAFE_BASE_URL", "OLLAMA_HOST"):
raw = (os.environ.get(key) or "").strip()
if raw:
from praisonaiagents.local.target import parse_ollama_host
parsed = parse_ollama_host(raw) if not raw.startswith("http") else raw.rstrip("/")
return (parsed or raw).rstrip("/"), key
return "http://127.0.0.1:11434", "default"
def resolve_system_one_base_url(api_base: Optional[str] = None) -> str:
"""Resolve base URL for ``/v1/systemone`` (Ollama 0.35+ or TypeSafe cloud)."""
return _base_source(api_base)[0]
def _is_loopback_host(host: Optional[str]) -> bool:
if not host:
return False
if host.lower() == "localhost":
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def _validate_base_url(base: str) -> None:
"""Reject non-HTTP(S) schemes and plain HTTP to non-loopback hosts."""
parsed = urlparse(base)
if parsed.scheme not in {"http", "https"}:
raise ValueError(f"system_one requires an http(s) base URL, got {base!r}")
if parsed.scheme == "http" and not _is_loopback_host(parsed.hostname):
raise ValueError(
f"system_one refuses plain HTTP for non-loopback host {parsed.hostname!r}; use https"
)
def choice_question(instructions: str, criteria: Mapping[str, str]) -> dict[str, Any]:
return {"type": "choice", "instructions": instructions, "criteria": dict(criteria)}
def noul_question(instructions: str) -> dict[str, Any]:
return {"type": "noul", "instructions": instructions}
def score_question(instructions: str, criteria: list[str]) -> dict[str, Any]:
return {"type": "score", "instructions": instructions, "criteria": list(criteria)}
@dataclass
class SystemOneResult:
"""Parsed ``/v1/systemone`` response."""
model: str
answers: dict[str, Any]
usage: dict[str, Any] = field(default_factory=dict)
raw: dict[str, Any] = field(default_factory=dict)
def choice(self, name: str) -> Optional[str]:
entry = self.answers.get(name) or {}
if entry.get("type") == "choice":
return entry.get("choice")
return None
def noul(self, name: str) -> Optional[float]:
entry = self.answers.get(name) or {}
if entry.get("type") == "noul":
val = entry.get("noul")
return float(val) if val is not None else None
return None
def score(self, name: str) -> Optional[float]:
entry = self.answers.get(name) or {}
if entry.get("type") == "score":
val = entry.get("score")
return float(val) if val is not None else None
return None
def confidence(self, name: str) -> Optional[float]:
entry = self.answers.get(name) or {}
val = entry.get("confidence")
return float(val) if val is not None else None
def _default_timeout(explicit: Optional[float]) -> float:
if explicit is not None:
return explicit
raw = (os.environ.get("SYSTEM_ONE_TIMEOUT") or os.environ.get("OLLAMA_SYSTEM_ONE_TIMEOUT") or "").strip()
if raw:
try:
return float(raw)
except ValueError:
pass
return 120.0
def _default_api_key(api_key: Optional[str], *, allow_typesafe_key: bool) -> Optional[str]:
"""Resolve the bearer key, scoping ``TYPESAFE_API_KEY`` to TypeSafe hosts only."""
if api_key is not None:
return api_key
if allow_typesafe_key and os.environ.get("TYPESAFE_API_KEY"):
return os.environ["TYPESAFE_API_KEY"]
return os.environ.get("OLLAMA_API_KEY") or "ollama"
def _build_payload(
*,
model: str,
state: Mapping[str, Any],
questions: Mapping[str, Mapping[str, Any]],
) -> dict[str, Any]:
return {
"model": model,
"state": dict(state),
"questions": {k: dict(v) for k, v in questions.items()},
}
def _parse_response(data: dict[str, Any]) -> SystemOneResult:
return SystemOneResult(
model=str(data.get("model") or ""),
answers=dict(data.get("answers") or {}),
usage=dict(data.get("usage") or {}),
raw=data,
)
def system_one(
*,
state: Mapping[str, Any],
questions: Mapping[str, Mapping[str, Any]],
model: Optional[str] = None,
api_base: Optional[str] = None,
api_key: Optional[str] = None,
timeout: Optional[float] = None,
) -> SystemOneResult:
"""
Run a System One decision request against Ollama or TypeSafe.
Example::
from praisonaiagents.decisions import system_one, choice_question, noul_question
result = system_one(
state={"ticket": "Please refund my duplicate charge."},
questions={
"team": choice_question(
"Which team?",
{"billing": "Payments", "technical": "Bugs", "other": "Other"},
),
"refund": noul_question("Does the user ask for a refund?"),
},
model="nimble",
)
assert result.choice("team") == "billing"
"""
resolved_model = (
model
or os.environ.get("TYPESAFE_DEFAULT_MODEL")
or os.environ.get("OLLAMA_DECISION_MODEL")
or "nimble"
)
base, source = _base_source(api_base)
_validate_base_url(base)
url = f"{base}/v1/systemone"
body = json.dumps(_build_payload(model=resolved_model, state=state, questions=questions)).encode("utf-8")
headers = {"Content-Type": "application/json", "Accept": "application/json"}
key = _default_api_key(api_key, allow_typesafe_key=source == "TYPESAFE_BASE_URL")
if key:
headers["Authorization"] = f"Bearer {key}"
timeout = _default_timeout(timeout)
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:2000]
raise RuntimeError(f"system_one HTTP {exc.code}: {detail}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(
f"system_one failed to reach {url!r}. Is Ollama 0.35+ running with a decision model pulled?"
) from exc
if not isinstance(data, dict):
raise RuntimeError(f"system_one expected JSON object, got {type(data).__name__}")
return _parse_response(data)
async def asystem_one(
*,
state: Mapping[str, Any],
questions: Mapping[str, Mapping[str, Any]],
model: Optional[str] = None,
api_base: Optional[str] = None,
api_key: Optional[str] = None,
timeout: Optional[float] = None,
) -> SystemOneResult:
"""Async ``system_one`` using aiohttp (already a core dependency)."""
import aiohttp
resolved_model = (
model
or os.environ.get("TYPESAFE_DEFAULT_MODEL")
or os.environ.get("OLLAMA_DECISION_MODEL")
or "nimble"
)
base, source = _base_source(api_base)
_validate_base_url(base)
url = f"{base}/v1/systemone"
payload = _build_payload(model=resolved_model, state=state, questions=questions)
headers: MutableMapping[str, str] = {"Content-Type": "application/json", "Accept": "application/json"}
key = _default_api_key(api_key, allow_typesafe_key=source == "TYPESAFE_BASE_URL")
if key:
headers["Authorization"] = f"Bearer {key}"
timeout_obj = aiohttp.ClientTimeout(total=_default_timeout(timeout))
async with aiohttp.ClientSession(timeout=timeout_obj) as session:
async with session.post(url, json=payload, headers=headers) as resp:
text = await resp.text()
if resp.status >= 400:
raise RuntimeError(f"system_one HTTP {resp.status}: {text[:2000]}")
data = json.loads(text)
if not isinstance(data, dict):
raise RuntimeError(f"system_one expected JSON object, got {type(data).__name__}")
return _parse_response(data)
def system_one_via_typesafe_sdk(
*,
state: Mapping[str, Any],
questions: Mapping[str, Any],
model: Optional[str] = None,
timeout: float = 120.0,
) -> SystemOneResult:
"""Optional path through the official ``typesafe-sdk`` (``pip install typesafe-sdk``)."""
try:
from typesafe_sdk import TypeSafeClient
except ImportError as exc:
raise ImportError(
"typesafe-sdk is not installed. Use: pip install typesafe-sdk "
"or call system_one() which uses HTTP directly."
) from exc
with TypeSafeClient(timeout=timeout) as client:
result = client.system_one(state=dict(state), questions=dict(questions), model=model)
answers: dict[str, Any] = {}
for key, val in getattr(result, "answers", {}).items():
answers[key] = val if isinstance(val, dict) else getattr(val, "__dict__", {"value": val})
return SystemOneResult(
model=str(getattr(result, "model", model or "")),
answers=answers,
usage=dict(getattr(result, "usage", {}) or {}),
raw={"via": "typesafe-sdk"},
)
@@ -467,21 +467,12 @@ def run_trials(
capture_record: bool = True,
) -> TrialReport:
"""Sync wrapper around :func:`arun_trials` (see it for arguments)."""
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(
arun_trials(agent, package, k=k, concurrency=concurrency,
capture_record=capture_record)
)
# Already inside an event loop: run on a dedicated loop in a worker thread.
import concurrent.futures
# Shared bridge: copies contextvars and surfaces errors unchanged, whether
# or not an event loop is already running on this thread.
from ..utils.async_bridge import run_coroutine_from_any_context
def _runner() -> TrialReport:
return asyncio.run(
arun_trials(agent, package, k=k, concurrency=concurrency,
capture_record=capture_record)
)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(_runner).result()
return run_coroutine_from_any_context(
arun_trials(agent, package, k=k, concurrency=concurrency,
capture_record=capture_record),
timeout=None,
)
@@ -283,6 +283,7 @@ _LAZY_IMPORTS = {
# Config version stamp + doctor-driven migration (Issue #3841)
"GATEWAY_CONFIG_VERSION": ("praisonaiagents.gateway.config", "GATEWAY_CONFIG_VERSION"),
"ConfigVersionError": ("praisonaiagents.gateway.config", "ConfigVersionError"),
"ConfigValidationError": ("praisonaiagents.gateway.config", "ConfigValidationError"),
"LegacyConfigRule": ("praisonaiagents.gateway.config", "LegacyConfigRule"),
"GATEWAY_CONFIG_RULES": ("praisonaiagents.gateway.config", "GATEWAY_CONFIG_RULES"),
"is_config_current": ("praisonaiagents.gateway.config", "is_config_current"),
@@ -639,6 +640,7 @@ __all__ = [
# Config version stamp + doctor-driven migration (Issue #3841)
"GATEWAY_CONFIG_VERSION",
"ConfigVersionError",
"ConfigValidationError",
"LegacyConfigRule",
"GATEWAY_CONFIG_RULES",
"is_config_current",
@@ -1,56 +1,201 @@
"""
Adapter to wrap a RecipeRuntime behind the standard Agent duck-typing for Gateway and BotOS.
Adapter to wrap recipe execution behind the standard Agent duck-typing for Gateway and BotOS.
Streaming source lives in the wrapper (``praisonai.recipe`` — ``run``/``run_stream``);
this core adapter is only a thin bridge that forwards incremental recipe events into
the already-present :class:`StreamEventEmitter` so recipe/YAML-backed gateway agents
reach parity with Python ``Agent``-backed ones (token drafts, status phrases, SSE).
"""
import asyncio
import logging
from typing import Optional, Any
from typing import Any, Optional
from praisonaiagents.streaming import StreamEventEmitter
logger = logging.getLogger(__name__)
class RecipeBotAdapter:
"""Wraps RecipeRuntime to act like an Agent for gateway/bot compatibility."""
"""Wraps recipe execution to act like an Agent for gateway/bot compatibility."""
def __init__(self, recipe_name: str, **kwargs):
self.recipe_name = recipe_name
self.config = kwargs
self._runtime = None
self.stream_emitter = StreamEventEmitter()
@property
def name(self) -> str:
return f"recipe-{self.recipe_name}"
def _get_runtime(self):
"""Lazy load and instantiate RecipeRuntime."""
if self._runtime is None:
try:
from praisonai.recipe.core import RecipeRuntime
self._runtime = RecipeRuntime(recipe_name=self.recipe_name, **self.config)
except ImportError as e:
raise ImportError(
f"Failed to load PraisonAI recipe components: {e}. "
"Make sure you have praisonai installed."
) from e
return self._runtime
def chat(self, message: str) -> str:
"""Process the message via the recipe runtime."""
def _recipe_module(self):
"""Lazy import of the wrapper recipe API (heavy implementation stays in praisonai)."""
try:
runtime = self._get_runtime()
# Forward the user_input to the recipe's variables
variables = runtime.context.variables if hasattr(runtime, 'context') else {}
variables["user_input"] = message
# Currently we rely on string return matching standard Agent
# Note: We don't have direct streaming from recipe to emitter yet; this binds them generically.
result = runtime.run()
if hasattr(result, "final_output"):
return str(result.final_output)
return str(result)
from praisonai import recipe
return recipe
except ImportError as e:
raise ImportError(
f"Failed to load PraisonAI recipe components: {e}. "
"Make sure you have praisonai installed."
) from e
@staticmethod
def _extract_text(output: Any) -> str:
"""Best-effort render of a recipe output payload to a single string."""
if output is None:
return ""
if isinstance(output, str):
return output
if hasattr(output, "final_output"):
return str(output.final_output)
if isinstance(output, dict):
for key in ("reply", "output", "message", "text", "result"):
if key in output:
return str(output[key])
return str(output)
def chat(self, message: str) -> str:
"""Process the message via the recipe runtime (blocking, string-only)."""
try:
recipe = self._recipe_module()
result = recipe.run(self.recipe_name, input={"user_input": message}, config=self.config or None)
if getattr(result, "ok", False):
return self._extract_text(result.output)
error = getattr(result, "error", None) or "Recipe execution failed"
raise RuntimeError(error)
except Exception as e:
logger.error(f"Recipe execution failed: {e}")
from ...bots.failure import render_failure_reply
return render_failure_reply(e).text
async def astart(
self,
message: str,
stream: bool = False,
cancel_token: Any = None,
**kwargs,
) -> str:
"""Async surface used by the gateway/bot streaming hot path.
Forwards the recipe's incremental events into ``self.stream_emitter`` so the
bot DraftStreamer / OpenAI-compatible SSE surface receive progressive output,
then returns the final text. Degrades safely to a single final send when the
recipe runtime cannot produce incremental output.
``cancel_token`` is accepted explicitly (rather than being swallowed by
``**kwargs``) so a cancelled/timed-out turn stops the recipe worker
cooperatively instead of leaving it running and emitting after the turn
ended.
"""
return await self.achat(message, cancel_token=cancel_token)
async def achat(self, message: str, cancel_token: Any = None) -> str:
"""Stream recipe execution into ``self.stream_emitter`` and return final text.
Events are produced by the recipe runtime on a worker thread
(``run_in_executor``), but are marshalled back onto this event loop via
``call_soon_threadsafe`` before hitting ``stream_emitter``. That keeps the
emission on the loop thread — matching the Python ``Agent`` streaming
contract — so bot draft callbacks that schedule coroutines with
``asyncio.get_running_loop()`` and SSE callbacks that read turn-local
context both observe recipe events exactly as they do for Agent runs.
"""
from praisonaiagents.streaming import StreamEvent, StreamEventType
try:
recipe = self._recipe_module()
except Exception as e: # pragma: no cover - import guard
logger.error(f"Recipe execution failed: {e}")
from ...bots.failure import render_failure_reply
return render_failure_reply(e).text
loop = asyncio.get_running_loop()
has_listeners = self.stream_emitter.has_callbacks
def _emit(event: StreamEvent) -> None:
# Marshal onto the loop thread so callbacks run in the same context
# the Agent path uses (get_running_loop()/turn-local SSE context).
def _deliver() -> None:
try:
self.stream_emitter.emit(event)
except Exception as emit_exc: # never break the run on an emit failure
logger.debug("Recipe stream emit failed: %s", emit_exc)
try:
loop.call_soon_threadsafe(_deliver)
except RuntimeError: # loop closed/stopping — emit inline as a fallback
_deliver()
def _cancelled() -> bool:
if cancel_token is None:
return False
for attr in ("is_cancelled", "cancelled"):
flag = getattr(cancel_token, attr, None)
try:
if (flag() if callable(flag) else flag):
return True
except Exception: # pragma: no cover - defensive token probe
continue
return False
def _run_blocking() -> str:
final_text = ""
emitted_first = False
# Forward the cancel token to the recipe runtime when it accepts one,
# so cancellation can stop work inside the recipe too — not only our
# iteration. Fall back to the unsupported signature otherwise.
stream_kwargs = {
"input": {"user_input": message},
"config": self.config or None,
}
if cancel_token is not None:
try:
import inspect
params = inspect.signature(recipe.run_stream).parameters
if "cancel_token" in params or any(
p.kind == p.VAR_KEYWORD for p in params.values()
):
stream_kwargs["cancel_token"] = cancel_token
except (ValueError, TypeError): # pragma: no cover - builtin/CFunction
pass
try:
stream = recipe.run_stream(self.recipe_name, **stream_kwargs)
for rec_event in stream:
if _cancelled():
break
etype = getattr(rec_event, "event_type", "")
data = getattr(rec_event, "data", {}) or {}
if etype == "progress" and has_listeners:
text = data.get("message")
if text:
_emit(StreamEvent(
type=StreamEventType.TOOL_PROGRESS,
content=str(text),
metadata={"step": data.get("step")},
))
elif etype == "output":
final_text = self._extract_text(data.get("output"))
if final_text and has_listeners:
if not emitted_first:
_emit(StreamEvent(type=StreamEventType.FIRST_TOKEN))
emitted_first = True
_emit(StreamEvent(
type=StreamEventType.DELTA_TEXT,
content=final_text,
))
elif etype == "error":
raise RuntimeError(data.get("message") or "Recipe execution failed")
if has_listeners:
_emit(StreamEvent(type=StreamEventType.STREAM_END))
return final_text
except Exception:
if has_listeners:
_emit(StreamEvent(type=StreamEventType.STREAM_END))
raise
try:
return await loop.run_in_executor(None, _run_blocking)
except Exception as e:
logger.error(f"Recipe execution failed: {e}")
from ...bots.failure import render_failure_reply
@@ -50,6 +50,122 @@ class ConfigVersionError(ValueError):
"""
class ConfigValidationError(ValueError):
"""Raised when gateway config contains an unknown/misspelled key.
Carries the dotted ``path`` to the offending section plus the offending
``keys`` and (when a near match exists) a ``suggestion`` so the error is
actionable — e.g. ``channels.telegram.tokenn: unknown key (did you mean
'token'?)`` — rather than the silent no-op a typo produces today (#5723).
"""
def __init__(
self,
path: str,
keys: List[str],
suggestion: Optional[str] = None,
) -> None:
self.path = path
self.keys = list(keys)
self.suggestion = suggestion
# Keys may be non-string YAML scalars; render by string form so the
# error message never raises while formatting itself.
shown = ", ".join(sorted(str(k) for k in self.keys))
msg = f"{path}: unknown key(s): {shown}"
if suggestion:
msg += f" (did you mean '{suggestion}'?)"
super().__init__(msg)
def _closest_known_key(unknown: str, known: Set[str]) -> Optional[str]:
"""Return the closest known key to ``unknown`` for a "did you mean" hint.
``unknown`` may be any YAML scalar (e.g. a numeric/boolean mapping key), so
only string keys are matched — difflib expects strings and a non-string key
cannot be a typo of a reserved string key.
"""
if not isinstance(unknown, str):
return None
import difflib
matches = difflib.get_close_matches(unknown, sorted(known), n=1, cutoff=0.6)
return matches[0] if matches else None
def _check_unknown_keys(
data: Mapping[str, Any],
known: Set[str],
path: str,
strict: bool,
) -> None:
"""Reject (strict) or warn about keys in ``data`` not present in ``known``.
When ``strict`` is False (the default, preserving today's fail-open
behaviour) a prominent warning is logged for each unknown key so the
misconfiguration surfaces before a future release makes it a hard failure.
"""
# Keys may be any YAML scalar (numeric/boolean mapping keys), so compare as
# strings and sort by string form to avoid a TypeError on mixed types —
# preserving today's tolerance (those extras were silently dropped) rather
# than crashing config loading.
unknown = sorted(
(k for k in data if k not in known), key=lambda k: str(k)
)
if not unknown:
return
suggestion = _closest_known_key(unknown[0], known)
if strict:
raise ConfigValidationError(path, unknown, suggestion)
import logging
hint = f" (did you mean '{suggestion}'?)" if suggestion else ""
logging.getLogger(__name__).warning(
"Ignoring unknown %s key(s) %s%s; valid keys are %s",
path,
unknown,
hint,
sorted(known),
)
def _check_typo_keys(
data: Mapping[str, Any],
reserved: Set[str],
path: str,
strict: bool,
) -> None:
"""Flag keys that look like typos of ``reserved`` keys.
Unlike :func:`_check_unknown_keys` this does *not* reject every extra key —
it only fires on a close misspelling of a reserved key (e.g. ``tokenn`` for
``token``), so sections that legitimately accept pass-through keys (channel
metadata) keep working while the #5723 footgun still surfaces.
"""
import difflib
for key in data:
if key in reserved:
continue
# Only a string key can be a typo of a reserved string key; a
# non-string key (numeric/boolean YAML scalar) passes through to
# metadata untouched, as before.
if not isinstance(key, str):
continue
match = difflib.get_close_matches(key, sorted(reserved), n=1, cutoff=0.8)
if match:
if strict:
raise ConfigValidationError(path, [key], match[0])
import logging
logging.getLogger(__name__).warning(
"Ignoring %s key %r which looks like a typo of %r; "
"if intentional this is passed through as metadata",
path,
key,
match[0],
)
@dataclass
class LegacyConfigRule:
"""A single declarative config-migration rule.
@@ -1367,8 +1483,33 @@ class MultiChannelGatewayConfig:
channels: Dict[str, ChannelRouteConfig] = field(default_factory=dict)
hooks: List[Any] = field(default_factory=list)
# Known top-level and nested keys for strict unknown-key validation
# (#5723). Kept here as the single source of truth consumed by all three
# surfaces (YAML, CLI, Python) — ``gateway doctor`` reuses the same sets.
_KNOWN_TOP_KEYS = frozenset({"gateway", "agents", "channels", "hooks", "config_version"})
_KNOWN_GATEWAY_KEYS = frozenset({
"host", "port", "trusted_proxies", "cors_origins", "allowed_origins",
"auth_token", "auth", "auth_scopes", "max_connections",
"max_sessions_per_agent", "session_config", "heartbeat_interval",
"reconnect_timeout", "per_turn_timeout", "ssl_cert", "ssl_key",
"max_buffered_bytes", "max_queued_frames", "max_concurrent_runs",
"queue_depth", "overflow_policy", "drain_timeout", "watchdog",
"watchdog_timeout", "max_concurrent_runs_per_scope",
"preauth_max_connections_per_ip", "max_unauthorized_frames",
"api", "liveness", "turn_lock", "control", "attachments",
"push", "preflight", "strict_tools", "hooks",
# Derived/read-only keys emitted by ``GatewayConfig.to_dict`` so a
# round-tripped config stays strict-clean.
"ssl_enabled", "scope_policy_enabled", "executor",
})
_KNOWN_CHANNEL_KEYS = frozenset({
"token", "app_token", "routes", "enabled",
})
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "MultiChannelGatewayConfig":
def from_dict(
cls, data: Dict[str, Any], *, strict: bool = False
) -> "MultiChannelGatewayConfig":
"""Create from parsed YAML dictionary.
Expected format::
@@ -1389,12 +1530,27 @@ class MultiChannelGatewayConfig:
Args:
data: Parsed YAML dictionary
strict: When True, unknown/misspelled keys raise
:class:`ConfigValidationError` with a path-scoped, actionable
message. When False (default, backward-compatible) unknown keys
are tolerated but logged as a prominent warning so a typo such
as ``channels.telegram.tokenn`` surfaces instead of silently
no-op'ing (#5723).
Returns:
Configured MultiChannelGatewayConfig instance
"""
# Validate top-level keys first so a misplaced section is caught early.
# Use a distinct "<root>" path so a top-level typo (e.g. ``channelz``)
# is not misreported under the ``gateway`` section it does not live in.
_check_unknown_keys(data, set(cls._KNOWN_TOP_KEYS), "<root>", strict)
# Parse gateway section
gw_data = data.get("gateway", {})
if isinstance(gw_data, dict):
_check_unknown_keys(
gw_data, set(cls._KNOWN_GATEWAY_KEYS), "gateway", strict
)
# Parse session config if provided
session_config = SessionConfig()
@@ -1497,6 +1653,17 @@ class MultiChannelGatewayConfig:
channels: Dict[str, ChannelRouteConfig] = {}
for name, ch_data in data.get("channels", {}).items():
if isinstance(ch_data, dict):
# Channels deliberately pass unrecognised keys through to
# ``metadata`` for channel-specific config, so we can't reject
# *every* extra key. Instead flag only near-miss typos of the
# reserved keys (e.g. ``tokenn`` → ``token``) — the motivating
# footgun in #5723 — leaving genuine metadata untouched.
_check_typo_keys(
ch_data,
set(cls._KNOWN_CHANNEL_KEYS),
f"channels.{name}",
strict,
)
channels[name] = ChannelRouteConfig(
channel_type=name,
token_env=ch_data.get("token", ""),
@@ -35,6 +35,10 @@ from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, TypeVar, Generic
from functools import wraps
from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
@dataclass
class InvocationContext:
@@ -303,16 +307,33 @@ def _invoke_observer(callback: Callable[[Any], Any], arg: Any) -> None:
none is running, otherwise scheduled on the running loop. The return value
is always ignored — these are observers.
"""
result = callback(arg)
if inspect.iscoroutine(result):
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is not None:
loop.create_task(result)
else:
asyncio.run(result)
try:
result = callback(arg)
if inspect.iscoroutine(result):
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is not None:
# Keep a strong reference so the task isn't GC'd mid-flight,
# and log (rather than lose) any exception it raises.
task = loop.create_task(result)
_OBSERVER_TASKS.add(task)
task.add_done_callback(_on_observer_task_done)
else:
asyncio.run(result)
except Exception as e:
# Observers must never corrupt the run.
logger.warning("Observer %s failed: %s", getattr(callback, "__name__", callback), e)
_OBSERVER_TASKS: set = set()
def _on_observer_task_done(task: "asyncio.Task") -> None:
_OBSERVER_TASKS.discard(task)
if not task.cancelled() and task.exception() is not None:
logger.warning("Async observer failed: %s", task.exception())
def as_step_hook(callback: Callable[[Any], Any]) -> AfterModelFn:
@@ -11,6 +11,7 @@ import time
import asyncio
from praisonaiagents._logging import get_logger
import subprocess
from concurrent.futures import ThreadPoolExecutor
from typing import List, Optional, Dict, Any
from .types import (
@@ -72,6 +73,47 @@ class HookRunner:
self._registry = registry if registry is not None else HookRegistry()
self._default_timeout = default_timeout
self._cwd = cwd or os.getcwd()
# Dedicated executor for sync hooks. A hook that times out cannot be
# cancelled and keeps its worker slot; isolating it here means a
# misbehaving hook can only exhaust hook capacity, never the loop's
# shared default executor that guardrails/tools/locks also rely on.
self._hook_pool: Optional[ThreadPoolExecutor] = None
# hook.id -> in-flight Future for a sync hook. The slot is reserved
# before submission and only cleared when the worker finishes, so a
# still-running (timed-out or cancelled) hook caps new launches at one
# thread per hook. Guarded by ``_stuck_lock`` so concurrent invocations
# of the same hook cannot both reserve the slot and race past the cap.
self._stuck: Dict[str, Any] = {}
self._stuck_lock_obj: Optional[asyncio.Lock] = None
@property
def _stuck_lock(self) -> asyncio.Lock:
"""Lazily create the in-flight-slot lock on first use.
Created lazily (not in ``__init__``) because a ``HookRunner`` may be
constructed before any event loop exists; the lock is only needed once
we are already inside ``async def execute`` / ``_execute_function_hook``.
"""
if self._stuck_lock_obj is None:
self._stuck_lock_obj = asyncio.Lock()
return self._stuck_lock_obj
def _release_stuck(self, hook_id: str, fut: Any) -> None:
"""Clear the in-flight slot once the worker Future completes.
Only removes the entry if it still points at *this* Future, so a newer
reserved invocation is never clobbered by an older worker draining.
"""
if self._stuck.get(hook_id) is fut:
self._stuck.pop(hook_id, None)
def _get_hook_pool(self) -> ThreadPoolExecutor:
"""Lazily create the dedicated sync-hook executor."""
if self._hook_pool is None:
self._hook_pool = ThreadPoolExecutor(
max_workers=8, thread_name_prefix="praison-hook"
)
return self._hook_pool
@property
def registry(self) -> HookRegistry:
@@ -167,27 +209,30 @@ class HookRunner:
Returns:
List of execution results
"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is not None and loop.is_running():
# Cannot execute sync in running event loop - would block the loop
# Guide users to use the async API instead
raise RuntimeError(
"execute_sync() cannot be called from within a running event loop. "
"Use 'await runner.execute(event, input_data, target)' instead in async contexts."
)
# Fast path: no hooks → no loop creation needed
# This mirrors the same check in execute() but avoids event loop creation overhead
hooks = self._registry.get_hooks(event, target)
if not hooks:
return []
# No running loop and hooks exist — safe to create one
return asyncio.run(self.execute(event, input_data, target, _hooks=hooks))
# Use the canonical sync-to-async bridge so blocking policy/guardrail
# hooks still run (and can deny) from a running event loop — e.g. a
# FastAPI/aiohttp handler, Jupyter, or a bot callback invoking
# agent.start()/agent.chat(). Raising here would turn documented sync
# entry points into hard failures whenever any hook is registered.
#
# Bound the bridge wait so a hook that ignores its own per-hook timeout
# (e.g. a blocking C call on the worker loop) can never pin the caller's
# thread forever. Each hook already enforces ``default_timeout``
# internally; we give the whole chain headroom of one timeout per hook
# so legitimately slow sequential chains are not cut short, while still
# providing a hard backstop instead of ``timeout=None``.
from ..utils.async_bridge import run_coroutine_from_any_context
bridge_timeout = max(self._default_timeout, 1.0) * (len(hooks) + 1)
return run_coroutine_from_any_context(
self.execute(event, input_data, target, _hooks=hooks),
timeout=bridge_timeout,
)
async def _execute_parallel(
self,
@@ -394,11 +439,52 @@ class HookRunner:
timeout=timeout
)
else:
# Execute sync function in thread pool with timeout
loop = asyncio.get_event_loop()
# Reserve the single in-flight slot for this hook *under a lock*
# and *before* submitting, so two concurrent invocations can't
# both see an empty ``_stuck`` and both launch a worker (which
# would exceed the intended one-worker-per-hook cap and double
# the side effects). The slot is only cleared once the worker
# finishes; a still-running previous worker fails this one
# closed immediately instead of leaking another thread.
async with self._stuck_lock:
prev = self._stuck.get(hook.id)
if prev is not None and not prev.done():
duration = (time.time() - start_time) * 1000
return HookExecutionResult(
hook_id=hook.id,
hook_name=hook.name or "unknown",
event=event,
success=False,
error="previous invocation still running past its timeout",
duration_ms=duration,
output=HookResult(
decision="deny",
reason=f"Hook '{hook.name}' is still running past its timeout",
),
)
# Execute sync function in the dedicated hook pool with
# timeout. ``copy_context_to_callable`` preserves
# trace/session contextvars across the thread, matching
# other executor sites.
from ..trace.context_events import copy_context_to_callable
loop = asyncio.get_event_loop()
func = hook.func
data = input_data
fut = loop.run_in_executor(
self._get_hook_pool(),
copy_context_to_callable(lambda: func(data)),
)
# Reserve the slot now; a completion callback clears it only
# when the worker actually finishes, so a timeout *or* a
# cancellation leaves the slot held until the thread drains.
self._stuck[hook.id] = fut
fut.add_done_callback(
lambda f, hid=hook.id: self._release_stuck(hid, f)
)
result = await asyncio.wait_for(
loop.run_in_executor(None, hook.func, input_data),
timeout=timeout
asyncio.shield(fut), timeout=timeout
)
duration = (time.time() - start_time) * 1000
@@ -31,13 +31,6 @@ STRATEGY_THRESHOLDS = {
DEFAULT_STRATEGY = "hierarchical" # > 100000 files
def estimate_tokens_simple(text: str) -> int:
"""Simple token estimation (~4 chars per token)."""
if not text:
return 0
return len(text) // 4 + 1
@dataclass
class CorpusStats:
"""
@@ -0,0 +1,60 @@
"""Bounded auto-continuation for responses cut off by the output-token limit.
When a final (no tool calls) assistant message stops with
``finish_reason == "length"``, the turn loop re-prompts the model to continue
and stitches the pieces into one message, up to a small cap. Past the cap the
existing ``length_truncated`` outcome is surfaced unchanged.
"""
from typing import Any, Dict, List, Optional
CONTINUE_PROMPT = (
"Your previous response was cut off by the output token limit. "
"Continue exactly where you left off. Do not repeat anything you already "
"wrote and do not add any preamble."
)
DEFAULT_MAX_CONTINUATIONS = 3
class OutputContinuation:
"""Per-turn continuation state. ``setting``: True/None=default cap, False/0=off, int=cap."""
__slots__ = ("remaining", "text")
def __init__(self, setting: Any = True) -> None:
if setting is None or setting is True:
self.remaining = DEFAULT_MAX_CONTINUATIONS
elif setting is False:
self.remaining = 0
else:
self.remaining = max(0, int(setting))
self.text = ""
def take(self, messages: List[Dict[str, Any]], partial: Optional[str], truncated: bool) -> bool:
"""Queue a continuation request for a length-truncated ``partial``.
Appends the partial assistant turn plus a continue prompt to
``messages`` and returns True, or returns False (no-op) when not
truncated, empty, or the cap is exhausted.
"""
if not truncated or not partial or self.remaining <= 0:
return False
self.remaining -= 1
self.text += partial
messages.append({"role": "assistant", "content": partial})
messages.append({"role": "user", "content": CONTINUE_PROMPT})
return True
def stitch(self, text: Optional[str]) -> Optional[str]:
"""Prefix earlier truncated pieces onto the final ``text``."""
if not self.text:
return text
return self.text + (text or "")
def is_structured(response_format: Any) -> bool:
"""True when ``response_format`` enforces JSON/schema output (stitching would break it)."""
if isinstance(response_format, dict):
return response_format.get("type", "text") != "text"
return bool(response_format)
@@ -556,6 +556,67 @@ def add_provider_adapter(name: str, adapter: LLMProviderAdapterProtocol) -> None
_provider_adapters[name] = adapter
def list_provider_adapters() -> List[str]:
"""Return the names of all registered provider adapters, sorted.
Read-only, lazy accessor so wrapper surfaces (CLI ``setup``/``auth``/
``models``, YAML resolution) can enumerate providers registered via
:func:`add_provider_adapter` or the ``praisonai.providers`` entry-point
group without importing heavy modules. Call
:func:`load_provider_entry_points` first if entry-point providers should be
included.
"""
load_provider_entry_points()
return sorted(_provider_adapters.keys())
_provider_entry_points_loaded = False
def load_provider_entry_points() -> None:
"""Discover and register providers published under ``praisonai.providers``.
Lazy and idempotent (runs its scan at most once, mirroring the tool
registry's ``discover_plugins()``): a pip-installed distribution can ship a
provider adapter without any edit to core::
[project.entry-points."praisonai.providers"]
myprovider = "my_pkg:MyAdapter"
The entry point may load either an adapter instance or a zero-arg callable/
class producing one. Discovery never raises — a broken plugin must not break
provider resolution.
"""
global _provider_entry_points_loaded
if _provider_entry_points_loaded:
return
_provider_entry_points_loaded = True
try:
from importlib.metadata import entry_points
for ep in entry_points(group="praisonai.providers"):
name = ep.name.lower()
if name in _provider_adapters:
# Never let a plugin silently replace a built-in adapter.
continue
try:
loaded = ep.load()
# A class or a factory callable is instantiated; an already
# constructed adapter instance (has the protocol's methods) is
# used as-is.
if isinstance(loaded, type):
adapter = loaded()
elif callable(loaded) and not hasattr(loaded, "supports_prompt_caching"):
adapter = loaded()
else:
adapter = loaded
except Exception:
continue
_provider_adapters[name] = adapter
except Exception: # pragma: no cover - discovery must never break resolution
pass
def get_provider_adapter(name: str) -> LLMProviderAdapterProtocol:
"""
Get provider adapter by name with fallback to default.
@@ -571,6 +632,17 @@ def get_provider_adapter(name: str) -> LLMProviderAdapterProtocol:
# Exact match first
if name_lower in _provider_adapters:
return _provider_adapters[name_lower]
# A plugin/entry-point provider (e.g. ``myprovider/model``) is only
# registered on first discovery; load lazily so prefixed model ids route
# through a discovered adapter rather than silently falling to default.
if name_lower not in _provider_adapters:
load_provider_entry_points()
if name_lower in _provider_adapters:
return _provider_adapters[name_lower]
base = name_lower.split("/", 1)[0]
if base in _provider_adapters:
return _provider_adapters[base]
# Provider prefixes or substrings
if "ollama" in name_lower:
@@ -594,4 +666,6 @@ __all__ = [
'GeminiAdapter',
'get_provider_adapter',
'add_provider_adapter',
'list_provider_adapters',
'load_provider_entry_points',
]
@@ -9,6 +9,7 @@ Provides both legacy API (classify_error) and new structured classification
"""
import re
import math
import random
from dataclasses import dataclass
from enum import Enum
@@ -21,6 +22,7 @@ __all__ = [
"classify_llm_error",
"should_retry",
"is_replay_unsafe",
"is_replay_unsafe_chain",
"get_retry_delay",
"extract_retry_after",
"get_error_context",
@@ -478,6 +480,11 @@ _REPLAY_UNSAFE_EXCEPTION_NAMES = frozenset({
# Signals that a transient failure happened BEFORE the request was dispatched,
# so the provider provably did not process it — safe to replay automatically.
# ``sslerror`` is deliberately NOT listed here: the stdlib ``ssl.SSLError`` is
# also raised for mid-response failures (e.g. "SSL connection reset by peer"),
# so a bare SSLError class is not proof of a pre-dispatch failure. It is treated
# as safe only when the message carries an explicit pre-dispatch TLS signal
# (handshake / certificate verification) and no post-dispatch signal — see below.
_REPLAY_SAFE_EXCEPTION_NAMES = frozenset({
"connecttimeout", # TCP connect never completed
"connecttimeouterror", # urllib3 connect timeout
@@ -485,14 +492,14 @@ _REPLAY_SAFE_EXCEPTION_NAMES = frozenset({
"connecterror", # httpx connect error (pre-dispatch)
"newconnectionerror", # urllib3: connection never established
"gaierror", # DNS resolution failure
"sslerror", # TLS handshake failure (pre-dispatch)
"sslcertverificationerror",
"sslcertverificationerror", # certificate verification fails before dispatch
})
_REPLAY_UNSAFE_MESSAGE_PATTERNS = (
r"read.?timed?.?out",
r"read.?timeout",
r"connection.?reset",
r"reset.?by.?peer",
r"peer.?closed",
r"incomplete.?read",
r"chunked",
@@ -500,6 +507,25 @@ _REPLAY_UNSAFE_MESSAGE_PATTERNS = (
r"server.?disconnected",
)
# Phase-specific pre-dispatch signals. The TLS handshake and certificate
# verification both complete *before* any request byte is written, so a failure
# naming either phase is pre-dispatch even when it also mentions a reset or
# disconnect (e.g. "connection reset by peer during TLS handshake"). These win
# over the generic post-dispatch patterns below, which otherwise match the bare
# "reset"/"disconnect" token. Kept deliberately narrow — only phases that are
# provably pre-request — so a true mid-response reset stays replay-unsafe.
_REPLAY_SAFE_PHASE_PATTERNS = (
r"handshake",
r"certificate.?verif",
r"certificate.?verify.?failed",
r"cert.?verif",
)
# Explicit pre-dispatch signals. These establish that the request never reached
# the provider (connect never completed, TLS handshake/certificate failed) and
# therefore win even over a generic transport label. A bare "tls"/"ssl" token is
# intentionally excluded: a transport label alone does not prove pre-dispatch
# failure, and matching it would mask a post-dispatch read/reset signal.
_REPLAY_SAFE_MESSAGE_PATTERNS = (
r"connect.?timed?.?out",
r"connection.?timed?.?out",
@@ -507,26 +533,56 @@ _REPLAY_SAFE_MESSAGE_PATTERNS = (
r"name.?resolution",
r"failed.?to.?resolve",
r"getaddrinfo",
r"ssl",
r"handshake",
r"tls",
)
) + _REPLAY_SAFE_PHASE_PATTERNS
def is_replay_unsafe(error: Exception) -> bool:
"""Return True if replaying the request could double-execute the turn.
Distinguishes *pre-dispatch* failures (the request provably never reached
the provider — DNS/connect/TLS failures) which are safe to replay, from
*post-dispatch* failures (read timeout, connection reset mid-response) where
the provider may already have produced output and any side-effecting tool
calls may already have run. Post-dispatch failures are replay-unsafe.
the provider — DNS/connect failures, TLS handshake and certificate
verification failures) which are safe to replay, from *post-dispatch*
failures (read timeout, connection reset mid-response) where the provider
may already have produced output and any side-effecting tool calls may
already have run. Post-dispatch failures are replay-unsafe.
A generic TLS/SSL transport label does NOT establish a pre-dispatch failure:
``ssl.SSLError`` is raised both for handshake failures (pre-dispatch) and for
mid-response read/reset failures (post-dispatch). So an explicit post-dispatch
signal (read timeout, connection reset) always wins over the transport label,
while explicit connect-timeout, TLS handshake and certificate-verification
signals remain safe.
A *phase-specific* pre-dispatch signal (TLS handshake, certificate
verification) is the one exception that wins over a post-dispatch token: both
phases complete before any request byte is written, so a reset or disconnect
*during the handshake* (e.g. "connection reset by peer during TLS handshake")
is pre-dispatch and safe to replay, not a mid-response failure.
Defaults to ``False`` (safe/retryable) for ambiguous transient errors so
existing auto-retry behaviour is preserved unless a post-dispatch signal is
present. Callers should only block the retry when the turn is side-effecting.
"""
# 1. Exception type (walk the MRO to catch provider subclasses) — most stable.
error_text = f"{type(error).__name__} {error}".lower()
# 1. A phase-specific pre-dispatch signal (TLS handshake / certificate
# verification) wins first: these phases finish before any request byte is
# sent, so a reset/disconnect naming the handshake is pre-dispatch and
# safe to replay — it must not be masked by the post-dispatch patterns.
for pattern in _REPLAY_SAFE_PHASE_PATTERNS:
if re.search(pattern, error_text):
return False
# 2. An explicit post-dispatch signal in the message wins next — even when
# the error is an SSLError or carries a generic TLS/SSL label. A read
# timeout or connection reset means bytes were already on the wire.
for pattern in _REPLAY_UNSAFE_MESSAGE_PATTERNS:
if re.search(pattern, error_text):
return True
# 3. Exception type (walk the MRO to catch provider subclasses) — stable, and
# checked only after ruling out a post-dispatch message signal above so a
# mid-response SSLError is not misclassified as a pre-dispatch handshake.
for cls in type(error).__mro__:
name = cls.__name__.lower()
if name in _REPLAY_SAFE_EXCEPTION_NAMES:
@@ -534,18 +590,37 @@ def is_replay_unsafe(error: Exception) -> bool:
if name in _REPLAY_UNSAFE_EXCEPTION_NAMES:
return True
# 2. Message text — pre-dispatch signals win over the generic "timeout".
error_text = f"{type(error).__name__} {error}".lower()
# 4. Explicit pre-dispatch message signals (connect timeout, handshake,
# certificate verification) establish the request never reached the
# provider — safe to replay.
for pattern in _REPLAY_SAFE_MESSAGE_PATTERNS:
if re.search(pattern, error_text):
return False
for pattern in _REPLAY_UNSAFE_MESSAGE_PATTERNS:
if re.search(pattern, error_text):
return True
return False
def is_replay_unsafe_chain(error: BaseException) -> bool:
"""Like :func:`is_replay_unsafe` but also inspects the exception cause chain.
A streaming attempt often re-raises a post-dispatch failure wrapped in a
generic exception (``raise Exception(...) from read_timeout``), so the
replay-unsafe signal lives on ``__cause__``/``__context__`` rather than the
outermost exception. Returning ``True`` when *any* link in the chain is
replay-unsafe keeps a wrapped read timeout from being silently replayed by a
higher-level fallback. A pre-dispatch (replay-safe) link does not override an
unsafe one found elsewhere in the chain; the chain is unsafe if any link is.
"""
seen: set = set()
current: Optional[BaseException] = error
while current is not None and id(current) not in seen:
seen.add(id(current))
if is_replay_unsafe(current):
return True
current = current.__cause__ or current.__context__
return False
def get_retry_delay(category: ErrorCategory, attempt: int = 1, base_delay: float = 1.0) -> float:
"""Get the appropriate delay before retrying based on error category.
@@ -644,7 +719,9 @@ def extract_retry_after(
retry_after = None
if retry_after is not None:
try:
return min(float(retry_after), cap_seconds)
delay = float(retry_after)
if math.isfinite(delay) and delay >= 0:
return min(delay, cap_seconds)
except (ValueError, TypeError):
# Not a plain delta-seconds value — RFC 7231 also permits an
# HTTP-date. Parse it so a provider that sends an absolute reset
@@ -658,24 +735,44 @@ def extract_retry_after(
# 2. Some SDKs expose a numeric ``retry_after`` attribute directly.
retry_after_attr = getattr(error, "retry_after", None)
if isinstance(retry_after_attr, (int, float)):
return min(float(retry_after_attr), cap_seconds)
try:
delay = float(retry_after_attr)
except OverflowError:
delay = float("inf")
if math.isfinite(delay) and delay >= 0:
return min(delay, cap_seconds)
error_str = str(error)
# 3. Fall back to parsing common Retry-After patterns from the message.
# Capture the full signed token so a negative hint (e.g. "-3600 seconds")
# is rejected by the finite/non-negative check below instead of being
# silently read as a positive delay. Match a whole numeric token so
# leading-dot fractions, exponents and malformed suffixes cannot be
# mistaken for an unrelated positive integer.
# A period may terminate a sentence, but may not split an embedded token.
number = (
r"(?<![\w.+-])([+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?)"
r"(?![\deE+-]|\.(?!\s|$|[\"')\]}])|\.\s*(?:seconds?|s)\b)"
)
patterns = [
r"retry.?after[:\s]+(\d+)",
r"retry[:\s]+(\d+)",
r"wait[:\s]+(\d+)",
r"(\d+).*second",
# Quoted duration values must close immediately after the unit.
r'"?retryDelay"?\s*:\s*"' + number + r's"',
r'"?retryDelay"?\s*:\s*' + number + r's(?=$|[\s,;}\]])',
r"try again in\s+" + number,
r"retry.?after[:\s]+" + number,
r"retry[:\s]+" + number,
r"wait[:\s]+" + number,
number + r"\s*second",
]
for pattern in patterns:
match = re.search(pattern, error_str, re.IGNORECASE)
if match:
# Invalid earlier hints must not hide a later valid hint of this form.
for match in re.finditer(pattern, error_str, re.IGNORECASE):
try:
delay = float(match.group(1))
return min(delay, cap_seconds)
if math.isfinite(delay) and delay >= 0:
return min(delay, cap_seconds)
except (ValueError, IndexError):
continue
@@ -524,10 +524,43 @@ class FailoverManager:
}
def reset_all(self) -> None:
"""Reset all profiles to available status."""
"""Reset all profiles to available status.
Clears both the local profile state and any retained coordinator
benches. Without clearing the coordinator, a subsequent
``get_next_profile()`` would re-apply the old cooldown via
``_sync_bench_from_coordinator()`` and silently undo the reset.
``reset_all()`` is a deliberate, operator-initiated override that
forces every credential back to AVAILABLE, so the coordinator bench is
cleared unconditionally — unlike ``mark_success()``, which only
recovers from a specific known cooldown and must not clobber a newer
concurrent bench. On a shared coordinator this therefore also clears
benches other replicas may have recorded; that is intended for an
explicit reset.
Fail-open with best-effort durability: a coordinator hiccup must never
wedge the local reset, but a single transient ``clear()`` failure must
not silently undo the reset either (the stale bench would be re-imported
on the next ``get_next_profile()``). We therefore verify the clear and
retry once before giving up and warning.
"""
with self._lock:
for profile in self._profiles:
profile.reset()
cred_id = profile.credential_id
for attempt in range(2):
try:
self._coordinator.clear(cred_id)
if self._coordinator.benched_until(cred_id) is None:
break
except Exception as e: # pragma: no cover - defensive
if attempt == 0:
continue
logger.warning(
f"Quota coordinator clear failed for '{profile.name}': {e}; "
f"local reset applied but shared bench may persist"
)
@runtime_checkable
File diff suppressed because it is too large. Load diff
@@ -105,6 +105,35 @@ def default_auxiliary_model(explicit: Optional[str] = None) -> str:
return DEFAULT_AUXILIARY_MODEL
# NVIDIA NIM (build.nvidia.com / local NIM containers). NIM catalog ids already
# start with ``nvidia/`` but litellm's provider prefix is ``nvidia_nim/``, so
# ``nvidia/<model>`` is rewritten. Short aliases map to catalog slugs verified
# against ``GET https://integrate.api.nvidia.com/v1/models`` on 2026-10-15.
NVIDIA_NIM_ALIASES = {
"nemotron-super": "nemotron-3-super-120b-a12b",
"nemotron-nano": "nemotron-nano-3-30b-a3b",
"nemotron-ultra": "nemotron-3-ultra-550b-a55b",
}
def to_litellm_nvidia_nim(model: str) -> Optional[str]:
"""Map a user-facing ``nvidia/...`` id to litellm's ``nvidia_nim/...`` route.
``nvidia/<x>`` -> ``nvidia_nim/nvidia/<x>`` (aliases expanded) and
``nvidia/<publisher>/<x>`` -> ``nvidia_nim/<publisher>/<x>``. Anything else,
including an already-routed ``nvidia_nim/...``, returns None.
"""
if not isinstance(model, str) or not model.lower().startswith("nvidia/"):
return None
rest = model[len("nvidia/"):]
if not rest:
return None
if "/" in rest:
return f"nvidia_nim/{rest}"
rest = NVIDIA_NIM_ALIASES.get(rest.lower(), rest)
return f"nvidia_nim/nvidia/{rest}"
def _entry_point_matchers():
"""Yield (provider_id, matcher) pairs registered by third-party plugins.
@@ -391,6 +391,19 @@ class OpenAIClient:
# Initialize clients lazily
self._sync_client = None
self._async_client = None
# Event loop the cached async client is bound to. A cached AsyncOpenAI
# keeps an httpx transport tied to the loop that first used it; reusing
# it from a different/closed loop raises "Event loop is closed", so we
# recreate the client when the running loop changes.
self._async_client_loop = None
# Whether the cached async client was created by ``async_client`` itself.
# Externally injected clients (e.g. a stubbed transport in tests) are
# never dropped on a loop change, since we did not bind them to a loop.
self._async_client_owned = False
# Guards the loop-change drop/rebuild of the async client so concurrent
# sync callers (each on its own ``asyncio.run`` loop) cannot observe a
# half-swapped client or a transient ``None``.
self._async_client_lock = threading.Lock()
# Set up logging
self.logger = get_logger(__name__)
@@ -444,13 +457,53 @@ class OpenAIClient:
via ``praisonaiagents.model_harness.allow_model_requests(False)``.
"""
check_model_request(getattr(self, "model", None), "openai.chat.completions")
if self._async_client is None:
_, AsyncOpenAI = _get_openai_classes()
client_kwargs = {"api_key": self.api_key, "base_url": self.base_url}
if self.max_retries is not None:
client_kwargs["max_retries"] = self.max_retries
self._async_client = AsyncOpenAI(**client_kwargs)
return self._async_client
try:
running_loop = asyncio.get_running_loop()
except RuntimeError:
running_loop = None
# Serialise the drop/rebuild so concurrent sync callers (each on their
# own ``asyncio.run`` loop) cannot observe a transient ``None`` or a
# half-swapped client. Clients built via ``__new__`` in tests may lack
# the lock, so fall back to a throwaway lock.
lock = getattr(self, "_async_client_lock", None)
if lock is None:
lock = threading.Lock()
self._async_client_lock = lock
with lock:
# Drop a client bound to a different (often already closed) loop so
# we never call into a transport whose loop is gone. Only clients we
# built here are loop-bound; an externally injected client (e.g. a
# stubbed transport in tests) is left untouched.
if (
self._async_client is not None
and self._async_client_owned
and running_loop is not None
and self._async_client_loop is not None
and self._async_client_loop is not running_loop
):
self._async_client = None
if self._async_client is None:
_, AsyncOpenAI = _get_openai_classes()
client_kwargs = {"api_key": self.api_key, "base_url": self.base_url}
if self.max_retries is not None:
client_kwargs["max_retries"] = self.max_retries
self._async_client = AsyncOpenAI(**client_kwargs)
self._async_client_loop = running_loop
self._async_client_owned = True
elif (
self._async_client_owned
and running_loop is not None
and self._async_client_loop is None
):
# The client was first built with no running loop (e.g.
# sync-path construction). Bind it to the first loop that
# actually uses it so a later loop change is detected and the
# stale client is dropped.
self._async_client_loop = running_loop
# Return a local reference taken under the lock so a concurrent
# drop on another thread cannot turn the caller's result into None.
client = self._async_client
return client
def build_messages(
self,
@@ -720,7 +773,10 @@ class OpenAIClient:
if not self._supports_responses_api(model):
return False
try:
return hasattr(self.sync_client, 'responses')
responses = getattr(self.sync_client, "responses", None)
return responses is not None and callable(
getattr(responses, "create", None)
)
except Exception:
return False
@@ -746,28 +802,39 @@ class OpenAIClient:
# ── Extract system instructions ──────────────────────────────────
instructions = None
input_items: List[Dict[str, Any]] = []
# Track call_ids emitted as function_call items so function_call_output
# items without a matching call stay out of the payload — orphaned
# outputs make the Responses API reject the request with a 400
# "No tool call found for function call output with call_id ...".
emitted_call_ids: set = set()
for msg in messages:
role = msg.get("role", "")
if role in ("system", "developer"):
content = msg.get("content", "")
content = self._normalise_instruction_content(msg.get("content", ""))
if instructions is None:
instructions = content
else:
instructions += "\n" + content
instructions = instructions + "\n" + content
else:
# Handle Chat Completions → Responses API format transforms
if role == "assistant" and msg.get("tool_calls"):
content = msg.get("content")
if content and content.strip():
if isinstance(content, list):
converted = self._build_responses_content(content)
if converted:
input_items.append({"role": "assistant", "content": converted})
elif content and content.strip():
input_items.append({"role": "assistant", "content": content})
for tc in msg["tool_calls"]:
fn = tc.get("function", tc) if isinstance(tc, dict) else tc
fn = tc.get("function", tc) if isinstance(tc, dict) else getattr(tc, "function", tc)
fn_name = fn.get("name", "") if isinstance(fn, dict) else getattr(fn, "name", "")
fn_args = fn.get("arguments", "{}") if isinstance(fn, dict) else getattr(fn, "arguments", "{}")
tc_id = tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", "")
# Skip items with empty name — API rejects them
if not fn_name:
continue
if tc_id:
emitted_call_ids.add(tc_id)
input_items.append({
"type": "function_call",
"call_id": tc_id,
@@ -775,9 +842,15 @@ class OpenAIClient:
"arguments": fn_args if isinstance(fn_args, str) else json.dumps(fn_args),
})
elif role == "tool":
call_id = msg.get("tool_call_id", "")
# Drop orphan outputs whose call_id has no matching
# function_call in this payload; the Responses API 400s on
# them and forces an unnecessary Chat Completions fallback.
if not call_id or call_id not in emitted_call_ids:
continue
input_items.append({
"type": "function_call_output",
"call_id": msg.get("tool_call_id", ""),
"call_id": call_id,
"output": msg.get("content", ""),
})
else:
@@ -842,6 +915,42 @@ class OpenAIClient:
encoded = base64.b64encode(image_path.read_bytes()).decode("ascii")
return f"data:{mime_type};base64,{encoded}"
@staticmethod
def _normalise_instruction_content(content: Any) -> str:
"""Normalise system/developer content into a plain instruction string.
Accepts a plain string or a Chat Completions-style list of text parts
(``[{"type": "text", "text": "..."}]`` or ``input_text``) and returns
the concatenated text. Non-text parts are rejected explicitly rather
than silently discarded. The caller-owned content is never mutated.
"""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
texts: List[str] = []
for part in content:
if (
isinstance(part, dict)
and part.get("type") in ("text", "input_text")
and isinstance(part.get("text"), str)
):
texts.append(part["text"])
else:
raise ValueError(
"Unsupported system/developer instruction: "
"System/developer instructions must contain text parts; "
f"unsupported content part: {part!r}. Only plain strings "
"or text parts ({'type': 'text', 'text': ...}) are supported."
)
return "\n".join(texts)
raise ValueError(
"Unsupported system/developer instruction: "
"System/developer instructions must be text or a list of text parts; "
f"unsupported content: {content!r}"
)
@classmethod
def _build_responses_content(cls, content: Any) -> Any:
"""Translate Chat Completions content parts to Responses API parts."""
@@ -898,16 +1007,52 @@ class OpenAIClient:
responses_content.append(part)
return responses_content
@staticmethod
def _responses_incomplete_finish_reason(response) -> Optional[str]:
"""Map known incomplete reasons to existing Chat Completions outcomes."""
status = response.get("status") if isinstance(response, dict) else getattr(response, "status", None)
if status != "incomplete":
return None
details = response.get("incomplete_details") if isinstance(response, dict) else getattr(response, "incomplete_details", None)
reason = details.get("reason") if isinstance(details, dict) else getattr(details, "reason", None)
return {"max_output_tokens": "length", "content_filter": "content_filter"}.get(reason)
@staticmethod
def _extract_responses_refusal(response) -> Optional[str]:
"""Preserve refusal explanations separately from ordinary output text."""
output = response.get("output", []) if isinstance(response, dict) else getattr(response, "output", [])
refusals = []
for item in output or []:
item_type = item.get("type") if isinstance(item, dict) else getattr(item, "type", None)
if item_type != "message":
continue
content = item.get("content", []) if isinstance(item, dict) else getattr(item, "content", [])
for block in content or []:
block_type = block.get("type") if isinstance(block, dict) else getattr(block, "type", None)
if block_type == "refusal":
refusal = block.get("refusal") if isinstance(block, dict) else getattr(block, "refusal", None)
if isinstance(refusal, str) and refusal:
refusals.append(refusal)
return "".join(refusals) or None
def _responses_to_chat_completion(self, response) -> ChatCompletion:
"""
Wrap a Responses API response into a ChatCompletion dataclass
so that all downstream code (tool loop, display, callbacks)
works without modification.
"""
status = response.get("status") if isinstance(response, dict) else getattr(response, "status", None)
if status == "failed":
from .llm import LLMResponseError
error = response.get("error") if isinstance(response, dict) else getattr(response, "error", None)
code = error.get("code") if isinstance(error, dict) else getattr(error, "code", None)
message = error.get("message") if isinstance(error, dict) else getattr(error, "message", None)
label = f"Responses API failed ({code})" if code else "Responses API failed"
raise LLMResponseError(f"{label}: {message or 'Provider returned a failed response.'}")
response_text = ""
tool_calls_list: List[ToolCall] = []
output_items = getattr(response, 'output', None) or []
output_items = (response.get('output') if isinstance(response, dict) else getattr(response, 'output', None)) or []
for item in output_items:
item_type = getattr(item, "type", "") if not isinstance(item, dict) else item.get("type", "")
@@ -942,22 +1087,26 @@ class OpenAIClient:
))
# Build usage
raw_usage = getattr(response, 'usage', None)
raw_usage = response.get('usage') if isinstance(response, dict) else getattr(response, 'usage', None)
usage = None
if raw_usage:
def usage_value(name):
return raw_usage.get(name, 0) if isinstance(raw_usage, dict) else getattr(raw_usage, name, 0)
usage = CompletionUsage(
prompt_tokens=getattr(raw_usage, 'input_tokens', 0),
completion_tokens=getattr(raw_usage, 'output_tokens', 0),
total_tokens=getattr(raw_usage, 'total_tokens', 0),
prompt_tokens=usage_value('input_tokens'),
completion_tokens=usage_value('output_tokens'),
total_tokens=usage_value('total_tokens'),
)
message = ChatCompletionMessage(
content=response_text if response_text else None,
role="assistant",
refusal=self._extract_responses_refusal(response),
tool_calls=tool_calls_list if tool_calls_list else None,
)
choice = Choice(
finish_reason="tool_calls" if tool_calls_list else "stop",
finish_reason=self._responses_incomplete_finish_reason(response) or ("tool_calls" if tool_calls_list else "stop"),
index=0,
message=message,
)
@@ -1055,6 +1204,7 @@ class OpenAIClient:
Returns:
ChatCompletion object or None if error
"""
raw = None
# Lazy import StreamEvent types only when needed
_emit = emit_events and stream_callback is not None
if _emit:
@@ -1114,6 +1264,9 @@ class OpenAIClient:
except (FileNotFoundError, ValueError):
raise
except Exception as e:
status = raw.get("status") if isinstance(raw, dict) else getattr(raw, "status", None)
if status == "failed":
raise
self.logger.warning(f"Responses API streaming failed, falling back: {e}")
# Fall through to Chat Completions streaming
@@ -1284,6 +1437,9 @@ class OpenAIClient:
return final_response
except Exception as e:
status = raw.get("status") if isinstance(raw, dict) else getattr(raw, "status", None)
if status == "failed":
raise
self.logger.error(f"Error in stream processing: {e}")
return None
@@ -1320,6 +1476,7 @@ class OpenAIClient:
Returns:
ChatCompletion object or None if error
"""
raw = None
# Lazy import StreamEvent types only when needed
_emit = emit_events and stream_callback is not None
if _emit:
@@ -1388,6 +1545,9 @@ class OpenAIClient:
except (FileNotFoundError, ValueError):
raise
except Exception as e:
status = raw.get("status") if isinstance(raw, dict) else getattr(raw, "status", None)
if status == "failed":
raise
self.logger.warning(f"Responses API async streaming failed, falling back: {e}")
# Fall through to Chat Completions streaming
@@ -1550,6 +1710,9 @@ class OpenAIClient:
return final_response
except Exception as e:
status = raw.get("status") if isinstance(raw, dict) else getattr(raw, "status", None)
if status == "failed":
raise
self.logger.error(f"Error in async stream processing: {e}")
return None
@@ -1654,6 +1817,7 @@ class OpenAIClient:
"""
# ── Responses API path (non-streaming only) ────────────────────
if not stream and self._use_responses_api(model):
raw = None
try:
resp_params = self._build_responses_input(
messages, model, temperature, tools,
@@ -1664,6 +1828,9 @@ class OpenAIClient:
except (FileNotFoundError, ValueError):
raise
except Exception as e:
status = raw.get("status") if isinstance(raw, dict) else getattr(raw, "status", None)
if status == "failed":
raise
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
# Fall through to Chat Completions
@@ -1718,6 +1885,7 @@ class OpenAIClient:
"""
# ── Responses API path (non-streaming only) ────────────────────
if not stream and self._use_responses_api(model):
raw = None
try:
resp_params = self._build_responses_input(
messages, model, temperature, tools,
@@ -1728,6 +1896,9 @@ class OpenAIClient:
except (FileNotFoundError, ValueError):
raise
except Exception as e:
status = raw.get("status") if isinstance(raw, dict) else getattr(raw, "status", None)
if status == "failed":
raise
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
# Fall through to Chat Completions
@@ -1807,6 +1978,8 @@ class OpenAIClient:
# path so /stop and live steering work on the OpenAI-native loop too.
cancel_token = kwargs.pop("cancel_token", None)
steering_drain = kwargs.pop("steering_drain", None)
# Bounded auto-continuation on output-token truncation (True/False/int cap).
output_continuation = kwargs.pop("output_continuation", True)
# Durable sink for deferred tool re-injection: the caller's persistent
# conversation history (e.g. the agent's ``chat_history``). Background
# jobs usually complete after this loop returns, so late results must
@@ -1840,6 +2013,9 @@ class OpenAIClient:
# truncation (unified with the LiteLLM path). "completed" by default.
self._last_stop_reason = "completed"
_wrapup_injected = False
from ._continuation import OutputContinuation, is_structured
# Schema-enforced output cannot be continued as a text suffix.
_continuation = OutputContinuation(False if is_structured(kwargs.get("response_format")) else output_continuation)
# Ensure a defined return value even if cancellation breaks the loop
# before any model call is made.
final_response = None
@@ -1848,6 +2024,9 @@ class OpenAIClient:
# G2: Check for cancellation at the top of each tool iteration.
if _is_cancelled():
self._last_stop_reason = "cancelled"
if _continuation.text and final_response is not None:
# Return every truncated piece gathered so far, not just the last.
final_response.choices[0].message.content = _continuation.text
break
# G2: Mid-run steering - inject pending steering notes before next call.
_inject_steering(messages)
@@ -2066,7 +2245,7 @@ class OpenAIClient:
history_sink=deferred_history_sink,
)
try:
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
results_str = json.dumps(tool_result) if tool_result is not None else "Function returned an empty output"
except (TypeError, ValueError):
tool_result = {"result": str(tool_result)}
results_str = json.dumps(tool_result)
@@ -2104,7 +2283,17 @@ class OpenAIClient:
if iteration_count >= max_iterations:
self._last_stop_reason = "max_steps"
else:
# No tool calls, we're done
# No tool calls, we're done -- unless the output-token limit cut
# the answer off, in which case ask the model to continue (bounded).
from ..agent.run_outcome import classify_finish_reason
_choice = final_response.choices[0]
_truncated = classify_finish_reason(getattr(_choice, "finish_reason", None)) == "length_truncated"
if _continuation.take(messages, _choice.message.content, _truncated):
continue
if _continuation.text:
_choice.message.content = _continuation.stitch(_choice.message.content)
if _truncated and _choice.message.content and self._last_stop_reason == "completed":
self._last_stop_reason = "length_truncated"
break
# Usage is recorded per iteration inside the loop above so every billed
@@ -2157,6 +2346,8 @@ class OpenAIClient:
# path so /stop and live steering work on the OpenAI-native loop too.
cancel_token = kwargs.pop("cancel_token", None)
steering_drain = kwargs.pop("steering_drain", None)
# Bounded auto-continuation on output-token truncation (True/False/int cap).
output_continuation = kwargs.pop("output_continuation", True)
# Durable sink for deferred tool re-injection (see sync counterpart).
deferred_history_sink = kwargs.pop("deferred_history_sink", None)
@@ -2186,6 +2377,9 @@ class OpenAIClient:
# Structured stop reason (unified with the sync/LiteLLM paths).
self._last_stop_reason = "completed"
_wrapup_injected = False
from ._continuation import OutputContinuation, is_structured
# Schema-enforced output cannot be continued as a text suffix.
_continuation = OutputContinuation(False if is_structured(kwargs.get("response_format")) else output_continuation)
# Ensure a defined return value even if cancellation breaks the loop
# before any model call is made.
final_response = None
@@ -2194,6 +2388,9 @@ class OpenAIClient:
# G2: Check for cancellation at the top of each tool iteration.
if _is_cancelled():
self._last_stop_reason = "cancelled"
if _continuation.text and final_response is not None:
# Return every truncated piece gathered so far, not just the last.
final_response.choices[0].message.content = _continuation.text
break
# G2: Mid-run steering - inject pending steering notes before next call.
_inject_steering(messages)
@@ -2440,7 +2637,7 @@ class OpenAIClient:
history_sink=deferred_history_sink,
)
try:
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
results_str = json.dumps(tool_result) if tool_result is not None else "Function returned an empty output"
except (TypeError, ValueError):
tool_result = {"result": str(tool_result)}
results_str = json.dumps(tool_result)
@@ -2470,7 +2667,17 @@ class OpenAIClient:
if iteration_count >= max_iterations:
self._last_stop_reason = "max_steps"
else:
# No tool calls, we're done
# No tool calls, we're done -- unless the output-token limit cut
# the answer off, in which case ask the model to continue (bounded).
from ..agent.run_outcome import classify_finish_reason
_choice = final_response.choices[0]
_truncated = classify_finish_reason(getattr(_choice, "finish_reason", None)) == "length_truncated"
if _continuation.take(messages, _choice.message.content, _truncated):
continue
if _continuation.text:
_choice.message.content = _continuation.stitch(_choice.message.content)
if _truncated and _choice.message.content and self._last_stop_reason == "completed":
self._last_stop_reason = "length_truncated"
break
# Usage is recorded per iteration inside the loop above so every billed
@@ -2669,7 +2876,7 @@ class OpenAIClient:
# sync/async non-streaming loops' (TypeError, ValueError)
# fallback.
try:
results_str = json.dumps(tool_result) if tool_result else "Function returned an empty output"
results_str = json.dumps(tool_result) if tool_result is not None else "Function returned an empty output"
except (TypeError, ValueError):
tool_result = {"result": str(tool_result)}
results_str = json.dumps(tool_result)
@@ -84,6 +84,7 @@ class RateLimiter:
def __post_init__(self):
"""Initialize internal state."""
self.max_retry_delay = max(0, self.max_retry_delay)
# Request-based limiting
if self.requests_per_minute is not None:
self._tokens = float(self.burst)
@@ -243,30 +243,20 @@ class OpenAIAdapter:
Delegates to async method following protocol requirements for
sync methods to be wrappers around the canonical async implementation.
"""
import asyncio
from ..utils.async_bridge import run_coroutine_from_any_context
# For streaming, explicitly disallow as it violates sync protocol contract
if kwargs.get('stream', False):
raise ValueError(
"Streaming is not supported in sync OpenAIAdapter. "
"Use achat_completion() for streaming support."
)
# Check if we're already in an event loop
try:
loop = asyncio.get_running_loop()
# In event loop - use thread pool to avoid nesting
import concurrent.futures
def run_in_thread():
return asyncio.run(self.achat_completion(messages, **kwargs))
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
# No running loop, safe to use asyncio.run
return asyncio.run(self.achat_completion(messages, **kwargs))
# Single shared bridge: copies contextvars, surfaces provider errors
# unchanged, and never nests asyncio.run inside a running loop.
return run_coroutine_from_any_context(
self.achat_completion(messages, **kwargs), timeout=None
)
class UnifiedLLMDispatcher:
@@ -53,6 +53,7 @@ class Cap(str, Enum):
STREAMING_WITH_TOOLS = "streaming_with_tools"
PARALLEL_TOOL_CALLS = "parallel_tool_calls"
EMBEDDINGS_ENDPOINT = "embeddings_endpoint"
SYSTEM_ONE = "system_one"
class Evidence(str, Enum):
@@ -105,6 +106,9 @@ _OLLAMA_CAP_MAP = {
"thinking": (Cap.THINKING,),
"embedding": (Cap.EMBEDDINGS_ENDPOINT,),
"audio": (Cap.AUDIO_IN,),
"decision": (Cap.SYSTEM_ONE,),
"systemone": (Cap.SYSTEM_ONE,),
"system_one": (Cap.SYSTEM_ONE,),
}
@@ -11,8 +11,6 @@ wrote. Orchestration (routing, parallel, repeat) still runs locally.
from __future__ import annotations
import asyncio
import concurrent.futures
import logging
import shlex
import threading
@@ -39,15 +37,14 @@ CAPABILITY_PROMPT = (
def _run_sync(coro):
"""Run ``coro`` to completion from sync code, even inside a running loop."""
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(coro)
# Already inside a loop (e.g. called from async workflow code): hand the
# coroutine to a private loop on another thread so we never re-enter.
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, coro).result()
"""Run ``coro`` to completion from sync code, even inside a running loop.
Routes through the shared bridge so contextvars propagate and the
coroutine's errors surface unchanged, whether or not a loop is running.
"""
from ..utils.async_bridge import run_coroutine_from_any_context
return run_coroutine_from_any_context(coro, timeout=None)
class SharedCompute:
+285 -92
View File
@@ -22,6 +22,26 @@ except ImportError:
StdioServerParameters = None
stdio_client = None
def _is_transport_closed(exc):
"""True when ``exc`` means the stdio session is gone for good."""
try:
import anyio
if isinstance(exc, (anyio.ClosedResourceError, anyio.BrokenResourceError, anyio.EndOfStream)):
return True
except ImportError:
pass
if isinstance(exc, (BrokenPipeError, ConnectionResetError, EOFError)):
return True
try:
from mcp.shared.exceptions import McpError
from mcp.types import CONNECTION_CLOSED
if isinstance(exc, McpError) and getattr(getattr(exc, "error", None), "code", None) == CONNECTION_CLOSED:
return True
except ImportError:
pass
return False
class MCPToolRunner(threading.Thread):
"""A dedicated thread for running MCP operations."""
@@ -31,6 +51,13 @@ class MCPToolRunner(threading.Thread):
self.queue = queue.Queue()
self.initialized = threading.Event()
self._init_error = None
# Set once the server's transport closes; the runner stops serving and
# callers get this message instead of queueing onto a dead session.
self._dead = None
# Startup task handle so stop() can cancel a hung initialize().
self._loop = None
self._main_task = None
self._stop_requested = False
self.tools = []
self.resources = []
self.resource_templates = []
@@ -41,12 +68,46 @@ class MCPToolRunner(threading.Thread):
self.start()
def run(self):
"""Main thread function that processes MCP requests."""
asyncio.run(self._run_async())
"""Main thread function that processes MCP requests.
On Windows the stdio transport must spawn a child process (e.g. ``npx``)
and talk to it over pipes. A non-main thread gets a ``SelectorEventLoop``
by default there, which does not support subprocesses and fails with an
opaque ``fileno`` error during init (issue #5598). Use a dedicated
``ProactorEventLoop`` on Windows so subprocess stdio works; other
platforms keep the standard ``asyncio.run`` behaviour.
"""
if platform.system() == 'Windows':
loop = asyncio.ProactorEventLoop()
try:
asyncio.set_event_loop(loop)
loop.run_until_complete(self._run_async())
finally:
# Mirror asyncio.run()'s graceful teardown so outstanding async
# generators and the default executor are finalized before the
# loop is closed (the subprocess transport relies on this).
try:
loop.run_until_complete(loop.shutdown_asyncgens())
except Exception:
pass
try:
loop.run_until_complete(loop.shutdown_default_executor())
except Exception:
pass
try:
loop.close()
finally:
asyncio.set_event_loop(None)
else:
asyncio.run(self._run_async())
async def _run_async(self):
"""Async entry point for MCP operations."""
self._loop = asyncio.get_running_loop()
self._main_task = asyncio.current_task()
try:
if self._stop_requested:
raise asyncio.CancelledError()
# Set up MCP session
async with stdio_client(self.server_params) as (read, write):
async with ClientSession(read, write) as session:
@@ -75,7 +136,11 @@ class MCPToolRunner(threading.Thread):
if item is None: # Shutdown signal
break
response_queue, kind, name, arguments = item
response_queue, kind, name, arguments, cancelled = item
if cancelled.is_set():
# Caller already gave up (timed out); never
# execute a call it was told did not happen.
continue
try:
if kind == "resource":
result = await asyncio.wait_for(session.read_resource(name), timeout=self.timeout)
@@ -87,7 +152,11 @@ class MCPToolRunner(threading.Thread):
except asyncio.TimeoutError:
response_queue.put((False, f"MCP {kind} call timed out after {self.timeout} seconds (server side)"))
except Exception as e:
response_queue.put((False, str(e)))
if _is_transport_closed(e):
self._dead = f"MCP server connection lost: {str(e) or type(e).__name__}"
response_queue.put((False, self._dead))
break # leave stdio_client() so the child is reaped
response_queue.put((False, str(e) or type(e).__name__))
except queue.Empty:
pass
@@ -95,9 +164,38 @@ class MCPToolRunner(threading.Thread):
await asyncio.sleep(0.01)
except asyncio.CancelledError:
break
except asyncio.CancelledError:
# stop() cancelled a startup that never completed; leaving the
# contexts above has already terminated the stdio child.
if not self.initialized.is_set():
self._init_error = "MCP initialization cancelled"
self.initialized.set()
except Exception as e:
self._init_error = f"MCP initialization error: {str(e)}"
if self.initialized.is_set():
# Session ended after a successful init (e.g. teardown of a
# crashed server); report it as a lost connection, not init.
self._dead = self._dead or f"MCP server connection lost: {str(e) or type(e).__name__}"
else:
self._init_error = f"MCP initialization error: {str(e)}"
self.initialized.set() # Ensure we don't hang
finally:
if self.initialized.is_set() and not self._init_error and not self._dead:
self._dead = "MCP server session ended"
self._fail_pending()
def _fail_pending(self):
"""Answer requests still queued after the loop exits so callers don't wait out the timeout."""
message = self._dead or self._init_error or "MCP server session ended"
while True:
try:
item = self.queue.get(block=False)
except queue.Empty:
return
if item is not None:
try:
item[0].put((False, message), block=False)
except Exception:
pass
async def _discover_resources_and_prompts(self, session):
"""Enumerate server resources, resource templates and prompts.
@@ -145,12 +243,18 @@ class MCPToolRunner(threading.Thread):
if self._init_error:
return (False, self._init_error)
if self._dead:
return (False, self._dead)
response_queue = queue.Queue(maxsize=1)
self.queue.put((response_queue, kind, name, arguments))
cancelled = threading.Event()
self.queue.put((response_queue, kind, name, arguments, cancelled))
if self._dead:
self._fail_pending() # the runner may have drained the queue before our put
try:
success, result = response_queue.get(timeout=self.timeout)
except queue.Empty:
cancelled.set()
return (False, f"MCP {kind} call timed out after {self.timeout} seconds")
if not success:
return (False, result)
@@ -194,6 +298,10 @@ class MCPToolRunner(threading.Thread):
if telemetry:
telemetry.track_tool_usage(tool_name, success=False, execution_time=0)
return f"Error: {self._init_error}"
if self._dead:
if telemetry:
telemetry.track_tool_usage(tool_name, success=False, execution_time=0)
return f"Error: {self._dead}"
# Start timing after initialization check
start_time = time.time()
@@ -201,12 +309,17 @@ class MCPToolRunner(threading.Thread):
response_queue = queue.Queue(maxsize=1)
try:
# Put request in queue with caller-specific response channel
self.queue.put((response_queue, "tool", tool_name, arguments))
cancelled = threading.Event()
self.queue.put((response_queue, "tool", tool_name, arguments, cancelled))
if self._dead:
self._fail_pending() # the runner may have drained the queue before our put
# Wait for result with timeout
try:
success, result = response_queue.get(timeout=self.timeout)
except queue.Empty:
# Mark abandoned so the runner skips it if not yet started
cancelled.set()
return f"Error: MCP tool call timed out after {self.timeout} seconds"
if not success:
return f"Error: {result}"
@@ -291,6 +404,14 @@ class MCPToolRunner(threading.Thread):
self.queue.put(None)
except Exception:
pass
# The sentinel is only read once init finishes, so cancel a startup
# that is still in progress (e.g. hung in session.initialize()).
self._stop_requested = True
if not self.initialized.is_set() and self._main_task is not None:
try:
self._loop.call_soon_threadsafe(self._main_task.cancel)
except RuntimeError:
pass # loop already closed
if timeout is None:
timeout = getattr(self, "timeout", 60)
if self.is_alive():
@@ -343,6 +464,10 @@ class MCP:
# skill requirement, once the last client holding it lets go (issue #5135).
_active_server_names: dict = {}
_active_server_names_lock = threading.Lock()
# Registrations a GC finalizer could not release because the lock was busy
# (possibly held by the frame the collector interrupted). ``list.append``
# is atomic; the next holder of the lock applies them.
_pending_name_releases: list = []
# Default init/tool-call timeout (seconds). Used when the caller does not
# pass an explicit ``timeout``.
@@ -361,8 +486,15 @@ class MCP:
def list_active_server_names(cls) -> set:
"""Return the set of MCP server names currently registered this run."""
with cls._active_server_names_lock:
cls._drain_pending_name_releases()
return {name for name, count in cls._active_server_names.items() if count > 0}
@classmethod
def _drain_pending_name_releases(cls) -> None:
"""Apply releases deferred by finalizers. Caller holds the lock."""
while cls._pending_name_releases:
cls._adjust_server_name_counts(cls._pending_name_releases.pop(), -1)
@classmethod
def _adjust_server_name_counts(cls, names: tuple, delta: int) -> None:
"""Add ``delta`` to each name's live count, dropping names reaching zero.
@@ -384,6 +516,7 @@ class MCP:
previous names after they have been handed over and release them twice.
"""
with type(self)._active_server_names_lock:
type(self)._drain_pending_name_releases()
previous = getattr(self, "_registered_server_names", None)
if previous == names:
return
@@ -393,20 +526,36 @@ class MCP:
type(self)._adjust_server_name_counts(names, 1)
self._registered_server_names = names or None
def _release_registered_names(self) -> None:
def _release_registered_names(self, *, blocking: bool = True) -> None:
"""Release this instance's registration, decrementing it exactly once.
Claiming the names and decrementing them share one critical section, so
concurrent lifecycle calls on the same instance (explicit
``shutdown()``, context-manager exit, agent cleanup, ``__del__``) cannot
each consume a shared client's count.
``blocking=False`` is for ``__del__``: the lock is non-reentrant and the
collector may have interrupted a frame on this thread that holds it.
If it is busy the claimed names are queued for the next lock holder.
Claiming without the lock is safe there because nothing else can still
reach an object that is being finalized.
"""
with type(self)._active_server_names_lock:
lock = type(self)._active_server_names_lock
if not lock.acquire(blocking=blocking):
names = getattr(self, "_registered_server_names", None)
if names:
self._registered_server_names = None
type(self)._pending_name_releases.append(names)
return
try:
type(self)._drain_pending_name_releases()
names = getattr(self, "_registered_server_names", None)
if not names:
return
self._registered_server_names = None
type(self)._adjust_server_name_counts(names, -1)
finally:
lock.release()
@classmethod
def _is_cold_start_launcher(cls, cmd) -> bool:
@@ -764,17 +913,17 @@ class MCP:
if has_resources:
def list_mcp_resources() -> str:
"""List the resources available from the connected MCP server."""
return _res.to_json(_res.resources_to_dicts(runner.resources))
return _res.to_json(_res.resources_to_dicts(self._live_stdio_runner(runner).resources))
def list_mcp_resource_templates() -> str:
"""List the resource templates available from the MCP server."""
return _res.to_json(
_res.resource_templates_to_dicts(runner.resource_templates)
_res.resource_templates_to_dicts(self._live_stdio_runner(runner).resource_templates)
)
def read_mcp_resource(uri: str) -> str:
"""Read a resource from the MCP server by its URI."""
return runner.read_resource(uri)
return self._live_stdio_runner(runner).read_resource(uri)
candidates.extend(
[list_mcp_resources, list_mcp_resource_templates, read_mcp_resource]
@@ -783,11 +932,11 @@ class MCP:
if has_prompts:
def list_mcp_prompts() -> str:
"""List the prompt templates available from the MCP server."""
return _res.to_json(_res.prompts_to_dicts(runner.prompts))
return _res.to_json(_res.prompts_to_dicts(self._live_stdio_runner(runner).prompts))
def get_mcp_prompt(name: str, arguments: dict = None) -> str:
"""Fetch an MCP prompt template rendered with the given arguments."""
return runner.get_prompt(name, arguments or {})
return self._live_stdio_runner(runner).get_prompt(name, arguments or {})
candidates.extend([list_mcp_prompts, get_mcp_prompt])
@@ -796,69 +945,80 @@ class MCP:
]
return synthetic
# Respawns allowed after a stdio server dies mid-session.
_MAX_STDIO_RESTARTS = 3
def _live_stdio_runner(self, fallback=None):
"""Return the current stdio runner, respawning it if its server died.
At most ``_MAX_STDIO_RESTARTS`` respawns; a replacement that fails to
start is marked dead so a later call may retry within that budget. No
replacement is kept once :meth:`shutdown` has begun.
"""
runner = getattr(self, "runner", None) or fallback
if runner is None or not isinstance(getattr(runner, "_dead", None), str):
return runner
lock = self.__dict__.setdefault("_restart_lock", threading.Lock())
with lock:
restarts = getattr(self, "_stdio_restarts", 0)
if (self.runner is runner and not getattr(self, "_closing", False)
and restarts < self._MAX_STDIO_RESTARTS):
self._stdio_restarts = restarts + 1
logging.warning(
"MCP stdio server lost (%s); restarting (%d/%d)",
runner._dead, self._stdio_restarts, self._MAX_STDIO_RESTARTS,
)
runner.stop(timeout=1)
new_runner = MCPToolRunner(runner.server_params, runner.timeout)
if not new_runner.initialized.wait(timeout=runner.timeout) or new_runner._init_error:
new_runner._dead = new_runner._init_error or "MCP server restart timed out"
new_runner.stop(timeout=1)
self.runner = new_runner
# shutdown() sets _closing before reading self.runner, so either
# it sees new_runner or we see _closing here.
if getattr(self, "_closing", False):
new_runner.stop(timeout=1)
return self.runner or runner
def _call_stdio_tool(self, tool_name, arguments):
"""Call a stdio tool, lazily respawning the runner if its server died.
The call that observed the crash is never replayed (it may already have
reached the server); the next call gets a fresh runner instead.
"""
runner = self._live_stdio_runner()
dead = getattr(runner, "_dead", None)
if isinstance(dead, str):
return f"Error: {dead}"
return runner.call_tool(tool_name, arguments)
def _create_tool_wrapper(self, tool):
"""Create a wrapper function for an MCP tool."""
# Determine parameter names from the schema
param_names = []
param_annotations = {}
required_params = []
if hasattr(tool, 'inputSchema') and tool.inputSchema:
properties = tool.inputSchema.get("properties", {})
required = tool.inputSchema.get("required", [])
for name, prop in properties.items():
param_names.append(name)
# Set annotation based on property type
prop_type = prop.get("type", "string")
if prop_type == "string":
param_annotations[name] = str
elif prop_type == "integer":
param_annotations[name] = int
elif prop_type == "number":
param_annotations[name] = float
elif prop_type == "boolean":
param_annotations[name] = bool
elif prop_type == "array":
param_annotations[name] = list
elif prop_type == "object":
param_annotations[name] = dict
else:
param_annotations[name] = Any
if name in required:
required_params.append(name)
# Create the function signature
# Separate required and optional parameters to ensure proper ordering
# (required parameters must come before optional parameters)
required_param_objects = []
optional_param_objects = []
for name in param_names:
is_required = name in required_params
param = inspect.Parameter(
name=name,
kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
default=inspect.Parameter.empty if is_required else None,
annotation=param_annotations.get(name, Any)
)
if is_required:
required_param_objects.append(param)
else:
optional_param_objects.append(param)
# Combine parameters with required first, then optional
params = required_param_objects + optional_param_objects
from .mcp_schema_utils import build_tool_signature, build_openai_tool_dict
input_schema = getattr(tool, 'inputSchema', None) or {}
signature = build_tool_signature(input_schema)
# Positional arguments map onto the named parameters of the signature,
# in declaration order. Non-identifier/keyword property names (``from``,
# ``max-results``) are handled through ``**kwargs``, so they are excluded
# here to keep positional mapping aligned with the visible parameters.
param_names = [
name
for name, param in signature.parameters.items()
if param.kind is not inspect.Parameter.VAR_KEYWORD
]
param_annotations = {
name: param.annotation
for name, param in signature.parameters.items()
if param.kind is not inspect.Parameter.VAR_KEYWORD
}
# Create function template to be properly decorated
def template_function(*args, **kwargs):
return None
# Create a proper function with the correct signature
template_function.__signature__ = inspect.Signature(params)
template_function.__signature__ = signature
template_function.__annotations__ = param_annotations
template_function.__name__ = tool.name
template_function.__qualname__ = tool.name
@@ -877,11 +1037,24 @@ class MCP:
all_args.update(kwargs)
# Call the tool
return self.runner.call_tool(tool.name, all_args)
return self._call_stdio_tool(tool.name, all_args)
# Make sure the wrapper has the correct signature for inspection
wrapper.__signature__ = inspect.Signature(params)
wrapper.__signature__ = signature
# Expose the server's original schema so schema builders (e.g.
# LLM._generate_tool_definition) surface every property instead of
# re-deriving from the signature, which omits ``**kwargs`` names
# (JSON Schema allows ``from``/``max-results`` that are not valid Python
# parameter names). The schema name tracks the wrapper's current
# ``__name__`` so it stays in sync after ``with_tool_prefix``.
def get_schema():
"""Return the OpenAI tool schema built from the server's original inputSchema."""
return build_openai_tool_dict(
wrapper.__name__, tool.description, input_schema
)
wrapper.get_schema = get_schema
return wrapper
def _initialize_npx_mcp_tools(self, cmd, arguments):
@@ -1336,6 +1509,14 @@ class MCP:
Call this method when done using the MCP instance to ensure
all background threads and connections are properly cleaned up.
"""
self._shutdown(from_finalizer=False)
def _shutdown(self, *, from_finalizer: bool) -> None:
"""Shared teardown. ``from_finalizer`` never blocks: it signals the
stdio runner instead of joining it (a join can wait up to the operation
timeout inside the garbage collector) and releases the server-name
registration without waiting on its lock."""
self._closing = True # stop _live_stdio_runner from installing a replacement
# Shutdown stdio runner if present
if hasattr(self, 'runner') and self.runner is not None:
try:
@@ -1345,18 +1526,33 @@ class MCP:
# Join the daemon thread (and terminate its stdio subprocess) so
# long-lived processes don't leak one child per MCP server. Plain
# shutdown() only enqueues a sentinel; stop() also joins the thread.
try:
if hasattr(self.runner, "stop"):
self.runner.stop()
except Exception:
pass # Best effort cleanup
# Only drop the reference once the thread has actually exited so a
# later shutdown() can retry the join if an in-flight call kept the
# runner alive past the join timeout. The thread is a daemon, so a
# surviving runner never blocks interpreter exit.
if not getattr(self.runner, "is_alive", lambda: False)():
self.runner = None
# Not from a finalizer: the sentinel above lets the daemon exit.
if not from_finalizer:
try:
if hasattr(self.runner, "stop"):
self.runner.stop()
except Exception:
pass # Best effort cleanup
# Only drop the reference once the thread has actually exited so a
# later shutdown() can retry the join if an in-flight call kept the
# runner alive past the join timeout. The thread is a daemon, so a
# surviving runner never blocks interpreter exit.
if not getattr(self.runner, "is_alive", lambda: False)():
self.runner = None
self._close_transport_clients(wait=not from_finalizer)
# Drop this server from the process-level registry now that its
# connections are gone, so skills' CapabilityValidator stops reporting
# it as available (issue #5135). Runs last so the registry only shrinks
# once the transports above have actually been torn down.
self._release_registered_names(blocking=not from_finalizer)
def _close_transport_clients(self, wait: bool = True) -> None:
"""Close SSE / HTTP-stream / WebSocket clients (best effort).
``wait=False`` (finalizer path) schedules the WebSocket close instead of
waiting up to 5 s for it on the transport's event loop."""
# Shutdown SSE client if present
if hasattr(self, 'sse_client') and self.sse_client is not None:
try:
@@ -1383,15 +1579,12 @@ class MCP:
if hasattr(self.websocket_client, 'shutdown'):
self.websocket_client.shutdown()
elif hasattr(self.websocket_client, 'close'):
self.websocket_client.close()
if wait:
self.websocket_client.close()
else:
self.websocket_client.close(wait=False)
except Exception:
pass
# Drop this server from the process-level registry now that its
# connections are gone, so skills' CapabilityValidator stops reporting
# it as available (issue #5135). Runs last so the registry only shrinks
# once the transports above have actually been torn down.
self._release_registered_names()
def __del__(self):
"""Clean up resources when the object is garbage collected.
@@ -1401,6 +1594,6 @@ class MCP:
pattern or call shutdown() explicitly.
"""
try:
self.shutdown()
self._shutdown(from_finalizer=True)
except Exception:
pass # Best effort cleanup in __del__
@@ -17,6 +17,11 @@ import time
from pathlib import Path
from typing import Any, Dict, Optional
from .._logging import get_logger
from ..utils.atomic_io import update_json
logger = get_logger(__name__)
def get_default_auth_filepath() -> str:
"""
@@ -74,22 +79,56 @@ class MCPAuthStorage:
parent.mkdir(parents=True, exist_ok=True)
def _read(self) -> Dict[str, Any]:
"""Read all entries from storage file."""
"""Read all entries from storage file.
A corrupt file is moved aside to a unique ``.corrupt`` sidecar and
logged rather than silently treated as "no credentials", so a torn
write can never make every server's tokens vanish without a trace.
The quarantine happens *under the same FileLock the writers use*, and
the file is re-read inside the lock first: if a concurrent writer
repaired the store between the first decode error and acquiring the
lock, we return the repaired data instead of quarantining valid
credentials.
"""
try:
with open(self.filepath, 'r') as f:
with open(self.filepath, 'r', encoding='utf-8') as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
except FileNotFoundError:
return {}
except (json.JSONDecodeError, ValueError):
pass
# Corrupt on the optimistic (lock-free) read. Take the writer lock and
# re-read before quarantining so we never move a file another process
# just repaired.
from ..session.store import FileLock
from ..utils.atomic_io import quarantine_corrupt_file
try:
with FileLock(self.filepath, timeout=10):
try:
with open(self.filepath, 'r', encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError:
return {}
except (json.JSONDecodeError, ValueError):
try:
dest = quarantine_corrupt_file(self.filepath)
logger.error(
"MCP auth store was corrupt; moved aside to %s",
dest,
)
except OSError:
logger.error(
"MCP auth store at %s is corrupt and could not be "
"quarantined", self.filepath,
)
return {}
except (OSError, IOError):
# Could not acquire the lock; fall back to the previous behaviour
# of reporting empty rather than blocking a read path.
return {}
def _write(self, data: Dict[str, Any]) -> None:
"""Write all entries to storage file with secure permissions."""
# Write to file
with open(self.filepath, 'w') as f:
json.dump(data, f, indent=2)
# Set file permissions to 0600 (owner read/write only)
if os.name != 'nt': # Unix-like systems
os.chmod(self.filepath, 0o600)
def get(self, mcp_name: str) -> Optional[Dict[str, Any]]:
"""
@@ -143,20 +182,34 @@ class MCPAuthStorage:
def _set(self, mcp_name: str, entry: Dict[str, Any], server_url: Optional[str] = None) -> None:
"""
Set an auth entry for an MCP server.
Uses a locked atomic read-modify-write so concurrent OAuth flows (across
threads or processes) no longer lose each other's entries, and a crash
mid-write leaves the previous file intact. The temp file created by
``atomic_write_json`` is mode 0600, so secrets are never world-readable,
even for the moment between create and chmod.
Args:
mcp_name: Name of the MCP server
entry: Auth entry dict
server_url: Optional server URL to track
"""
data = self._read()
# Update server_url if provided
if server_url:
entry["server_url"] = server_url
data[mcp_name] = entry
self._write(data)
def mutate(data: Dict[str, Any]) -> None:
data[mcp_name] = entry
update_json(self.filepath, mutate, indent=2)
# Tighten to 0600 after the atomic swap. mkstemp already created the temp
# file as 0600 so there is no world-readable window; this keeps the mode
# correct across platforms and pre-existing files.
if os.name != 'nt':
try:
os.chmod(self.filepath, 0o600)
except OSError:
pass
def set_tokens(
self,
@@ -201,10 +254,15 @@ class MCPAuthStorage:
Args:
mcp_name: Name of the MCP server
"""
data = self._read()
if mcp_name in data:
del data[mcp_name]
self._write(data)
def mutate(data: Dict[str, Any]) -> None:
data.pop(mcp_name, None)
update_json(self.filepath, mutate, indent=2)
if os.name != 'nt':
try:
os.chmod(self.filepath, 0o600)
except OSError:
pass
def set_code_verifier(self, mcp_name: str, code_verifier: str) -> None:
"""
@@ -8,7 +8,6 @@ import asyncio
import atexit
from praisonaiagents._logging import get_logger
import threading
import inspect
import json
import time
import uuid
@@ -35,7 +34,8 @@ except ImportError:
logger = get_logger("mcp-http-stream")
# Import shared utilities for thread-safe event loop and schema fixing
from .mcp_schema_utils import ThreadLocalEventLoop, build_openai_tool_dict
from ..trace.redact import redact_string
from .mcp_schema_utils import ThreadLocalEventLoop, build_openai_tool_dict, build_tool_signature, get_running_loop_or_none
# Thread-local event loop for async operations (thread-safe)
_event_loop_manager = ThreadLocalEventLoop()
@@ -130,6 +130,7 @@ class HTTPStreamMCPTool:
"""A wrapper for an MCP tool that can be used with praisonaiagents."""
def __init__(self, name: str, description: str, session: ClientSession, input_schema: Optional[Dict[str, Any]] = None, timeout: int = 60):
"""Store the tool metadata, build its call signature, and remember the session's event loop."""
self.name = name
self.__name__ = name # Required for Agent to recognize it as a tool
self.__qualname__ = name # Required for Agent to recognize it as a tool
@@ -140,43 +141,16 @@ class HTTPStreamMCPTool:
self.timeout = timeout
# Create a signature based on input schema
params = []
if input_schema and 'properties' in input_schema:
for param_name, prop_schema in input_schema['properties'].items():
# Determine type annotation based on schema
prop_type = prop_schema.get('type', 'string') if isinstance(prop_schema, dict) else 'string'
if prop_type == 'string':
annotation = str
elif prop_type == 'integer':
annotation = int
elif prop_type == 'number':
annotation = float
elif prop_type == 'boolean':
annotation = bool
elif prop_type == 'array':
annotation = list
elif prop_type == 'object':
annotation = dict
else:
annotation = Any
params.append(
inspect.Parameter(
name=param_name,
kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
default=inspect.Parameter.empty if param_name in input_schema.get('required', []) else None,
annotation=annotation
)
)
self.__signature__ = inspect.Signature(params)
self.__signature__ = build_tool_signature(input_schema)
# The client creates this wrapper on the loop that owns ``session``
self._loop = get_running_loop_or_none()
def __call__(self, **kwargs):
"""Synchronous wrapper for the async call."""
logger.debug(f"Tool {self.name} called with args: {kwargs}")
# Use the global event loop
loop = get_event_loop()
# Run on the session's loop; the caller's thread-local loop is not running
loop = self._loop or get_event_loop()
# Run the async call in the event loop
future = asyncio.run_coroutine_threadsafe(self._async_call(**kwargs), loop)
@@ -534,7 +508,8 @@ class HTTPStreamMCPClient:
raise ImportError(
"The installed 'mcp' package does not provide the Streamable HTTP "
"client (mcp.client.streamable_http.streamablehttp_client). "
"Upgrade it with: pip install -U 'mcp'"
"This transport was removed in mcp 2.x; install a compatible "
"version with: pip install \"mcp<2\""
)
# Use the server URL exactly as provided. The official
@@ -590,7 +565,7 @@ class HTTPStreamMCPClient:
async def _async_initialize(self):
"""Asynchronously initialize the connection and tools."""
logger.debug(f"Connecting to MCP server at {self.base_url}")
logger.debug(f"Connecting to MCP server at {redact_string(self.base_url)}")
# Use the official MCP SDK's Streamable HTTP client so that the read and
# write streams are proper anyio memory-object streams that ClientSession
@@ -600,7 +575,8 @@ class HTTPStreamMCPClient:
raise ImportError(
"The installed 'mcp' package does not provide "
"mcp.client.streamable_http.streamablehttp_client. "
"Upgrade it with: pip install -U 'mcp'"
"This transport was removed in mcp 2.x; install a compatible "
"version with: pip install \"mcp<2\""
)
headers = self.options.get('headers') or None
@@ -9,8 +9,10 @@ DRY: This module centralizes schema fixing logic that was previously
duplicated across mcp.py, mcp_sse.py, mcp_http_stream.py, and mcp_websocket.py.
"""
import inspect
import keyword
import threading
from typing import Any, Dict
from typing import Any, Dict, Optional
def fix_array_schemas(schema: Any) -> Any:
@@ -66,6 +68,71 @@ def build_openai_tool_dict(name: str, description: str, input_schema: Any) -> Di
}
_JSON_TYPE_ANNOTATIONS = {
"string": str,
"integer": int,
"number": float,
"boolean": bool,
"array": list,
"object": dict,
}
def build_tool_signature(input_schema: Optional[Dict[str, Any]]) -> inspect.Signature:
"""
Build the Python signature of an MCP tool wrapper from its input schema.
DRY: shared by the stdio, SSE, HTTP-stream, and WebSocket tool wrappers.
MCP input schemas are JSON Schema, so a property name need not be a valid
Python parameter name (``from``, ``class``, ``max-results``), and required
properties may follow optional ones. ``inspect.Parameter`` rejects both,
which would otherwise abort the whole server connection. Representable
properties become named parameters, required ones first; the others are
accepted through a trailing ``**kwargs``, so callers still pass them by
their original names. The signature is for introspection only: the schema
sent to the LLM is built from ``input_schema`` itself.
Args:
input_schema: JSON Schema for the tool input
Returns:
inspect.Signature: Signature describing the tool's arguments
"""
schema = input_schema if isinstance(input_schema, dict) else {}
properties = schema.get("properties")
if not isinstance(properties, dict):
return inspect.Signature([])
required = schema.get("required") or []
required_params = []
optional_params = []
has_unnamed = False
for name, prop_schema in properties.items():
if not isinstance(name, str) or not name.isidentifier() or keyword.iskeyword(name):
has_unnamed = True
continue
prop_type = prop_schema.get("type", "string") if isinstance(prop_schema, dict) else "string"
annotation = _JSON_TYPE_ANNOTATIONS.get(prop_type, Any) if isinstance(prop_type, str) else Any
is_required = name in required
param = inspect.Parameter(
name=name,
kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
default=inspect.Parameter.empty if is_required else None,
annotation=annotation,
)
(required_params if is_required else optional_params).append(param)
params = required_params + optional_params
if has_unnamed:
taken = {param.name for param in params}
var_keyword = "kwargs"
while var_keyword in taken:
var_keyword = f"_{var_keyword}"
params.append(inspect.Parameter(var_keyword, inspect.Parameter.VAR_KEYWORD))
return inspect.Signature(params)
class ThreadLocalEventLoop:
"""
Thread-local event loop storage for MCP transports.
@@ -144,3 +211,24 @@ def get_thread_local_event_loop():
asyncio.AbstractEventLoop: Event loop for current thread
"""
return _default_event_loop_manager.get_loop()
def get_running_loop_or_none():
"""
Return the event loop running in the current thread, or ``None``.
The SSE, HTTP-stream, and WebSocket tool wrappers are created inside their
client's ``_async_initialize`` coroutine, so this captures the loop that
owns the MCP session. Their synchronous ``__call__`` must schedule work on
that loop: callers such as the agent's tool executor run in other threads,
where the thread-local loop exists but is not running.
Returns:
asyncio.AbstractEventLoop or None: The running loop, if any
"""
import asyncio
try:
return asyncio.get_running_loop()
except RuntimeError:
return None
@@ -7,7 +7,6 @@ over SSE transport.
import asyncio
from praisonaiagents._logging import get_logger
import threading
import inspect
import json
from typing import List, Dict, Any, Optional, Callable, Iterable
@@ -23,7 +22,8 @@ except ImportError:
logger = get_logger("mcp-sse")
# Import shared utilities for thread-safe event loop and schema fixing
from .mcp_schema_utils import ThreadLocalEventLoop, build_openai_tool_dict
from ..trace.redact import redact_string
from .mcp_schema_utils import ThreadLocalEventLoop, build_openai_tool_dict, build_tool_signature, get_running_loop_or_none
# Thread-local event loop for async operations (thread-safe)
_event_loop_manager = ThreadLocalEventLoop()
@@ -36,6 +36,7 @@ class SSEMCPTool:
"""A wrapper for an MCP tool that can be used with praisonaiagents."""
def __init__(self, name: str, description: str, session: ClientSession, input_schema: Optional[Dict[str, Any]] = None, timeout: int = 60):
"""Store the tool metadata, build its call signature, and remember the session's event loop."""
self.name = name
self.__name__ = name # Required for Agent to recognize it as a tool
self.__qualname__ = name # Required for Agent to recognize it as a tool
@@ -46,43 +47,16 @@ class SSEMCPTool:
self.timeout = timeout
# Create a signature based on input schema
params = []
if input_schema and 'properties' in input_schema:
for param_name, prop_schema in input_schema['properties'].items():
# Determine type annotation based on schema
prop_type = prop_schema.get('type', 'string') if isinstance(prop_schema, dict) else 'string'
if prop_type == 'string':
annotation = str
elif prop_type == 'integer':
annotation = int
elif prop_type == 'number':
annotation = float
elif prop_type == 'boolean':
annotation = bool
elif prop_type == 'array':
annotation = list
elif prop_type == 'object':
annotation = dict
else:
annotation = str # Default to string for SSE
params.append(
inspect.Parameter(
name=param_name,
kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
default=inspect.Parameter.empty if param_name in input_schema.get('required', []) else None,
annotation=annotation
)
)
self.__signature__ = inspect.Signature(params)
self.__signature__ = build_tool_signature(input_schema)
# The client creates this wrapper on the loop that owns ``session``
self._loop = get_running_loop_or_none()
def __call__(self, **kwargs):
"""Synchronous wrapper for the async call."""
logger.debug(f"Tool {self.name} called with args: {kwargs}")
# Use the global event loop
loop = get_event_loop()
# Run on the session's loop; the caller's thread-local loop is not running
loop = self._loop or get_event_loop()
# Run the async call in the event loop
future = asyncio.run_coroutine_threadsafe(self._async_call(**kwargs), loop)
@@ -166,7 +140,7 @@ class SSEMCPClient:
async def _async_initialize(self):
"""Asynchronously initialize the connection and tools."""
logger.debug(f"Connecting to MCP server at {self.server_url}")
logger.debug(f"Connecting to MCP server at {redact_string(self.server_url)}")
# Create SSE client
self._streams_context = sse_client(url=self.server_url)
@@ -20,14 +20,14 @@ import asyncio
import json
from praisonaiagents._logging import get_logger
import threading
import inspect
import re
from typing import Any, Dict, Optional, List
logger = get_logger("mcp-websocket")
# Import shared utilities for thread-safe event loop and schema fixing
from .mcp_schema_utils import ThreadLocalEventLoop, build_openai_tool_dict
from ..trace.redact import redact_string
from .mcp_schema_utils import ThreadLocalEventLoop, build_openai_tool_dict, build_tool_signature, get_running_loop_or_none
def is_websocket_url(url: str) -> bool:
"""
@@ -169,7 +169,7 @@ class WebSocketTransport:
),
timeout=self.timeout
)
logger.debug(f"WebSocket connected to {self.url}")
logger.debug(f"WebSocket connected to {redact_string(self.url)}")
# Start background receive task
self._receive_task = asyncio.create_task(self._receive_loop())
@@ -277,45 +277,9 @@ class WebSocketMCPTool:
self.timeout = timeout
# Build function signature from input schema
self.__signature__ = self._build_signature()
def _build_signature(self) -> inspect.Signature:
"""Build function signature from input schema."""
params = []
if self.input_schema and 'properties' in self.input_schema:
required = self.input_schema.get('required', [])
for param_name, prop_schema in self.input_schema['properties'].items():
# Determine type annotation
prop_type = prop_schema.get('type', 'string') if isinstance(prop_schema, dict) else 'string'
annotation = self._json_type_to_python(prop_type)
# Determine default value
default = inspect.Parameter.empty if param_name in required else None
params.append(
inspect.Parameter(
name=param_name,
kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
default=default,
annotation=annotation
)
)
return inspect.Signature(params)
def _json_type_to_python(self, json_type: str) -> type:
"""Convert JSON Schema type to Python type."""
type_map = {
'string': str,
'integer': int,
'number': float,
'boolean': bool,
'array': list,
'object': dict
}
return type_map.get(json_type, Any)
self.__signature__ = build_tool_signature(self.input_schema)
# The client creates this wrapper on the loop that owns ``session``
self._loop = get_running_loop_or_none()
def __call__(self, **kwargs):
"""
@@ -325,7 +289,8 @@ class WebSocketMCPTool:
"""
logger.debug(f"Tool {self.name} called with args: {kwargs}")
loop = get_event_loop()
# Run on the session's loop; the caller's thread-local loop is not running
loop = self._loop or get_event_loop()
future = asyncio.run_coroutine_threadsafe(self._async_call(**kwargs), loop)
try:
@@ -403,6 +368,11 @@ class WebSocketMCPClient:
self.transport = None
self.tools: List[WebSocketMCPTool] = []
self._closed = False
# The loop that owns ``self.session``. ``get_event_loop()`` is
# thread-local, so close() must not look it up again: a finalizer or a
# caller on another thread would get a different, idle loop and the
# scheduled cleanup would never run.
self._loop = None
# Configure logging
if debug:
@@ -416,6 +386,7 @@ class WebSocketMCPClient:
def _initialize(self):
"""Initialize the connection and discover tools."""
loop = get_event_loop()
self._loop = loop
# Start event loop in background thread if not running
def run_event_loop():
@@ -431,7 +402,7 @@ class WebSocketMCPClient:
async def _async_initialize(self):
"""Asynchronously initialize connection and discover tools."""
logger.debug(f"Connecting to MCP server at {self.server_url}")
logger.debug(f"Connecting to MCP server at {redact_string(self.server_url)}")
# Lazy import mcp package
try:
@@ -529,16 +500,20 @@ class WebSocketMCPClient:
except Exception:
pass
def close(self):
"""Synchronous cleanup."""
def close(self, wait: bool = True):
"""Synchronous cleanup. ``wait=False`` only schedules it (finalizers)."""
if self._closed:
return
try:
loop = get_event_loop()
if not loop.is_closed():
loop = getattr(self, "_loop", None)
if loop is not None and not loop.is_closed():
future = asyncio.run_coroutine_threadsafe(self.aclose(), loop)
future.result(timeout=5)
if wait:
future.result(timeout=5)
else:
# Never connected (or its loop is gone): nothing to close.
self._closed = True
except Exception:
self._closed = True
@@ -546,6 +521,6 @@ class WebSocketMCPClient:
"""Cleanup on garbage collection."""
try:
if not self._closed:
self.close()
self.close(wait=False)
except Exception:
pass
@@ -6,6 +6,7 @@ No dependencies required - stores data as plain Python lists.
"""
import threading
from copy import deepcopy
from typing import Any, Dict, List, Optional
@@ -15,6 +16,8 @@ class InMemoryAdapter:
Stores short-term and long-term memories as plain Python lists.
Data is not persisted across process restarts.
Metadata must support deepcopy; writes and reads own independent copies so
caller edits cannot change stored records outside the adapter's lock.
Usage:
adapter = InMemoryAdapter()
@@ -48,7 +51,7 @@ class InMemoryAdapter:
"id": str(self._next_id),
"text": text,
"type": "short",
"metadata": metadata,
"metadata": deepcopy(metadata),
}
self._next_id += 1
self._data.append(entry)
@@ -64,7 +67,7 @@ class InMemoryAdapter:
for e in self._data
if e["type"] == "short" and query.lower() in e["text"].lower()
]
return results[:limit]
return deepcopy(results[:limit])
def store_long_term(
self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs
@@ -74,7 +77,7 @@ class InMemoryAdapter:
"id": str(self._next_id),
"text": text,
"type": "long",
"metadata": metadata,
"metadata": deepcopy(metadata),
}
self._next_id += 1
self._data.append(entry)
@@ -90,7 +93,7 @@ class InMemoryAdapter:
for e in self._data
if e["type"] == "long" and query.lower() in e["text"].lower()
]
return results[:limit]
return deepcopy(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.
@@ -123,5 +126,8 @@ class InMemoryAdapter:
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
with self._lock:
# Return defensive copy to prevent external mutation of internal state
return [dict(entry) for entry in self._data]
# Entries are owned on insertion and never mutated afterward.
# Keep their membership snapshot under lock, then copy nested data
# without blocking stores, deletes, or eviction.
snapshot = list(self._data)
return deepcopy(snapshot)
@@ -12,6 +12,9 @@ import sqlite3
import json
from praisonaiagents._logging import get_logger
import threading
import weakref
from contextlib import nullcontext
from uuid import uuid4
from typing import Any, Dict, List, Optional
from datetime import datetime
@@ -19,6 +22,16 @@ from ..protocols import MemoryProtocol
logger = get_logger(__name__)
def _metadata_user_id(raw):
"""Match the existing Python JSON reader, including non-finite values."""
try:
metadata = json.loads(raw) if raw else {}
return str(metadata["user_id"]) if isinstance(metadata, dict) and metadata.get("user_id") is not None else None
except (TypeError, ValueError):
return None
class SqliteMemoryAdapter:
"""
SQLite-based memory adapter implementing MemoryProtocol.
@@ -54,16 +67,29 @@ class SqliteMemoryAdapter:
self.short_db = short_db
self.long_db = long_db
self.verbose = verbose
# Each adapter/tier owns one named in-memory database. Plain :memory:
# would create a different database for every thread-local connection.
self._short_target = (
f"file:praisonai-short-{uuid4().hex}?mode=memory&cache=shared"
if short_db == ":memory:" else short_db
)
self._long_target = (
f"file:praisonai-long-{uuid4().hex}?mode=memory&cache=shared"
if long_db == ":memory:" else long_db
)
# Thread-local storage for SQLite connections (thread-safe)
self._local = threading.local()
# Write lock for serializing database modifications (thread-safe)
self._write_lock = threading.Lock()
self._write_lock = threading.RLock()
# Connection registry for cleanup across threads
self._all_connections = set()
self._connection_lock = threading.Lock()
# Owning thread (and shared in-memory target, if any) per connection,
# so connections of dead threads can be reclaimed
self._connection_owners: Dict[Any, tuple] = {}
# Set logger level based on verbose
if verbose >= 5:
@@ -73,52 +99,89 @@ class SqliteMemoryAdapter:
def _get_stm_conn(self):
"""Get thread-local short-term memory connection."""
if getattr(self._local, 'stm_conn', None) is None:
self._local.stm_conn = sqlite3.connect(
self.short_db,
check_same_thread=False,
timeout=10.0
)
self._local.stm_conn.execute("""
CREATE TABLE IF NOT EXISTS short_term_memory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
metadata TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
with self._write_lock if self.short_db == ":memory:" else nullcontext():
if getattr(self._local, 'stm_conn', None) is None:
self._local.stm_conn = sqlite3.connect(
self._short_target,
uri=self.short_db == ":memory:",
check_same_thread=False,
timeout=10.0
)
""")
self._local.stm_conn.commit()
self._local.stm_conn.create_function("memory_user_id", 1, _metadata_user_id, deterministic=True)
self._local.stm_conn.execute("""
CREATE TABLE IF NOT EXISTS short_term_memory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
metadata TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
self._local.stm_conn.commit()
# Register connection for cleanup
with self._connection_lock:
self._all_connections.add(self._local.stm_conn)
# Register connection for cleanup
self._register_connection(
self._local.stm_conn,
self._short_target if self.short_db == ":memory:" else None,
)
return self._local.stm_conn
return self._local.stm_conn
def _get_ltm_conn(self):
"""Get thread-local long-term memory connection."""
if getattr(self._local, 'ltm_conn', None) is None:
self._local.ltm_conn = sqlite3.connect(
self.long_db,
check_same_thread=False,
timeout=10.0
)
self._local.ltm_conn.execute("""
CREATE TABLE IF NOT EXISTS long_term_memory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
metadata TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
with self._write_lock if self.long_db == ":memory:" else nullcontext():
if getattr(self._local, 'ltm_conn', None) is None:
self._local.ltm_conn = sqlite3.connect(
self._long_target,
uri=self.long_db == ":memory:",
check_same_thread=False,
timeout=10.0
)
""")
self._local.ltm_conn.commit()
self._local.ltm_conn.create_function("memory_user_id", 1, _metadata_user_id, deterministic=True)
self._local.ltm_conn.execute("""
CREATE TABLE IF NOT EXISTS long_term_memory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
metadata TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
self._local.ltm_conn.commit()
# Register connection for cleanup
with self._connection_lock:
self._all_connections.add(self._local.ltm_conn)
# Register connection for cleanup
self._register_connection(
self._local.ltm_conn,
self._long_target if self.long_db == ":memory:" else None,
)
return self._local.ltm_conn
return self._local.ltm_conn
def _register_connection(self, conn, memory_target: Optional[str] = None) -> None:
"""Track ``conn`` for cleanup and close connections of dead threads.
Connections are thread-local, so once their owning thread exits nothing
can reach them except this registry. Sweeping on each new registration
keeps short-lived threads from leaking one connection (and fd) each.
``memory_target`` names a shared in-memory database: SQLite drops it when
its last connection closes, so one connection to it is always kept.
"""
with self._connection_lock:
self._all_connections.add(conn)
self._connection_owners[conn] = (weakref.ref(threading.current_thread()), memory_target)
for old, (owner, target) in list(self._connection_owners.items()):
thread = owner()
if thread is not None and thread.is_alive():
continue
if target is not None and not any(
t == target for c, (_, t) in self._connection_owners.items() if c is not old
):
continue
del self._connection_owners[old]
self._all_connections.discard(old)
try:
old.close()
except Exception:
pass
def store_short_term(
self,
text: str,
@@ -127,13 +190,12 @@ class SqliteMemoryAdapter:
) -> str:
"""Store content in short-term memory."""
conn = self._get_stm_conn()
with self._write_lock:
with self._write_lock, conn:
cursor = conn.execute(
"INSERT INTO short_term_memory (content, metadata) VALUES (?, ?)",
(text, json.dumps(metadata or {}))
)
conn.commit()
return str(cursor.lastrowid)
return str(cursor.lastrowid)
def search_short_term(
self,
@@ -142,23 +204,27 @@ class SqliteMemoryAdapter:
**kwargs
) -> List[Dict[str, Any]]:
"""Search short-term memory."""
conn = self._get_stm_conn()
cursor = conn.execute(
"SELECT id, content, metadata, timestamp FROM short_term_memory "
"WHERE content LIKE ? ORDER BY timestamp DESC LIMIT ?",
(f"%{query}%", limit)
)
with self._write_lock if self.short_db == ":memory:" else nullcontext():
conn = self._get_stm_conn()
user_id = kwargs.get("user_id")
user_filter = " AND memory_user_id(metadata) = ?" if user_id is not None else ""
params = (f"%{query}%", str(user_id), limit) if user_id is not None else (f"%{query}%", limit)
cursor = conn.execute(
"SELECT id, content, metadata, timestamp FROM short_term_memory "
"WHERE content LIKE ?" + user_filter + " ORDER BY timestamp DESC LIMIT ?",
params
)
results = []
for row in cursor.fetchall():
results.append({
"id": str(row[0]),
"text": row[1],
"metadata": json.loads(row[2]) if row[2] else {},
"timestamp": row[3]
})
results = []
for row in cursor.fetchall():
results.append({
"id": str(row[0]),
"text": row[1],
"metadata": json.loads(row[2]) if row[2] else {},
"timestamp": row[3]
})
return results
return results
def store_long_term(
self,
@@ -168,13 +234,12 @@ class SqliteMemoryAdapter:
) -> str:
"""Store content in long-term memory."""
conn = self._get_ltm_conn()
with self._write_lock:
with self._write_lock, conn:
cursor = conn.execute(
"INSERT INTO long_term_memory (content, metadata) VALUES (?, ?)",
(text, json.dumps(metadata or {}))
)
conn.commit()
return str(cursor.lastrowid)
return str(cursor.lastrowid)
def search_long_term(
self,
@@ -183,23 +248,27 @@ class SqliteMemoryAdapter:
**kwargs
) -> List[Dict[str, Any]]:
"""Search long-term memory."""
conn = self._get_ltm_conn()
cursor = conn.execute(
"SELECT id, content, metadata, timestamp FROM long_term_memory "
"WHERE content LIKE ? ORDER BY timestamp DESC LIMIT ?",
(f"%{query}%", limit)
)
with self._write_lock if self.long_db == ":memory:" else nullcontext():
conn = self._get_ltm_conn()
user_id = kwargs.get("user_id")
user_filter = " AND memory_user_id(metadata) = ?" if user_id is not None else ""
params = (f"%{query}%", str(user_id), limit) if user_id is not None else (f"%{query}%", limit)
cursor = conn.execute(
"SELECT id, content, metadata, timestamp FROM long_term_memory "
"WHERE content LIKE ?" + user_filter + " ORDER BY timestamp DESC LIMIT ?",
params
)
results = []
for row in cursor.fetchall():
results.append({
"id": str(row[0]),
"text": row[1],
"metadata": json.loads(row[2]) if row[2] else {},
"timestamp": row[3]
})
results = []
for row in cursor.fetchall():
results.append({
"id": str(row[0]),
"text": row[1],
"metadata": json.loads(row[2]) if row[2] else {},
"timestamp": row[3]
})
return results
return results
def delete_memory(self, memory_id: str, tier: Optional[str] = None, **kwargs) -> bool:
"""Delete a memory by ID.
@@ -215,23 +284,23 @@ class SqliteMemoryAdapter:
if tier in (None, "short"):
try:
stm = self._get_stm_conn()
cur = stm.execute(
"DELETE FROM short_term_memory WHERE id = ?", (memory_id,)
)
with stm:
cur = stm.execute(
"DELETE FROM short_term_memory WHERE id = ?", (memory_id,)
)
if cur.rowcount > 0:
deleted = True
stm.commit()
except Exception as e:
logger.warning(f"Adapter delete_memory (short_term) failed: {e}")
if tier in (None, "long"):
try:
ltm = self._get_ltm_conn()
cur = ltm.execute(
"DELETE FROM long_term_memory WHERE id = ?", (memory_id,)
)
with ltm:
cur = ltm.execute(
"DELETE FROM long_term_memory WHERE id = ?", (memory_id,)
)
if cur.rowcount > 0:
deleted = True
ltm.commit()
except Exception as e:
logger.warning(f"Adapter delete_memory (long_term) failed: {e}")
return deleted
@@ -239,21 +308,21 @@ class SqliteMemoryAdapter:
def reset_short_term(self) -> None:
"""Clear all short-term memories."""
conn = self._get_stm_conn()
with self._write_lock:
with self._write_lock, conn:
conn.execute("DELETE FROM short_term_memory")
conn.commit()
def reset_long_term(self) -> None:
"""Clear all long-term memories."""
conn = self._get_ltm_conn()
with self._write_lock:
with self._write_lock, conn:
conn.execute("DELETE FROM long_term_memory")
conn.commit()
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
"""Get all memories from both short-term and long-term."""
short_memories = self.search_short_term("", limit=1000)
long_memories = self.search_long_term("", limit=1000)
# SQLite's negative LIMIT means no upper bound. Enumeration must return
# every row; ordinary searches retain their default/explicit limits.
short_memories = self.search_short_term("", limit=-1)
long_memories = self.search_long_term("", limit=-1)
# Mark memory types
for memory in short_memories:
@@ -273,6 +342,7 @@ class SqliteMemoryAdapter:
except Exception:
pass
self._all_connections.clear()
self._connection_owners.clear()
def close_thread_connections(self):
"""Close only the calling thread's connections.
@@ -298,4 +368,5 @@ class SqliteMemoryAdapter:
continue
with self._connection_lock:
self._all_connections.discard(conn)
setattr(self._local, attr, None)
self._connection_owners.pop(conn, None)
setattr(self._local, attr, None)
@@ -16,6 +16,7 @@ when explicitly enabled to avoid impacting performance.
"""
import re
import threading
from praisonaiagents._logging import get_logger
from typing import Any, Dict, List, Optional, Callable, TYPE_CHECKING
@@ -24,6 +25,8 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
_MAX_PENDING_INTERACTIONS = 1000
class AutoMemoryExtractor:
"""
Automatically extracts memorable content from conversations.
@@ -269,8 +272,13 @@ class AutoMemory:
verbose=verbose
)
# Track what we've already processed to avoid duplicates
# Track what we've already processed to avoid duplicates. The lock keeps
# the dedup check and store atomic so overlapping calls (shared agent in
# worker threads) with the same interaction store exactly once.
self._processed_hashes: set = set()
self._processing_lock = threading.RLock()
self._pending_memories: Dict[str, List[Dict[str, Any]]] = {}
self._stored_offsets: Dict[str, int] = {}
def process_interaction(
self,
@@ -289,6 +297,32 @@ class AutoMemory:
Returns:
List of extracted memories
"""
result = self._process_interaction(user_message, assistant_response, store)
if not store:
from copy import deepcopy
return deepcopy(result)
return result
def _make_pending_room(self) -> None:
"""Retry the oldest pending write before refusing a new interaction.
Caller holds the processing lock. Successful partial offsets are kept
on failure, and no pending interaction is evicted to admit new work.
"""
if len(self._pending_memories) < _MAX_PENDING_INTERACTIONS:
return
pending_hash = next(iter(self._pending_memories))
try:
self._store_memories(self._pending_memories[pending_hash], pending_hash)
except Exception as exc:
raise RuntimeError("AutoMemory pending interaction limit reached; pending retry failed") from exc
self._processed_hashes.add(pending_hash)
self._pending_memories.pop(pending_hash)
self._stored_offsets.pop(pending_hash, None)
def _process_interaction(
self, user_message: str, assistant_response: Optional[str], store: bool
) -> List[Dict[str, Any]]:
if not self.enabled:
return []
@@ -297,28 +331,47 @@ class AutoMemory:
if assistant_response:
text += "\n" + assistant_response
# Check if already processed
# Previews must not consume dedup, so only the hash is checked (not
# recorded) when store is False.
import hashlib
text_hash = hashlib.sha256(text.encode()).hexdigest()[:16]
if text_hash in self._processed_hashes:
return []
self._processed_hashes.add(text_hash)
# Quick filter
if not self.extractor.should_remember(text):
return []
# Extract memories
memories = self.extractor.extract(text)
with self._processing_lock:
if text_hash in self._processed_hashes:
return []
memories = self._pending_memories.get(text_hash)
if memories is None:
# Quick filter
if not self.extractor.should_remember(text):
return []
memories = self.extractor.extract(text)
if store and memories:
self._store_memories(memories)
with self._processing_lock:
# Another caller may have completed or partially stored this
# interaction while extraction ran outside the lock.
if text_hash in self._processed_hashes:
return []
pending = self._pending_memories.get(text_hash)
if pending is not None:
memories = pending
elif len(self._pending_memories) >= _MAX_PENDING_INTERACTIONS:
self._make_pending_room()
self._pending_memories[text_hash] = memories
self._store_memories(memories, text_hash)
# Keep partial progress on failure; do not evict it to admit
# another interaction and duplicate its successful writes.
self._processed_hashes.add(text_hash)
self._pending_memories.pop(text_hash, None)
self._stored_offsets.pop(text_hash, None)
return memories
def _store_memories(self, memories: List[Dict[str, Any]]):
def _store_memories(self, memories: List[Dict[str, Any]], text_hash: Optional[str] = None):
"""Store extracted memories in the base memory."""
for mem in memories:
for index, mem in enumerate(memories):
if text_hash is not None and index < self._stored_offsets.get(text_hash, 0):
continue
mem_type = mem.get("type", "")
content = mem.get("content", "")
importance = mem.get("importance", 0.5)
@@ -344,6 +397,9 @@ class AutoMemory:
# Store as long-term memory
self.memory.add_long_term(content, importance=importance)
if text_hash is not None:
self._stored_offsets[text_hash] = index + 1
if self.verbose:
logger.info(f"Auto-stored memory: {mem_type} - {content[:50]}...")
@@ -127,16 +127,23 @@ class DocsManager:
remaining = content
# Check for YAML frontmatter
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
lines = content.splitlines(keepends=True)
if lines and lines[0].rstrip("\r\n \t") == "---":
for end in range(1, len(lines)):
if lines[end].rstrip("\r\n \t") != "---":
continue
try:
import yaml
frontmatter = yaml.safe_load(parts[1]) or {}
remaining = parts[2].strip()
parsed = yaml.safe_load("".join(lines[1:end]))
if parsed is None:
parsed = {}
if isinstance(parsed, dict):
frontmatter = parsed
remaining = "".join(lines[end + 1:]).strip()
except Exception:
# If YAML parsing fails, use content as-is
pass
break
return frontmatter, remaining
@@ -179,6 +186,11 @@ class DocsManager:
self._docs[key] = doc
self._log(f"Loaded doc: {key}")
@staticmethod
def _effective_priority(priority: int, scope: str) -> int:
"""Apply the same global-document offset on creation and reload."""
return priority - 1000 if scope == "global" else priority
def _load_all_docs(self):
"""Load all docs from global and workspace directories."""
self._docs.clear()
@@ -188,7 +200,7 @@ class DocsManager:
self._load_docs_from_dir(self.global_docs_path, "global")
for key, doc in self._docs.items():
if key.startswith("global:"):
doc.priority = doc.priority - 1000 # Lower priority for global
doc.priority = self._effective_priority(doc.priority, "global")
# 2. Load workspace docs
workspace_docs_dir = self.workspace_path / self.DOCS_DIR_NAME.replace("/", os.sep)
@@ -307,17 +319,22 @@ class DocsManager:
section = f"{header}\n{doc_text}\n"
if total_chars + len(section) <= max_chars:
# The join separator is part of the output budget too.
remaining = max_chars - total_chars - 1
if len(section) <= remaining:
parts.append(section)
total_chars += len(section)
total_chars += 1 + len(section)
else:
# Truncate last doc
remaining = max_chars - total_chars
if remaining > 100:
parts.append(section[:remaining] + "\n... (truncated)")
# Preserve the complete header and truncation notice.
marker = "\n... (truncated)"
prefix = f"{header}\n"
body_budget = remaining - len(prefix) - len(marker)
if body_budget > 0:
parts.append(prefix + doc_text[:body_budget] + marker)
break
return "\n".join(parts)
# A heading without a documentation section is not useful context.
return "\n".join(parts) if len(parts) > 1 else ""
def create_doc(
self,
@@ -352,14 +369,19 @@ class DocsManager:
file_path = docs_dir / f"{name}.md"
# Build frontmatter
import yaml
frontmatter_lines = ["---"]
if description:
frontmatter_lines.append(f'description: "{description}"')
if priority != 0:
frontmatter_lines.append(f"priority: {priority}")
frontmatter_lines.append(yaml.safe_dump(
{"description": description}, allow_unicode=False
).rstrip("\n"))
# Persist explicit zero instead of reloading the workspace default (100).
frontmatter_lines.append(f"priority: {priority}")
if tags:
tags_str = ", ".join(f'"{t}"' for t in tags)
frontmatter_lines.append(f"tags: [{tags_str}]")
frontmatter_lines.append(yaml.safe_dump(
{"tags": tags}, allow_unicode=False
).rstrip("\n"))
frontmatter_lines.append("---")
frontmatter_lines.append("")
@@ -372,7 +394,7 @@ class DocsManager:
name=name,
content=content,
description=description,
priority=priority,
priority=self._effective_priority(priority, scope),
tags=tags or [],
file_path=str(file_path)
)
@@ -24,6 +24,8 @@ import hashlib
import logging
from praisonaiagents._logging import get_logger
import threading
import weakref
from collections import Counter
from pathlib import Path
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
@@ -38,6 +40,20 @@ else:
logger = get_logger(__name__)
_store_locks = weakref.WeakValueDictionary()
_store_locks_guard = threading.Lock()
def _shared_store_lock(path):
"""Share in-process transactions for instances targeting the same store."""
key = os.path.normcase(str(path.resolve()))
with _store_locks_guard:
lock = _store_locks.get(key)
if lock is None:
lock = threading.RLock()
_store_locks[key] = lock
return lock
@dataclass
class MemoryItem:
@@ -171,7 +187,7 @@ class FileMemory:
self.config = self._load_config(config)
# Initialize thread-safe memory stores with lock for in-memory data protection
self._lock = threading.RLock() # Reentrant lock for nested operations
self._lock = _shared_store_lock(self.user_path)
self._short_term: List[MemoryItem] = []
self._long_term: List[MemoryItem] = []
self._entities: Dict[str, EntityItem] = {}
@@ -450,12 +466,12 @@ class FileMemory:
def _save_long_term(self):
"""Save long-term memory to file."""
data = [item.to_dict() for item in self._long_term]
self._write_json(self.long_term_file, data)
return self._write_json(self.long_term_file, data)
def _save_entities(self):
"""Save entities to file."""
data = {k: v.to_dict() for k, v in self._entities.items()}
self._write_json(self.entities_file, data)
return self._write_json(self.entities_file, data)
def _save_summaries(self):
"""Save summaries to file."""
@@ -585,16 +601,30 @@ class FileMemory:
importance=importance
)
previous_items = self._long_term[:]
self._long_term.append(item)
# Enforce limit
limit = self.config["long_term_limit"]
if len(self._long_term) > limit:
# Remove lowest importance items
self._long_term.sort(key=lambda x: x.importance, reverse=True)
# Remove lowest importance items. Break equal-importance ties by
# recency (newer first) so a freshly appended turn is not
# discarded in favour of an older equally-weighted one — raw
# conversation turns all share the default importance, and a
# later agent must still be able to prefetch the newest facts.
self._long_term.sort(
key=lambda x: (x.importance, x.created_at), reverse=True
)
self._long_term = self._long_term[:limit]
self._save_long_term()
try:
saved = self._save_long_term()
except Exception:
self._long_term = previous_items
raise
if saved is False:
self._long_term = previous_items
raise OSError("Failed to persist long-term memory")
self._log(f"Added long-term memory: {content[:50]}...")
# Emit trace event
@@ -641,6 +671,8 @@ class FileMemory:
k: EntityItem.from_dict(v)
for k, v in self._read_json(self.entities_file, {}).items()
}
from copy import deepcopy
previous_entities = deepcopy(self._entities)
entity_id = self._generate_id(f"{name}:{entity_type}")
# Check if entity exists
@@ -663,7 +695,14 @@ class FileMemory:
)
self._entities[entity_id] = entity
self._save_entities()
try:
saved = self._save_entities()
except Exception:
self._entities = previous_entities
raise
if saved is False:
self._entities = previous_entities
raise OSError("Failed to persist entity memory")
self._log(f"Added/updated entity: {name} ({entity_type})")
return entity_id
@@ -1062,6 +1101,10 @@ class FileMemory:
True if memory was found and deleted, False otherwise
"""
with self._lock:
self._short_term = [
MemoryItem.from_dict(item)
for item in self._read_json(self.short_term_file, [])
]
for i, item in enumerate(self._short_term):
if item.id == memory_id:
del self._short_term[i]
@@ -1081,6 +1124,10 @@ class FileMemory:
True if memory was found and deleted, False otherwise
"""
with self._lock:
self._long_term = [
MemoryItem.from_dict(item)
for item in self._read_json(self.long_term_file, [])
]
for i, item in enumerate(self._long_term):
if item.id == memory_id:
del self._long_term[i]
@@ -1100,6 +1147,10 @@ class FileMemory:
True if entity was found and deleted, False otherwise
"""
with self._lock:
self._entities = {
key: EntityItem.from_dict(value)
for key, value in self._read_json(self.entities_file, {}).items()
}
# Find entity by name using existing helper
entity = self._find_entity_by_name(name)
if entity:
@@ -1504,24 +1555,31 @@ class FileMemory:
Args:
llm_func: Optional LLM function for summarization.
Should accept (prompt: str) -> str
max_items: Max items to keep after compression
Should accept (prompt: str) -> str. May be called again
if concurrent turnover removes selected records.
max_items: Max snapshot items to keep after compression. Records
added while summarizing are also retained.
Returns:
The generated summary
The persisted summary, or an empty string if no compression commits.
"""
# Check length and gather content under lock
with self._lock:
self._short_term = [
MemoryItem.from_dict(item)
for item in self._read_json(self.short_term_file, [])
]
if len(self._short_term) <= max_items:
return "" # No compression needed
# Gather content to compress while holding lock
items_to_compress = self._short_term[:-max_items]
content_list = [item.content for item in items_to_compress]
items_to_compress = self._short_term[:-max_items] if max_items else self._short_term[:]
# Generate summary OUTSIDE lock (LLM call may be slow)
if llm_func:
prompt = f"""Summarize the following conversation context into key points.
while items_to_compress:
content_list = [item.content for item in items_to_compress]
if llm_func:
prompt = f"""Summarize the following conversation context into key points.
Preserve important facts, decisions, and context.
Be concise but comprehensive.
@@ -1529,26 +1587,56 @@ Context to summarize:
{chr(10).join(f'- {c}' for c in content_list)}
Summary:"""
summary = llm_func(prompt)
else:
# Simple concatenation if no LLM
summary = "Compressed context: " + " | ".join(content_list[:5]) + "..."
summary = llm_func(prompt)
else:
# Simple concatenation if no LLM
summary = "Compressed context: " + " | ".join(content_list) + "..."
# Add summary as a high-importance long-term memory (add_long_term has its own lock)
self.add_long_term(
content=f"[Session Summary] {summary}",
metadata={"type": "compression_summary", "items_compressed": len(items_to_compress)},
importance=0.9
)
# Commit a still-current snapshot under the shared store lock. The LLM
# stays outside it; overlapping calls must not commit duplicate summaries.
with self._lock:
current = self._read_json(self.short_term_file, [])
# A survivor is a selected record still present with unchanged
# content. Evicted records (turnover) and same-id replacements
# (import_data) are both excluded so we never summarize stale
# content and then delete the record that replaced it.
available = Counter((item["id"], item["content"]) for item in current)
survivors = []
for item in items_to_compress:
key = (item.id, item.content)
if available[key]:
survivors.append(item)
available[key] -= 1
if len(survivors) != len(items_to_compress):
# Summarize the survivors afresh, never records added during
# this call. Every retry drops at least one record, so retries
# are finite; a peer that consumed the whole snapshot
# produces no duplicate.
items_to_compress = survivors
continue
summary_id = self.add_long_term(
content=f"[Session Summary] {summary}",
metadata={"type": "compression_summary", "items_compressed": len(items_to_compress)},
importance=0.9,
)
retained = self._read_json(self.long_term_file, [])
if not any(item["id"] == summary_id for item in retained):
return ""
# Remove sources only after verifying their summary survived retention.
remaining = Counter((item.id, item.content) for item in items_to_compress)
self._short_term = []
for item in self._read_json(self.short_term_file, []):
key = (item["id"], item["content"])
if remaining[key]:
remaining[key] -= 1
else:
self._short_term.append(MemoryItem.from_dict(item))
self._save_short_term()
# Keep only recent items under lock
with self._lock:
self._short_term = self._short_term[-max_items:]
self._save_short_term()
self._log(f"Compressed {len(items_to_compress)} items into summary")
self._log(f"Compressed {len(items_to_compress)} items into summary")
return summary
return summary
return ""
def auto_compress_if_needed(
self,
@@ -1597,7 +1685,8 @@ Summary:"""
checkpoints_path = self.user_path / "checkpoints"
checkpoints_path.mkdir(parents=True, exist_ok=True)
checkpoint_id = name or f"checkpoint_{int(time.time())}"
from uuid import uuid4
checkpoint_id = name or f"checkpoint_{int(time.time())}_{uuid4().hex}"
checkpoint_file = checkpoints_path / f"{checkpoint_id}.json"
checkpoint_data = {
@@ -14,6 +14,7 @@ Each store handles a specific type of learning data:
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
import json
from pathlib import Path
from typing import Any, Dict, List, Optional, TYPE_CHECKING
@@ -125,9 +126,9 @@ class BaseStore(ABC):
ensure_dir(base)
return str(base / f"{self.store_name}.json")
def _load(self) -> None:
def _load(self, *, strict: bool = False) -> None:
"""Load entries from storage using BaseJSONStore."""
data = self._store.load()
data = self._store._load_strict() if strict else self._store.load()
self._entries = {
k: LearnEntry.from_dict(v) for k, v in data.items()
}
@@ -145,7 +146,7 @@ class BaseStore(ABC):
# Re-read the current on-disk state before mutating so a concurrent
# writer sharing the same store (e.g. another agent with the same
# user_id) isn't silently overwritten by this save.
self._load()
self._load(strict=True)
# Deduplication: Check for exact content match
content_normalized = content.strip().lower()
for existing_entry in self._entries.values():
@@ -192,28 +193,51 @@ class BaseStore(ABC):
def get(self, entry_id: str) -> Optional[LearnEntry]:
"""Get entry by ID."""
return self._entries.get(entry_id)
def _refresh_for_retrieval(self) -> bool:
"""Refresh before telemetry writes; use cached entries on backend outage."""
try:
self._load(strict=True)
return True
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
# Unreadable source: retain the cache without persisting telemetry.
refresh_error = exc
except (KeyError, TypeError, ValueError, AttributeError):
# Invalid records are data errors, not temporary backend outages.
raise
except Exception as exc:
refresh_error = exc
import logging
logging.warning("Failed to refresh learning store; using read-only cached entries: %s", refresh_error)
return False
def search(self, query: str, limit: int = 10) -> List[LearnEntry]:
"""Simple text search (can be overridden for semantic search)."""
# Retrieval persists usage telemetry, so refresh before selecting entries.
refreshed = self._refresh_for_retrieval()
query_lower = query.lower()
results = [
entry for entry in self._entries.values()
if query_lower in entry.content.lower()
]
results = results[:limit]
self._touch(results)
if refreshed:
self._touch(results)
return results
def list_all(self, limit: int = 100) -> List[LearnEntry]:
"""List all entries."""
refreshed = self._refresh_for_retrieval()
entries = list(self._entries.values())
entries.sort(key=lambda x: x.updated_at, reverse=True)
entries = entries[:limit]
self._touch(entries)
if refreshed:
self._touch(entries)
return entries
def update(self, entry_id: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> Optional[LearnEntry]:
"""Update an existing entry."""
self._load(strict=True)
if entry_id not in self._entries:
return None
entry = self._entries[entry_id]
@@ -227,6 +251,7 @@ class BaseStore(ABC):
def delete(self, entry_id: str) -> bool:
"""Delete an entry."""
self._load(strict=True)
if entry_id in self._entries:
del self._entries[entry_id]
self._save()
@@ -237,8 +262,8 @@ class BaseStore(ABC):
def clear(self) -> int:
"""Clear all entries."""
count = len(self._entries)
self._store._reset({})
self._entries = {}
self._save()
self._was_updated = True
return count
@@ -11,7 +11,6 @@ from praisonaiagents._logging import get_logger
from datetime import datetime, timezone
# Decomposed memory functionality - imported as mixins for backward compatibility
from .search import SearchMixin
from .core import MemoryCoreMixin
# Protocol-driven imports (AGENTS.md compliant)
@@ -39,7 +38,7 @@ CACHE_BOUNDARY = "--- CACHE_BOUNDARY ---"
class Memory(SearchMixin, MemoryCoreMixin):
class Memory(MemoryCoreMixin):
"""
A single-file memory manager covering:
- Short-term memory (STM) for ephemeral context
@@ -328,8 +327,17 @@ class Memory(SearchMixin, MemoryCoreMixin):
# compatible).
suffix = self._path_safe_suffix(self.cfg.get("user_id"))
config["short_db"] = self.cfg.get("short_db", os.path.join(project_data, f"short_term{suffix}.db"))
config["long_db"] = self.cfg.get("long_db", os.path.join(project_data, f"long_term{suffix}.db"))
# ``db_path`` is a documented single-file alias for the sqlite backend
# (MemoryConfig(backend="sqlite", config={"db_path": ...})). Without this
# it was silently dropped and both agents fell back to the per-user
# default paths, so a caller-supplied shared file never took effect and
# cross-instance recall failed. An explicit short_db/long_db still wins.
db_path = self.cfg.get("db_path")
default_short = db_path or os.path.join(project_data, f"short_term{suffix}.db")
default_long = db_path or os.path.join(project_data, f"long_term{suffix}.db")
config["short_db"] = self.cfg.get("short_db", default_short)
config["long_db"] = self.cfg.get("long_db", default_long)
config["rag_db_path"] = self.cfg.get("rag_db_path", os.path.join(project_data, f"chroma_db{suffix}"))
config["collection_name"] = self.cfg.get("collection_name", f"memory_store{suffix}")
config["verbose"] = self.verbose
@@ -373,8 +381,15 @@ class Memory(SearchMixin, MemoryCoreMixin):
# per-user store paths derived in _get_adapter_config (explicit paths win).
suffix = self._path_safe_suffix(self.cfg.get("user_id"))
self.short_db = self.cfg.get("short_db", os.path.join(project_data, f"short_term{suffix}.db"))
self.long_db = self.cfg.get("long_db", os.path.join(project_data, f"long_term{suffix}.db"))
# Honor the ``db_path`` single-file alias here too so the legacy direct
# connections (_get_stm_conn/_get_ltm_conn) point at the caller-supplied
# file, matching _get_adapter_config. Explicit short_db/long_db still win.
db_path = self.cfg.get("db_path")
default_short = db_path or os.path.join(project_data, f"short_term{suffix}.db")
default_long = db_path or os.path.join(project_data, f"long_term{suffix}.db")
self.short_db = self.cfg.get("short_db", default_short)
self.long_db = self.cfg.get("long_db", default_long)
# Only create separate SQLite adapter if primary adapter is not SQLite
if self.provider != "sqlite":
@@ -512,9 +527,8 @@ class Memory(SearchMixin, MemoryCoreMixin):
)
self._local.stm_conn.commit()
# Register connection for cleanup
with self._connection_lock:
self._all_connections.add(self._local.stm_conn)
# Register connection for cleanup (also reclaims dead threads' connections)
self._sqlite_adapter._register_connection(self._local.stm_conn)
return self._local.stm_conn
def _get_ltm_conn(self):
@@ -532,9 +546,8 @@ class Memory(SearchMixin, MemoryCoreMixin):
)
self._local.ltm_conn.commit()
# Register connection for cleanup
with self._connection_lock:
self._all_connections.add(self._local.ltm_conn)
# Register connection for cleanup (also reclaims dead threads' connections)
self._sqlite_adapter._register_connection(self._local.ltm_conn)
return self._local.ltm_conn
def _log_verbose(self, msg: str, level: int = logging.INFO):
@@ -2513,6 +2526,7 @@ class Memory(SearchMixin, MemoryCoreMixin):
# Remove from registry before closing
with self._connection_lock:
self._all_connections.discard(self._local.stm_conn)
self._sqlite_adapter._connection_owners.pop(self._local.stm_conn, None)
self._local.stm_conn.close()
self._local.stm_conn = None
except Exception as e:
@@ -2523,6 +2537,7 @@ class Memory(SearchMixin, MemoryCoreMixin):
# Remove from registry before closing
with self._connection_lock:
self._all_connections.discard(self._local.ltm_conn)
self._sqlite_adapter._connection_owners.pop(self._local.ltm_conn, None)
self._local.ltm_conn.close()
self._local.ltm_conn = None
except Exception as e:
@@ -156,7 +156,7 @@ class RulesManager:
MAX_RULE_CHARS = 12000
# Import pattern for @path/to/file syntax
IMPORT_PATTERN = re.compile(r'(?<!`)@([\w./-]+)(?!`)')
IMPORT_PATTERN = re.compile(r'(?<!`)@((?:~/)?[\w./-]+)(?!`)')
def __init__(
self,
@@ -181,6 +181,7 @@ class RulesManager:
self.verbose = verbose
self._rules: Dict[str, Rule] = {}
self._extra_rule_files: List[str] = []
self._load_all_rules()
def _log(self, msg: str, level: int = logging.INFO):
@@ -193,7 +194,8 @@ class RulesManager:
Find the git repository root from a starting path.
Uses pure Python (no gitpython dependency) for performance.
Falls back to subprocess if needed.
Worktrees and submodules are covered by the walk-up (their ``.git``
is a file). ``git`` is only invoked when GIT_DIR/GIT_WORK_TREE is set.
Args:
start_path: Starting path (defaults to workspace_path)
@@ -211,7 +213,10 @@ class RulesManager:
return current
current = current.parent
# Method 2: Fallback to git command (handles edge cases like worktrees)
# Method 2: Only GIT_DIR/GIT_WORK_TREE can locate a repo without a
# .git on disk; otherwise rev-parse would fail too (~9 ms per agent).
if not (os.environ.get("GIT_DIR") or os.environ.get("GIT_WORK_TREE")):
return None
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
@@ -299,38 +304,54 @@ class RulesManager:
frontmatter = {}
body = content
# Check for YAML frontmatter (--- ... ---)
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
yaml_content = parts[1].strip()
body = parts[2].strip()
# Simple YAML parsing (avoid dependency)
for line in yaml_content.split("\n"):
line = line.strip()
if ":" in line:
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
# Handle lists
if value.startswith("[") and value.endswith("]"):
# Parse simple list: ["*.py", "*.pyx"]
value = [v.strip().strip('"\'') for v in value[1:-1].split(",") if v.strip()]
# Handle booleans
elif value.lower() in ("true", "false"):
value = value.lower() == "true"
# Handle numbers
elif value.isdigit():
value = int(value)
# Handle quoted strings
elif value.startswith('"') and value.endswith('"'):
value = value[1:-1]
elif value.startswith("'") and value.endswith("'"):
value = value[1:-1]
frontmatter[key] = value
# Delimiters are complete lines, not substrings in metadata values.
lines = content.splitlines(keepends=True)
if lines and lines[0].rstrip("\r\n \t") == "---":
import yaml
for end in range(1, len(lines)):
if lines[end].rstrip("\r\n \t") != "---":
continue
# Invalid metadata retains text for explicit use, not automatic selection.
frontmatter = {"activation": "manual"}
try:
yaml_content = "".join(lines[1:end])
loader = yaml.SafeLoader(yaml_content)
try:
node = loader.get_single_node()
if isinstance(node, yaml.MappingNode):
loader.flatten_mapping(node)
for key, value in node.value:
# These schema fields preserve scalar spellings,
# including leading zeroes and numeric filenames.
if (key.value == "priority" and isinstance(value, yaml.ScalarNode)
and re.fullmatch(r"[+-]?[0-9]+", value.value)):
value.tag = "tag:yaml.org,2002:str"
elif key.value == "globs" and isinstance(value, yaml.SequenceNode):
for item in value.value:
if isinstance(item, yaml.ScalarNode):
item.tag = "tag:yaml.org,2002:str"
parsed = loader.construct_document(node) if node is not None else {}
finally:
loader.dispose()
if isinstance(parsed, dict):
if "priority" in parsed:
priority = parsed["priority"]
if isinstance(priority, str) and re.fullmatch(r"[+-]?[0-9]+", priority):
parsed["priority"] = int(priority, 10)
elif type(priority) is not int:
raise ValueError("Rule priority must be an integer")
if "globs" in parsed:
globs = parsed["globs"]
if not isinstance(globs, list) or not all(isinstance(item, str) for item in globs):
raise ValueError("Rule globs must be a list of scalar patterns")
parsed["globs"] = globs
frontmatter = parsed
body = "".join(lines[end + 1:]).strip()
except (yaml.YAMLError, ValueError):
# Preserve the original instruction text when metadata is invalid.
pass
break
return frontmatter, body
@@ -443,6 +464,7 @@ class RulesManager:
def _load_all_rules(self):
"""Load rules from all sources including git root and additional directories."""
self._rules = {}
self._explicit_rule_keys = set()
# Find git root for monorepo support
git_root = self._find_git_root()
@@ -515,6 +537,9 @@ class RulesManager:
current = current.parent
for path in self._extra_rule_files:
self._load_extra_rule_file(path)
self._log(f"Loaded {len(self._rules)} rules total")
def add_rule_file(self, path: str) -> int:
@@ -532,6 +557,14 @@ class RulesManager:
Returns:
Number of rule files successfully added.
"""
path = str(path)
if path in self._extra_rule_files:
self._extra_rule_files.remove(path)
self._extra_rule_files.append(path)
return self._load_extra_rule_file(path)
def _load_extra_rule_file(self, path: str) -> int:
"""Load the current matches for a registered instruction file or glob."""
candidate = Path(path)
if candidate.is_absolute():
matches = [candidate] if candidate.exists() else list(
@@ -543,13 +576,31 @@ class RulesManager:
matches = list(self.workspace_path.glob(path))
added = 0
for file_path in matches:
for file_path in sorted(matches, key=str):
if not file_path.is_file():
continue
rule = self._load_root_instruction_file(file_path)
if rule:
rule.priority = rule.priority + 200 # Explicit files win
self._rules[f"extra:{rule.name}"] = rule
source = os.path.normcase(str(file_path.resolve()))
# Explicit registration replaces any auto-discovered or previous
# copy of this file, but keeps distinct files with the same name.
duplicates = [
key for key, existing in self._rules.items()
if existing.file_path
and os.path.normcase(str(Path(existing.file_path).resolve())) == source
]
# Keep a discovered scope key so public name lookup/deletion
# still addresses this file, without retaining a second copy.
storage_key = next(
(key for key in duplicates if not key.startswith("extra:")),
f"extra:{source}",
)
for key in duplicates:
del self._rules[key]
self._explicit_rule_keys.discard(key)
self._rules[storage_key] = rule
self._explicit_rule_keys.add(storage_key)
added += 1
self._log(f"Added {added} extra rule file(s) from '{path}'")
return added
@@ -596,8 +647,13 @@ class RulesManager:
def get_rule_by_name(self, name: str) -> Optional[Rule]:
"""Get a rule by name (for manual @mention invocation)."""
# Registration precedence is independent of the retained deletion scope.
for key, rule in reversed(list(self._rules.items())):
if key in self._explicit_rule_keys and rule.name == name:
return rule
# Check all scopes
for scope in ["subdir", "workspace", "global"]:
for scope in ["subdir", "workspace", "global", "root"]:
key = f"{scope}:{name}"
if key in self._rules:
return self._rules[key]
@@ -662,14 +718,18 @@ class RulesManager:
section = f"{header}\n{rule_text}\n"
if total_chars + len(section) <= max_chars:
separator_chars = 1 if parts else 0
remaining = max_chars - total_chars - separator_chars
if len(section) <= remaining:
parts.append(section)
total_chars += len(section)
total_chars += separator_chars + len(section)
else:
# Truncate last rule
remaining = max_chars - total_chars
if remaining > 100:
parts.append(section[:remaining] + "\n... (truncated)")
# Preserve the complete header and truncation notice.
marker = "\n... (truncated)"
prefix = f"{header}\n"
body_budget = remaining - len(prefix) - len(marker)
if body_budget > 0:
parts.append(prefix + rule_text[:body_budget] + marker)
break
return "\n".join(parts)
@@ -684,7 +744,7 @@ class RulesManager:
Only rules with ``activation == "glob"`` are considered; ``always``
rules are handled up front by the system-prompt builder. Results are
deduplicated by rule name and any name in ``exclude_names`` is skipped
deduplicated by file identity and any name in ``exclude_names`` is skipped
so already-injected rules are not emitted twice.
Args:
@@ -700,11 +760,15 @@ class RulesManager:
for rule in self._rules.values():
if rule.activation != "glob":
continue
if rule.name in exclude or rule.name in seen:
identity = (
os.path.normcase(str(Path(rule.file_path).resolve()))
if rule.file_path else rule.name
)
if rule.name in exclude or identity in seen:
continue
if any(rule.matches_file(fp) for fp in file_paths):
matched.append(rule)
seen.add(rule.name)
seen.add(identity)
matched.sort(key=lambda r: r.priority, reverse=True)
return matched
@@ -747,20 +811,20 @@ class RulesManager:
file_path = rules_dir / f"{name}.md"
# Build frontmatter
frontmatter_lines = ["---"]
import yaml
metadata = {"activation": activation}
if description:
frontmatter_lines.append(f'description: "{description}"')
metadata["description"] = description
if globs:
globs_str = ", ".join(f'"{g}"' for g in globs)
frontmatter_lines.append(f"globs: [{globs_str}]")
frontmatter_lines.append(f"activation: {activation}")
metadata["globs"] = globs
if priority != 0:
frontmatter_lines.append(f"priority: {priority}")
frontmatter_lines.append("---")
frontmatter_lines.append("")
metadata["priority"] = priority
# Write file
full_content = "\n".join(frontmatter_lines) + content
full_content = "---\n" + yaml.safe_dump(
metadata, sort_keys=False, allow_unicode=False
) + "---\n" + content
file_path.write_text(full_content, encoding="utf-8")
# Create and register rule
@@ -811,6 +875,7 @@ class RulesManager:
try:
Path(rule.file_path).unlink()
del self._rules[key]
self._explicit_rule_keys.discard(key)
self._log(f"Deleted rule '{name}'")
return True
except Exception as e:
@@ -1,366 +0,0 @@
"""
Search and retrieval functionality for Memory class.
This module contains methods related to memory search, retrieval, and querying.
Split from the main memory.py file for better maintainability.
"""
import json
import logging
from typing import Any, Dict, List, Optional, Tuple
from datetime import datetime
class SearchMixin:
"""Mixin class containing search and retrieval methods for the Memory class."""
# Allowed table names for SQLite queries to prevent SQL injection
_ALLOWED_SQLITE_TABLES = frozenset({"short_term", "long_term"})
def search_short_term(self, query: str, limit: int = 5, metadata_filter: Optional[Dict] = None,
min_quality: Optional[float] = None, user_id: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]:
"""
Search short-term memory for relevant content using protocol-driven approach.
Args:
query: Search query string
limit: Maximum number of results to return
metadata_filter: Optional metadata filter
min_quality: Minimum quality score threshold
user_id: Optional user ID for filtering
**kwargs: Additional provider-specific arguments
Returns:
List of relevant memory entries
"""
results = []
# Protocol-driven search: Delegate to adapter first
try:
if hasattr(self, 'memory_adapter') and self.memory_adapter:
adapter_results = self.memory_adapter.search_short_term(
query, limit=limit, user_id=user_id, agent_id=kwargs.get('agent_id'),
run_id=kwargs.get('run_id'), **kwargs
)
# Convert adapter results to legacy format
if isinstance(adapter_results, list):
for result in adapter_results:
if hasattr(result, 'text') and hasattr(result, 'metadata'):
# SearchResult from protocol
results.append({
"id": getattr(result, 'id', ''),
"text": result.text,
"metadata": result.metadata or {},
"score": getattr(result, 'score', 1.0)
})
elif isinstance(result, dict):
# Already in legacy format
results.append(result)
self._log_verbose(f"Found {len(results)} results in {self.provider} STM via adapter")
except Exception as e:
self._log_verbose(f"Failed to search {self.provider} STM: {e}", logging.WARNING)
# Apply quality filtering if specified
if min_quality is not None:
results = [r for r in results if r.get("metadata", {}).get("quality", 0.0) >= min_quality]
# Apply metadata filtering (defense-in-depth). Auto-derive a user_id
# filter so per-user isolation holds even for adapters that accept
# user_id but ignore it in their own query (Chroma/RAG, SQLite, in-memory).
# An explicit user_id always wins so a conflicting
# metadata_filter["user_id"] can never widen the scope to another
# tenant's data (mirrors Memory._build_metadata_filter).
effective_filter = dict(metadata_filter or {})
if user_id:
effective_filter["user_id"] = user_id
if effective_filter:
filtered_results = []
for result in results:
metadata = result.get("metadata", {})
if all(metadata.get(k) == v for k, v in effective_filter.items()):
filtered_results.append(result)
results = filtered_results
return self._deduplicate_and_rank_results(results, limit)
def search_long_term(self, query: str, limit: int = 10, metadata_filter: Optional[Dict] = None,
min_quality: Optional[float] = None, user_id: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]:
"""
Search long-term memory for relevant content using protocol-driven approach.
Args:
query: Search query string
limit: Maximum number of results to return
metadata_filter: Optional metadata filter
min_quality: Minimum quality score threshold
user_id: Optional user ID for filtering
**kwargs: Additional provider-specific arguments
Returns:
List of relevant memory entries
"""
results = []
# Protocol-driven search: Delegate to adapter first
try:
if hasattr(self, 'memory_adapter') and self.memory_adapter:
adapter_results = self.memory_adapter.search_long_term(
query, limit=limit, user_id=user_id, agent_id=kwargs.get('agent_id'),
run_id=kwargs.get('run_id'), **kwargs
)
# Convert adapter results to legacy format
if isinstance(adapter_results, list):
for result in adapter_results:
if hasattr(result, 'text') and hasattr(result, 'metadata'):
# SearchResult from protocol
results.append({
"id": getattr(result, 'id', ''),
"text": result.text,
"metadata": result.metadata or {},
"score": getattr(result, 'score', 1.0)
})
elif isinstance(result, dict):
# Already in legacy format
results.append(result)
self._log_verbose(f"Found {len(results)} results in {self.provider} LTM via adapter")
except Exception as e:
self._log_verbose(f"Failed to search {self.provider} LTM: {e}", logging.WARNING)
# Apply quality filtering if specified
if min_quality is not None:
results = [r for r in results if r.get("metadata", {}).get("quality", 0.0) >= min_quality]
# Apply metadata filtering (defense-in-depth). Auto-derive a user_id
# filter so per-user isolation holds even for adapters that accept
# user_id but ignore it in their own query (Chroma/RAG, SQLite, in-memory).
# An explicit user_id always wins so a conflicting
# metadata_filter["user_id"] can never widen the scope to another
# tenant's data (mirrors Memory._build_metadata_filter).
effective_filter = dict(metadata_filter or {})
if user_id:
effective_filter["user_id"] = user_id
if effective_filter:
filtered_results = []
for result in results:
metadata = result.get("metadata", {})
if all(metadata.get(k) == v for k, v in effective_filter.items()):
filtered_results.append(result)
results = filtered_results
return self._deduplicate_and_rank_results(results, limit)
def _search_vector_stm(self, query: str, limit: int) -> List[Dict[str, Any]]:
"""Search short-term memory using vector similarity."""
if not hasattr(self, 'stm_collection'):
return []
# Get query embedding
query_embedding = self._get_embedding(query)
if not query_embedding:
return []
# Search similar vectors
results = self.stm_collection.query(
query_embeddings=[query_embedding],
n_results=limit,
include=['metadatas', 'documents', 'distances']
)
return self._format_vector_results(results)
def _search_vector_ltm(self, query: str, limit: int) -> List[Dict[str, Any]]:
"""Search long-term memory using vector similarity."""
if not hasattr(self, 'ltm_collection'):
return []
# Get query embedding
query_embedding = self._get_embedding(query)
if not query_embedding:
return []
# Search similar vectors
results = self.ltm_collection.query(
query_embeddings=[query_embedding],
n_results=limit,
include=['metadatas', 'documents', 'distances']
)
return self._format_vector_results(results)
def _search_mongodb(self, collection, query: str, limit: int, metadata_filter: Optional[Dict],
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
"""Search MongoDB collection with given parameters."""
if not collection:
return []
# Build search filter
search_filter = {"$text": {"$search": query}}
# Add additional filters
if metadata_filter:
for key, value in metadata_filter.items():
search_filter[f"metadata.{key}"] = value
if min_quality is not None:
search_filter["quality_score"] = {"$gte": min_quality}
if user_id:
search_filter["metadata.user_id"] = user_id
# Perform search with text score
cursor = collection.find(
search_filter,
{"score": {"$meta": "textScore"}}
).sort([("score", {"$meta": "textScore"})]).limit(limit)
return [self._format_mongodb_result(doc) for doc in cursor]
def _search_mongodb_stm(self, query: str, limit: int, metadata_filter: Optional[Dict],
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
"""Search short-term memory in MongoDB."""
collection = getattr(self, 'stm_collection', None)
return self._search_mongodb(collection, query, limit, metadata_filter, min_quality, user_id)
def _search_mongodb_ltm(self, query: str, limit: int, metadata_filter: Optional[Dict],
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
"""Search long-term memory in MongoDB."""
collection = getattr(self, 'ltm_collection', None)
return self._search_mongodb(collection, query, limit, metadata_filter, min_quality, user_id)
def _search_sqlite(self, conn, table: str, query: str, limit: int, metadata_filter: Optional[Dict],
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
"""Search SQLite table with given parameters."""
if not conn:
return []
# Validate table name to prevent SQL injection
if table not in self._ALLOWED_SQLITE_TABLES:
allowed = ", ".join(sorted(self._ALLOWED_SQLITE_TABLES))
raise ValueError(f"Invalid table name: {table}. Allowed tables: {allowed}")
# Build WHERE clause
where_clauses = ["content LIKE ?"]
params = [f"%{query}%"]
if min_quality is not None:
where_clauses.append("quality_score >= ?")
params.append(min_quality)
# Simple metadata filtering (JSON contains check)
if metadata_filter:
for key, value in metadata_filter.items():
where_clauses.append("metadata LIKE ?")
params.append(f'%"{key}": "{value}"%')
if user_id:
where_clauses.append("metadata LIKE ?")
params.append(f'%"user_id": "{user_id}"%')
where_clause = " AND ".join(where_clauses)
query_sql = f"""
SELECT id, content, metadata, timestamp, quality_score
FROM {table}
WHERE {where_clause}
ORDER BY quality_score DESC, timestamp DESC
LIMIT ?
"""
params.append(limit)
cursor = conn.execute(query_sql, params)
rows = cursor.fetchall()
return [self._format_sqlite_result(dict(row)) for row in rows]
def _search_sqlite_stm(self, query: str, limit: int, metadata_filter: Optional[Dict],
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
"""Search short-term memory in SQLite."""
conn = self._get_stm_conn() # Let AttributeError propagate naturally
return self._search_sqlite(conn, "short_term", query, limit, metadata_filter, min_quality, user_id)
def _search_sqlite_ltm(self, query: str, limit: int, metadata_filter: Optional[Dict],
min_quality: Optional[float], user_id: Optional[str]) -> List[Dict[str, Any]]:
"""Search long-term memory in SQLite."""
conn = self._get_ltm_conn() # Let AttributeError propagate naturally
return self._search_sqlite(conn, "long_term", query, limit, metadata_filter, min_quality, user_id)
def _format_vector_results(self, results: Dict) -> List[Dict[str, Any]]:
"""Format ChromaDB vector search results."""
formatted = []
if not results.get('documents') or not results['documents'][0]:
return formatted
documents = results['documents'][0]
metadatas = results.get('metadatas', [[]])[0]
distances = results.get('distances', [[]])[0]
for i, doc in enumerate(documents):
metadata = metadatas[i] if i < len(metadatas) else {}
distance = distances[i] if i < len(distances) else 1.0
formatted.append({
'content': doc,
'metadata': metadata,
'similarity_score': 1.0 - distance, # Convert distance to similarity
'source': 'vector'
})
return formatted
def _format_mongodb_result(self, doc: Dict) -> Dict[str, Any]:
"""Format MongoDB search result."""
return {
'id': str(doc.get('_id', '')),
'content': doc.get('content', ''),
'metadata': doc.get('metadata', {}),
'timestamp': doc.get('timestamp', ''),
'quality_score': doc.get('quality_score', 0.0),
'text_score': doc.get('score', 0.0),
'source': 'mongodb'
}
def _format_sqlite_result(self, row: Dict) -> Dict[str, Any]:
"""Format SQLite search result."""
metadata = {}
if row.get('metadata'):
try:
metadata = json.loads(row['metadata'])
except json.JSONDecodeError:
pass
return {
'id': str(row.get('id', '')),
'content': row.get('content', ''),
'metadata': metadata,
'timestamp': row.get('timestamp', ''),
'quality_score': row.get('quality_score', 0.0),
'source': 'sqlite'
}
def _deduplicate_and_rank_results(self, results: List[Dict[str, Any]], limit: int) -> List[Dict[str, Any]]:
"""Remove duplicates and rank results by relevance."""
# Simple deduplication by content
seen_content = set()
unique_results = []
for result in results:
content = result.get('content', '').strip().lower()
if content and content not in seen_content:
seen_content.add(content)
unique_results.append(result)
# Sort by quality score or similarity score
unique_results.sort(
key=lambda x: (
x.get('similarity_score', 0.0) +
x.get('quality_score', 0.0) +
x.get('text_score', 0.0)
),
reverse=True
)
return unique_results[:limit]
@@ -29,6 +29,8 @@ Usage:
formatted = formatter.format(response)
"""
from .._lazy import create_lazy_getattr
__all__ = [
# Core classes
"OutputStyle",
@@ -61,100 +63,35 @@ __all__ = [
]
def __getattr__(name: str):
"""Lazy load module components to avoid import overhead."""
if name == "OutputStyle":
from .style import OutputStyle
return OutputStyle
if name == "OutputFormatter":
from .formatter import OutputFormatter
return OutputFormatter
if name == "StylePreset":
from .style import StylePreset
return StylePreset
if name == "OutputConfig":
# Consolidated to the canonical config.OutputConfig (see issue #2294).
# The former divergent dataclass in output/config.py was unused; this
# import path is preserved for backward compatibility.
from ..config import OutputConfig
return OutputConfig
_LAZY_IMPORTS = {
"OutputStyle": ("praisonaiagents.output.style", "OutputStyle"),
"OutputFormatter": ("praisonaiagents.output.formatter", "OutputFormatter"),
"StylePreset": ("praisonaiagents.output.style", "StylePreset"),
# Consolidated to the canonical config.OutputConfig (see issue #2294).
# The former divergent dataclass in output/config.py was unused; this
# import path is preserved for backward compatibility.
"OutputConfig": ("praisonaiagents.config", "OutputConfig"),
# Status output (for status preset - no timestamps)
if name == "StatusOutput":
from .status import StatusOutput
return StatusOutput
if name == "enable_status_output":
from .status import enable_status_output
return enable_status_output
if name == "disable_status_output":
from .status import disable_status_output
return disable_status_output
if name == "is_status_output_enabled":
from .status import is_status_output_enabled
return is_status_output_enabled
if name == "get_status_output":
from .status import get_status_output
return get_status_output
"StatusOutput": ("praisonaiagents.output.status", "StatusOutput"),
"enable_status_output": ("praisonaiagents.output.status", "enable_status_output"),
"disable_status_output": ("praisonaiagents.output.status", "disable_status_output"),
"is_status_output_enabled": ("praisonaiagents.output.status", "is_status_output_enabled"),
"get_status_output": ("praisonaiagents.output.status", "get_status_output"),
# Trace output (for trace preset - with timestamps)
if name == "TraceOutput":
from .trace import TraceOutput
return TraceOutput
if name == "enable_trace_output":
from .trace import enable_trace_output
return enable_trace_output
if name == "disable_trace_output":
from .trace import disable_trace_output
return disable_trace_output
if name == "is_trace_output_enabled":
from .trace import is_trace_output_enabled
return is_trace_output_enabled
if name == "get_trace_output":
from .trace import get_trace_output
return get_trace_output
"TraceOutput": ("praisonaiagents.output.trace", "TraceOutput"),
"enable_trace_output": ("praisonaiagents.output.trace", "enable_trace_output"),
"disable_trace_output": ("praisonaiagents.output.trace", "disable_trace_output"),
"is_trace_output_enabled": ("praisonaiagents.output.trace", "is_trace_output_enabled"),
"get_trace_output": ("praisonaiagents.output.trace", "get_trace_output"),
# Editor output (for editor preset - beginner-friendly numbered steps)
if name == "EditorOutput":
from .editor import EditorOutput
return EditorOutput
if name == "enable_editor_output":
from .editor import enable_editor_output
return enable_editor_output
if name == "disable_editor_output":
from .editor import disable_editor_output
return disable_editor_output
if name == "is_editor_output_enabled":
from .editor import is_editor_output_enabled
return is_editor_output_enabled
if name == "get_editor_output":
from .editor import get_editor_output
return get_editor_output
if name == "TOOL_LABELS":
from .editor import TOOL_LABELS
return TOOL_LABELS
if name == "BlockType":
from .editor import BlockType
return BlockType
if name == "DisplayBlock":
from .editor import DisplayBlock
return DisplayBlock
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
"EditorOutput": ("praisonaiagents.output.editor", "EditorOutput"),
"enable_editor_output": ("praisonaiagents.output.editor", "enable_editor_output"),
"disable_editor_output": ("praisonaiagents.output.editor", "disable_editor_output"),
"is_editor_output_enabled": ("praisonaiagents.output.editor", "is_editor_output_enabled"),
"get_editor_output": ("praisonaiagents.output.editor", "get_editor_output"),
"TOOL_LABELS": ("praisonaiagents.output.editor", "TOOL_LABELS"),
"BlockType": ("praisonaiagents.output.editor", "BlockType"),
"DisplayBlock": ("praisonaiagents.output.editor", "DisplayBlock"),
}
__getattr__ = create_lazy_getattr(_LAZY_IMPORTS, __name__)
@@ -34,6 +34,50 @@ from typing import List, Optional
# Operators that separate simple-commands within a compound command.
_SEPARATORS = ("&&", "||", ";", "|", "&", "\n")
# POSIX wrapper executables that run *another* command passed as their trailing
# arguments (``sudo rm -rf x`` really runs ``rm``). A deny on ``rm`` must still
# fire through these, so the wrapper is stripped and the inner command re-parsed
# as the effective operation. Matched on the basename, so ``/usr/bin/sudo``
# works too. ``timeout``/``nice`` take a leading non-command operand (a duration
# / niceness value) which is skipped by ``_skip_wrapper_operands``.
_POSIX_PREFIX_WRAPPERS = frozenset(
{
"sudo",
"env",
"command",
"nohup",
"nice",
"ionice",
"stdbuf",
"setsid",
"time",
"timeout",
"exec",
"busybox",
"xargs",
"doas",
}
)
# POSIX shells whose ``-c <script>`` argument is an inner command string that
# must be decomposed so a deny on the inner command still fires.
_POSIX_SHELLS = frozenset({"sh", "bash", "zsh", "dash", "ksh", "ash"})
# Short option flags that consume the *next* token as their value, per wrapper.
# ``sudo -u root rm`` must skip ``root`` so the effective command resolves to
# ``rm`` rather than mis-reading ``root`` as the executable (a fail-open that
# would let the broad allow match). Only the value-taking flags that appear
# before the command need listing; unknown flags fall back to being skipped
# as valueless, which over-approximates safely (the next token is re-parsed as
# a command and still checked against deny rules).
_WRAPPER_VALUE_FLAGS = {
"sudo": frozenset({"-u", "-g", "-h", "-p", "-C", "-r", "-t", "-U", "-R", "-T"}),
"doas": frozenset({"-u", "-C"}),
"nice": frozenset({"-n"}),
"ionice": frozenset({"-c", "-n", "-p"}),
"env": frozenset({"-u", "-C", "-S"}),
}
# Shell *parameter* expansion that bash resolves at runtime, invisibly to the
# static tokenizer. ``rm${IFS}-rf${IFS}/`` tokenizes as one opaque token here
# but bash word-splits it into ``rm -rf /`` when executed, so a broad allow
@@ -64,6 +108,23 @@ def has_unresolvable_expansion(cmd: str) -> bool:
return False
return _UNSAFE_EXPANSION_RE.search(cmd) is not None
def has_parse_error(cmd: str) -> bool:
"""Return ``True`` if *cmd* cannot be safely tokenized by ``shlex``.
An unbalanced quote (``echo 'x; rm -rf x``) makes ``shlex.split`` raise,
after which the fallback splitter mis-tokenizes the command and a specific
deny rule can no longer be verified against the real sub-operations. The
caller escalates such commands to ``ASK`` rather than allowing them.
"""
if not cmd or not cmd.strip():
return False
try:
shlex.split(cmd, comments=False, posix=True)
return False
except ValueError:
return True
# Redirection operators that truncate/overwrite or append to a file.
# These produce an additional ``write:<path>`` sub-target.
_WRITE_REDIRECTS = (">", ">>", ">|", "&>", "&>>")
@@ -560,6 +621,167 @@ def _unwrap_shell_wrapper(segment: str) -> Optional[List[ShellOp]]:
return parse_command(inner, dialect=dialect)
def _basename(executable: str) -> str:
"""Return the basename of *executable* (``/bin/rm`` -> ``rm``)."""
return executable.rsplit("/", 1)[-1]
# Standard system binary directories. An absolute executable under one of these
# is a normal PATH-resolvable command (``/usr/bin/rm``), so stripping the prefix
# to match a command deny rule is safe. Executables elsewhere (``/tmp/tool``)
# keep their path so the workspace-boundary gate still gates them.
_STANDARD_BIN_DIRS = (
"/bin/",
"/sbin/",
"/usr/bin/",
"/usr/sbin/",
"/usr/local/bin/",
"/usr/local/sbin/",
"/opt/homebrew/bin/",
)
def _is_standard_bin_path(executable: str) -> bool:
"""Return ``True`` if *executable* is a direct child of a system bindir.
A plain string prefix is insufficient: ``/bin/../../tmp/tool`` starts with
``/bin/`` yet resolves outside the bindir, so stripping the prefix to a bare
name would hide a path-shaped external executable from the workspace gate.
Require that, after the bindir prefix, the remainder is a single path
component (no ``/`` and no ``..``) so only genuine ``/bin/rm``-style paths
are reduced; anything with further separators or traversal keeps its path.
"""
for prefix in _STANDARD_BIN_DIRS:
if executable.startswith(prefix):
remainder = executable[len(prefix):]
if remainder and "/" not in remainder and remainder not in ("..", "."):
return True
return False
return False
def _skip_wrapper_operands(name: str, rest: List[str]) -> List[str]:
"""Drop a wrapper's own flags/operands, returning the inner argv.
``rest`` is the argument list following the wrapper executable. Leading
``VAR=val`` assignments (``env FOO=bar cmd``) and option flags are dropped.
``timeout``/``nice``/``ionice`` take a leading non-flag operand (a duration
or priority value) before the real command, which is also skipped.
"""
i = 0
n = len(rest)
# ``env``/``command`` leading ``VAR=val`` assignments precede the command.
while i < n and "=" in rest[i] and not rest[i].startswith("-") \
and rest[i].split("=", 1)[0].isidentifier():
i += 1
# Skip wrapper option flags (``sudo -u root``, ``env -i``). Flags that take
# a separate value token (``sudo -u <user>``) consume the following token so
# the real command is not mistaken for the value.
value_flags = _WRAPPER_VALUE_FLAGS.get(name, frozenset())
while i < n and rest[i].startswith("-"):
# ``--`` ends option parsing: everything after is the command.
if rest[i] == "--":
i += 1
break
flag = rest[i]
i += 1
# ``-u=root`` carries its value inline; ``-u root`` consumes the next
# token. Unknown flags are treated as valueless (safe over-approx).
if "=" not in flag and flag in value_flags and i < n:
i += 1
# ``timeout 5 cmd`` / ``timeout 5s cmd`` / ``nice -n 5 cmd``: skip a single
# leading duration/priority operand. ``timeout`` durations may carry a unit
# suffix (``s``/``m``/``h``/``d``), so ``5s`` must still be recognised as the
# operand rather than mistaken for the command (which would let a broad allow
# match while the specific ``rm`` deny never fires).
if name in ("timeout", "nice", "ionice") and i < n:
if _is_duration_operand(rest[i], allow_unit=(name == "timeout")):
i += 1
return rest[i:]
def _is_duration_operand(token: str, *, allow_unit: bool) -> bool:
"""Return ``True`` if *token* is a numeric duration/priority operand.
``nice``/``ionice`` take a bare number; ``timeout`` additionally accepts a
single-character unit suffix (``5s``, ``1.5m``, ``2h``, ``1d``).
"""
if not token:
return False
core = token
if allow_unit and token[-1] in "smhd" and len(token) > 1:
core = token[:-1]
return core.replace(".", "", 1).isdigit()
def _is_external_path(executable: str) -> bool:
"""Return ``True`` if *executable* is a path-shaped external program.
A bare name (``rm``) is PATH-resolved; a path under a standard bindir
(``/bin/rm``) is a normal command. Anything else that carries a path
separator (``/tmp/sudo``, ``../tool``, ``/bin/../x``) runs code from outside
the trusted bindirs and must keep its path so the workspace-boundary gate
still prompts for it.
"""
if "/" not in executable and not executable.startswith(("~", "$")):
return False
return not _is_standard_bin_path(executable)
def _normalize_posix_argv(tokens: List[str]) -> List["ShellOp"]:
"""Resolve POSIX wrapper prefixes to the effective inner operation(s).
``sudo rm -rf x`` / ``/bin/rm -rf x`` / ``env FOO=1 rm x`` all reduce to an
``rm`` operation so a ``rm`` deny still fires. ``bash -c '<script>'`` recurses
into the quoted script, preserving the inner operations' redirect
``write_targets`` so a deny on an inner ``> /protected`` write still fires.
When a *wrapper* is itself invoked by an external path (``/tmp/sudo cmd`` or
``/tmp/bash -c '…'``), the path is preserved as an extra guard op so the
workspace-boundary gate still requires approval for running that external
executable — unwrapping must never let an out-of-bindir program escape the
gate.
Returns a list of :class:`ShellOp`; an empty list means nothing could be
resolved and the caller should treat the original tokens as a single op.
"""
argv = list(tokens)
guard = 0
guard_ops: List["ShellOp"] = []
while argv and guard < 32:
guard += 1
name = _basename(argv[0])
is_shell = name in _POSIX_SHELLS and "-c" in argv[1:]
is_wrapper = name in _POSIX_PREFIX_WRAPPERS
if (is_shell or is_wrapper) and _is_external_path(argv[0]):
# The wrapper/shell binary itself lives outside the trusted bindirs;
# keep it as a path-shaped op so the workspace gate still prompts.
guard_ops.append(ShellOp(executable=argv[0]))
if is_shell:
idx = argv.index("-c", 1)
if idx + 1 < len(argv):
inner_ops = list(parse_command(argv[idx + 1]))
return guard_ops + inner_ops
return guard_ops + [ShellOp(executable=argv[0], args=list(argv[1:]))]
if is_wrapper:
inner = _skip_wrapper_operands(name, argv[1:])
if not inner:
# Wrapper with no residual command (``sudo -v``): keep as-is.
return guard_ops + [ShellOp(executable=argv[0], args=list(argv[1:]))]
argv = inner
continue
break
# Strip a *standard system* directory prefix so ``/bin/rm`` matches a ``rm``
# deny rule. Executables under arbitrary/external paths (``/tmp/tool``,
# ``../tool``) are left intact so the workspace-boundary gate still sees a
# path-shaped executable and requires approval.
if argv and argv[0] != _basename(argv[0]) and _is_standard_bin_path(argv[0]):
argv = [_basename(argv[0]), *argv[1:]]
if not argv:
return guard_ops
return guard_ops + [ShellOp(executable=argv[0], args=list(argv[1:]))]
def _parse_segment(segment: str, dialect: str = DIALECT_POSIX) -> List[ShellOp]:
"""Parse a single simple-command segment into ShellOp(s).
@@ -662,6 +884,25 @@ def _parse_segment(segment: str, dialect: str = DIALECT_POSIX) -> List[ShellOp]:
op.args = args
# POSIX wrapper/path normalization: ``sudo rm``/``/bin/rm``/``env FOO=1 rm``/
# ``bash -c '…'`` all reduce to the effective inner command(s) so a deny on
# the real executable fires instead of matching a broad allow on the wrapper.
if posix_mode and op.executable:
resolved = _normalize_posix_argv([op.executable, *op.args])
if resolved and [
[o.executable, *o.args] for o in resolved
] != [[op.executable, *op.args]]:
resolved_ops = [o for o in resolved if o.executable or o.write_targets]
for i, inner_op in enumerate(resolved_ops):
inner_op.dialect = dialect
# Keep the segment's own redirect targets on the first resolved
# op (unless it already carries inner redirects from ``-c``).
if i == 0 and op.write_targets:
inner_op.write_targets = op.write_targets + inner_op.write_targets
ops.append(inner_op)
if ops:
return ops
if op.executable or op.write_targets:
ops.append(op)
@@ -484,10 +484,32 @@ class PermissionManager:
"""
original_target = prefix + command
try:
from .command_parser import parse_command, has_unresolvable_expansion
from .command_parser import (
parse_command,
has_unresolvable_expansion,
has_parse_error,
)
except Exception:
return None
# A command whose quoting cannot be tokenized (e.g. an unterminated
# quote: ``echo 'x; rm -rf x``) cannot be statically decomposed, so a
# specific deny can no longer be verified against its sub-operations.
# Escalate to ASK (an explicit deny still wins) rather than letting the
# flat matcher optimistically ALLOW the opaque string.
if has_parse_error(command):
flat = self._check_flat(original_target, agent)
if flat.action == PermissionAction.DENY:
return flat
return PermissionResult(
action=PermissionAction.ASK,
target=original_target,
reason=(
"Command could not be parsed (unbalanced quotes?); "
"requires approval"
),
)
# Shell parameter/command expansion (``${IFS}``, ``$(...)``, backticks)
# is resolved by bash at runtime and is invisible to the static
# tokenizer, so a payload like ``rm${IFS}-rf${IFS}/`` could slip past a
@@ -904,6 +904,9 @@ class PluginManager:
event=event,
func=func,
name=f"{name}:{event.value}",
# Delivery policies compose in order: apply a rewrite
# before the next policy, and stop the chain on denial.
sequential=event == PluginHook.MESSAGE_SENDING,
)
hook_ids.append(hook_id)
count += 1
@@ -1205,9 +1208,18 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
if _overrides("after_message"):
def after_message_hook(data, _p=plugin):
new = _p.after_message(_message_payload(data))
try:
new = _p.after_message(_message_payload(data))
except GuardrailBlocked as e:
return HookResult.block(e.reason)
decision = _as_decision(new)
if decision is not None:
return decision
if isinstance(new, dict) and "content" in new and hasattr(data, "content"):
data.content = new["content"]
# Bot delivery reads the canonical rewrite field on the result,
# not just the mutated event payload.
return HookResult(modified_input={"content": data.content})
return HookResult.allow()
yield HookEvent.MESSAGE_SENDING, after_message_hook
@@ -1361,3 +1373,15 @@ def get_plugin_manager() -> PluginManager:
if _default_manager is None:
_default_manager = PluginManager()
return _default_manager
def has_registered_plugins() -> bool:
"""Return whether the global manager has any plugins registered.
Cheap, allocation-free fast path for the hot Agent-init merge calls: when
the singleton has not been created or holds no plugins (the common case),
callers can skip constructing/scanning the manager entirely. Does NOT
create the singleton as a side effect.
"""
manager = _default_manager
return manager is not None and bool(manager._plugins)
@@ -9,11 +9,14 @@ from praisonaiagents._logging import get_logger
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Union
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
# Import HookEvent at module level for alias (DRY)
from ..hooks.types import HookEvent
if TYPE_CHECKING:
from ..hooks.types import HookResult
logger = get_logger(__name__)
# PluginHook is now an alias for HookEvent - single source of truth
@@ -26,7 +29,8 @@ class GuardrailBlocked(Exception):
A ``POLICY``/``GUARDRAIL`` plugin can raise this from any ``before_*``
method (``before_tool``, ``before_llm``, ``before_agent``,
``before_message``) to stop the tool call / LLM request / agent run /
inbound message. It may also be raised from ``after_tool`` to block
inbound message. Raising it from ``after_message`` cancels final channel
delivery. It may also be raised from ``after_tool`` to block
*propagation* of a tool result (e.g. secret/PII detected) — the tool has
already run, but the output is suppressed before it reaches the model.
The plugin bridge converts it into a denying ``HookResult`` so the
@@ -45,7 +49,8 @@ class PluginDecision:
This is an alternative to returning a full ``HookResult`` (which requires
importing from ``hooks.types``) or raising :class:`GuardrailBlocked`. Return
``PluginDecision.deny(reason)`` or ``PluginDecision.block(reason)`` from a
``before_*`` method to stop the action; the bridge forwards it to the
``before_*`` method to stop the action, or from ``after_message`` to cancel
final channel delivery; the bridge forwards it to the
runtime's block enforcement. Returning ``allow()`` (or the usual
``dict``/``str``/``tuple``/``None``) keeps today's rewrite/no-op semantics.
"""
@@ -157,7 +162,7 @@ class Plugin(ABC):
def before_agent(
self, prompt: str, context: Dict[str, Any]
) -> Union[str, "PluginDecision", None]:
) -> Union[str, "PluginDecision", "HookResult", None]:
"""Called before agent execution.
Return a modified ``prompt`` (rewrite), or a deny/block decision
@@ -172,7 +177,7 @@ class Plugin(ABC):
def before_tool(
self, tool_name: str, args: Dict[str, Any]
) -> Union[Dict[str, Any], "PluginDecision", None]:
) -> Union[Dict[str, Any], "PluginDecision", "HookResult", None]:
"""Called before tool execution.
Return modified ``args`` (rewrite), or a deny/block decision
@@ -200,7 +205,7 @@ class Plugin(ABC):
def before_message(
self, message: Dict[str, Any]
) -> Union[Dict[str, Any], "PluginDecision", None]:
) -> Union[Dict[str, Any], "PluginDecision", "HookResult", None]:
"""Called before message is processed.
Return a modified ``message`` (rewrite), or a deny/block decision
@@ -209,8 +214,16 @@ class Plugin(ABC):
"""
return message
def after_message(self, message: Dict[str, Any]) -> Dict[str, Any]:
"""Called after message is processed. Can modify message."""
def after_message(
self, message: Dict[str, Any]
) -> Union[Dict[str, Any], "PluginDecision", "HookResult", None]:
"""Called before final channel delivery (MESSAGE_SENDING).
Return a message dict with modified ``content`` to rewrite the reply,
a ``PluginDecision.deny/block`` or ``HookResult`` to cancel delivery,
or ``None`` for no-op. Raising :class:`GuardrailBlocked` also cancels.
This hook does not cover progressive drafts or direct transport sends.
"""
return message
def message_sent(self, message: Dict[str, Any]) -> None:
@@ -232,7 +245,7 @@ class Plugin(ABC):
def before_llm(
self, messages: List[Dict], params: Dict[str, Any]
) -> Union[tuple, "PluginDecision", None]:
) -> Union[tuple, "PluginDecision", "HookResult", None]:
"""Called before LLM call.
Return a ``(messages, params)`` tuple (rewrite), or a deny/block
@@ -398,12 +411,14 @@ class FunctionPlugin(Plugin):
def before_message(
self, message: Dict[str, Any]
) -> Union[Dict[str, Any], "PluginDecision", None]:
) -> Union[Dict[str, Any], "PluginDecision", "HookResult", None]:
if PluginHook.MESSAGE_RECEIVED in self._hooks:
return self._hooks[PluginHook.MESSAGE_RECEIVED](message)
return message
def after_message(self, message: Dict[str, Any]) -> Dict[str, Any]:
def after_message(
self, message: Dict[str, Any]
) -> Union[Dict[str, Any], "PluginDecision", "HookResult", None]:
if PluginHook.MESSAGE_SENDING in self._hooks:
return self._hooks[PluginHook.MESSAGE_SENDING](message)
return message
Loaded 100 of 510 files, more files were not shown because too many files have changed in this diff. Show more