Compare commits

...
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison b443953ca9 test: verify mixed-scope doc priority survives reload and reopen
Co-authored-by: Mervin Praison <[email protected]>
2026-10-07 09:52:42 +00:00
dajiaohuang 044da7cf02 test: document priority regression contracts 2026-10-03 22:48:43 +08:00
dajiaohuang f55067be38 test: exercise document priority with real agent responses 2026-10-03 22:17:51 +08:00
dajiaohuang 6fd8684b69 refactor: share document scope priority adjustment 2026-10-03 20:44:05 +08:00
Wu Shuwen 874d580160 Preserve documentation priority across creation and reload 2026-10-03 08:40:19 +01: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
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
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] 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] a3e66c800a Merge pull request #5606 from MervinPraison/claude/issue-5597-20261002-0830
fix: prevent infinite recursion on CLI YAML run (fixes #5597)
2026-10-02 11:54:55 +00:00
praisonai-triage-agent[bot] 3733f2b4e3 Merge pull request #5568 from MervinPraison/claude/issue-5566-20261002-0224
fix: handle invalid UTF-8 in FileBackend.load
2026-10-02 11:53:55 +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
Wu Shuwen bb01adbfaf Close SQLiteBackend connection on schema initialization failure (#5575) 2026-10-02 12:44:57 +01:00
Wu Shuwen 6b752549c2 fix: apply session retention beyond listing limits (#5584) 2026-10-02 12:42:59 +01:00
Wu Shuwen 271ce2ee67 fix(storage): honor complete file backend suffixes (#5565) 2026-10-02 12:41:16 +01:00
Wu Shuwen 7d05bf91ab fix(session): merge agent histories under the session lock (#5545) 2026-10-02 12:39:35 +01: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
MervinPraison 0d6a092f50 docs: auto-update feature parity trackers [skip ci] 2026-10-02 11:34:35 +00:00
Wu Shuwen f0aa4db150 fix(session): preserve complete agent history messages (#5541)
* fix(session): preserve complete agent history messages

* fix(session): exclude history extensions from Responses messages
2026-10-02 12:33:12 +01:00
Wu Shuwen db86605cbf fix: refresh indexed session recall and routes after portable imports (#5524)
* Refresh indexed session content and routes after portable imports

* Refresh imported indexes from persisted session state

* fix(session): reject stale cache during imported index refresh
2026-10-02 12:31:23 +01:00
praisonai-triage-agent[bot] b1bfd53142 Merge pull request #5555 from MervinPraison/claude/issue-5553-20261002-0136
fix: skip invalid UTF-8 legacy files during SQLite transcript migration
2026-10-02 11:24:58 +00:00
Wu Shuwen bfa65d18e6 fix(session): recheck import no-clobber under the write lock (#5547) 2026-10-02 12:20:38 +01:00
praisonai-triage-agent[bot]andMervin Praison 86a2877540 fix: enforce approver authorization on HTTP and webhook approval backends (fixes #5610) (#5612)
The HTTP dashboard and webhook out-of-band approval backends accepted the
approver identity from the incoming request with no authorisation check,
letting anyone who could reach the endpoint resolve a gated tool/exec action.

Apply the existing _approval_base authorisation primitive to both:
- HTTPApproval: mint a single-use per-approval token (carried only in the
  private URL), require it on page/decide, and pin the recorded approver to
  the allowlist. Default-open only when no allowlist is configured.
- WebhookApproval: drop any decision whose approver is not in the allowlist
  (immediate POST and polled status paths), continuing to wait rather than
  resolving.

Both accept allowed_approvers= (with HTTP_APPROVERS / WEBHOOK_APPROVERS env
fallback), matching the chat-native backends. Fully backward-compatible: no
allowlist = legacy behaviour.

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 12:17:54 +01:00
69f2af031d fix: remove partial staging file when FileBackend.save serialization fails (#5563)
* fix: remove partial staging file when FileBackend.save serialization fails (fixes #5561)

Record the temp file name immediately on entering the NamedTemporaryFile
context so the exception cleanup can delete partial .tmp files when
json.dump raises (e.g. circular references or tuple keys).

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

* test(storage): cover failed overwrite preserves existing record

Addresses greptile review finding: the regression test now performs a
failing save against the existing "good" key so the final load assertion
guards against corruption of an existing record during a failed overwrite.

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: dajiaohuang <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-02 12:17:29 +01:00
0f3f5ba785 fix: quote SQLite table and index identifiers in SQLiteBackend (#5571)
* fix: quote SQLite table and index identifiers in SQLiteBackend (fixes #5569)

Validated table names that are SQL keywords or start with a digit
(e.g. 'select', '123', '9table') failed during CREATE TABLE and CRUD
because the identifier was inlined unquoted. Quote the validated table
name and the generated index name as SQLite identifiers so all schema
and CRUD statements work consistently.

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

* test: add SQLite identifier regression tests for keyword/digit table names

Covers CRUD, upsert, list_keys (full+prefix), delete, clear, reopen with
auto_create=False, table isolation in one DB, and validator rejection for
names like `select`, `123`, `9table` (Issue #5569).

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: dajiaohuang <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-02 12:17:26 +01:00
praisonai-triage-agent[bot]anddajiaohuang a49978feff fix: record temp_path before json.dump in BaseJSONStore._save (fixes #5580) (#5582)
Record the staging file name before json.dump so a serialization or
UTF-8 encoding failure can be cleaned up by the existing exception
branch, preventing partial .tmp files from leaking beside the store.

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 12:17:23 +01:00
MervinPraison 0f48e1af97 docs: auto-update feature parity trackers [skip ci] 2026-10-02 11:16:35 +00:00
Wu Shuwen 867df48c8b fix: read session item counts as UTF-8 (#5586) 2026-10-02 12:13:10 +01:00
praisonai-triage-agent[bot]andMervin Praison a7d664d940 fix: security return type, AutoGen task_callback, Async state/knowledge ABCs (#5403)
* fix: correct security return type, wire AutoGen task_callback, add Async state/knowledge ABCs (fixes #5402)

- security: enable_injection_defense now annotated -> Dict[str, str] to match
  its docstring and dict return (was -> str).
- autogen_adapter: fire the documented task_callback (task_prepared on prep,
  task_completed after initiate_chats), mirroring the retrofitted agent_callback
  so framework=autogen no longer silently drops task instrumentation.
- persistence: add AsyncStateStore and AsyncKnowledgeStore ABCs for parity with
  the existing AsyncConversationStore, giving native-async third-party backends a
  formal interface to subclass; exported from state/knowledge/persistence __init__.

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

* fix: run async task_callback in AutoGen adapter + add focused tests

Route task_callback invocation through dispatch_maybe_awaitable so an
async (coroutine) callback is actually executed instead of returned
un-awaited (greptile P1). Centralise exception isolation in
_fire_task_callback so a broken callback never aborts the chat. Add
unit tests covering sync/async execution and exception swallowing
(greptile P2).

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 12:12:26 +01:00
Wu Shuwen aedf7e7fb9 fix: enforce session title character limits on fallback paths (#5520)
* fix: honor session title length limits for all return paths

* Skip empty title requests and preserve short prefixes
2026-10-02 12:08:47 +01:00
praisonai-triage-agent[bot]andMervinPraison c8a1e1a308 fix: remove dead code in llm.py and agent.py (fixes #5394) (#5397)
- llm.py: delete shadowed duplicate method definitions (_param_accepts_string
  instance copy, orphaned _schema_accepts_string, shadowed
  _force_tool_usage_message and _tool_repair_message) — live definitions kept
- llm.py: delete unreachable duplicate format_error branch in
  classify_error_kind (subset of an earlier block that already returns)
- agent.py: remove unused imports (concurrent.futures, random, duplicate re,
  unused Generator)

No behaviour or public API changes.

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 12:08:11 +01:00
praisonai-triage-agent[bot]andMervin Praison 7cbc34355d fix: lazy gateway_host import in integration/__init__ (#5393)
* fix: defer gateway_host import behind __getattr__ in integration/__init__ (fixes #5392)

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

* test: add regression test for lazy gateway_host loading in integration package

Guards the PR #5392 startup improvement: verifies importing
praisonai.integration leaves gateway_host unloaded and that accessing
run_integrated_gateway lazily loads it on first use. Also asserts host_app
symbols stay eager, __getattr__ raises AttributeError for unknown names, and
__all__ is unchanged. Addresses Greptile review finding (lazy import lacked a
focused test).

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

* test: isolate lazy-gateway reload so it does not pollute sibling tests

The new regression test popped praisonai.integration from sys.modules but
left the host_app submodule cached, so re-importing the package failed to
rebind the host_app attribute. That leaked partially-loaded state into
test_host_app_context.py, breaking its monkeypatch.setattr path resolution
(4 errors when run together). Wrap the reload in a fixture that also drops
host_app and restores the original sys.modules entries on teardown.

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-02 11:07:40 +01:00
praisonai-triage-agent[bot]andMervin Praison 815c4c2b81 fix: deprecate inert single_agent param in auto.py generators (#5373)
* fix: deprecate inert single_agent param in auto.py generators (fixes #5372)

The single_agent parameter on AutoGenerator and WorkflowAutoGenerator was
accepted, documented, and stored but never read, silently misleading users
who set single_agent=True (a team/workflow was still generated). Emit a
DeprecationWarning when passed truthy and update docstrings to reflect that
single-agent generation is not implemented. Public symbols retained.

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

* test: cover single_agent DeprecationWarning behaviour in auto.py generators

Adds warning-behaviour tests for both AutoGenerator and
WorkflowAutoGenerator: asserts DeprecationWarning fires when
single_agent is truthy and does not fire when left False. Addresses
Greptile P2 (warning behaviour lacked committed test assertions).

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 11:07:26 +01:00
MervinPraison f900d1f5e8 docs: auto-update feature parity trackers [skip ci] 2026-10-02 10:06:14 +00:00
praisonai-triage-agent[bot]andMervin Praison 256ab6dd12 fix: Agent.chat(output_pydantic=Model) returns validated Pydantic instance (#5604)
* fix: Agent.chat(output_pydantic=Model) returns validated Pydantic instance (fixes #5594)

chat_mixin.py returned the raw response string on the structured-output
path instead of parsing it into the requested Pydantic model. Added a
minimal _coerce_structured_output helper and wired it into all sync/async
return paths (native response_format, legacy json_object, and custom-LLM).
Parsing is tolerant of markdown code fences and falls back to the raw
string if parsing fails, preserving backward compatibility.

Co-authored-by: Dhivya-Bharathy <[email protected]>

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

* fix: preserve output_json string contract; harden after-agent hook for Pydantic output

Address reviewer-found regressions in the output_pydantic coercion (PR #5604):
- Coerce only output_pydantic into a model; output_json stays a raw JSON
  string so downstream task/workflow json.loads parsing keeps working.
- Coerce the after-agent hook response payload to str so a Pydantic return
  value no longer crashes string-only hooks (response[:500] truncation).
- Add public-path chat() tests plus a hook-payload test.

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-02 11:05:18 +01:00
praisonai-triage-agent[bot]andMervin Praison c4276c7af7 fix: harden AgentOS auth, fix integration listing & agent_info 500 (#5592)
* fix: harden AgentOS auth, fix integration listing & agent_info 500 (fixes #5591)

- app/agentos.py: route auth through shared build_api_key_middleware
  (constant-time hmac.compare_digest) instead of hand-rolled plain-!=
  middleware; removes duplicated auth code and the timing side channel.
- integrations/registry.py: get_available() now iterates the authoritative
  _loaders map (resolving lazily) instead of the _items resolved cache, so
  get_available_integrations() returns installed CLIs on a fresh registry
  instead of {}.
- api/agent_invoke.py: guard get_agent_info against instructions=None /
  non-str to stop a TypeError 500 on a GET that should always succeed.

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

* fix(auth): narrow /__praisonai__ bypass to exact discovery path

The shared API-key middleware exempted any path starting with
/__praisonai__/ from authentication. AgentOS mounts its sensitive
routes under a configurable api_prefix, so a deployment using
api_prefix="/__praisonai__" would expose every run/session/approval/
chat endpoint unauthenticated. Only /__praisonai__/discovery is meant
to be public; match it exactly instead of by prefix. Closes the P1
flagged by Greptile on #5592.

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 11:05:12 +01:00
05493b760e fix: cross-session memory recall for memory={"user_id": ...} (fixes #5595) (#5603)
* fix: persist raw turns on sync chat path for cross-session memory recall (fixes #5595)

Two Agent instances sharing a memory store (e.g. memory={"user_id": uid})
failed to recall facts because the synchronous chat/run/start path never
wrote the interaction to the memory store; only the async unified path did.
Recall injection (get_memory_context) worked but had nothing to read.

Store the raw turn to short-term memory in _after_agent_side_effects when a
memory instance exists and auto_memory extraction is off, mirroring the
existing async-path write. Best-effort and backward-compatible.

Co-authored-by: Dhivya-Bharathy <[email protected]>

* fix: async-safe memory turn persistence + deterministic compute reclaim test

- memory_mixin: offload the raw-turn store write to a worker thread when
  _persist_memory_turn runs inside a live event loop (async achat/astart
  after-agent path) so it never blocks the loop (AGENTS.md §4.5); sync path
  still writes inline. Addresses Greptile P1 "async turns block on disk".
- tests/test_memory_cross_session: add MEM-004 (public memory={"user_id": uid}
  dict config through the real choke point) and MEM-005 (async-path persistence
  is non-blocking yet recalled). Addresses Greptile P2 "tests bypass public paths".
- tests/unit/test_run_on_docker: fix flaky test_release_reclaims_even_on_an_
  event_loop_thread by waiting (bounded) for the fire-and-forget bridge write,
  mirroring the sibling finalizer test; still fails if _release ever leaks.

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: Dhivya-Bharathy <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-02 11:05:07 +01:00
a2b52f6694 fix: guardrail block returns safe message instead of None (#5596) (#5605)
* fix: guardrail block returns safe message instead of None (fixes #5596)

On a guardrail block (or exhausted retries), Agent.chat/achat caught the
raised exception, rolled back history, and returned None. Callers expecting
a str then crashed on .strip(). Add Agent._guardrail_blocked_message() and
return it from every guardrail-failure branch so a blocked run yields a
safe, non-empty string (with the block reason) and never leaks the rejected
output.

Co-authored-by: Dhivya-Bharathy <[email protected]>

* fix: harden guardrail block fallback (no reason leak, correct exc var)

Address reviewer findings on PR #5605:
- _guardrail_blocked_message now returns a FIXED safe string and no longer
  appends the guardrail reason, which (for LLM validators) can echo the
  rejected output. The reason is logged and remains available via
  last_guardrail_error. Satisfies FR-001 (fixed safe string).
- async reflection block path now passes the guardrail exception (guard_e)
  instead of the outer reflection-parsing error (e) for accurate diagnostics.
- Tests: assert the fallback does not leak the reason; add a reflection-enabled
  block regression test.

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: Dhivya-Bharathy <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-02 11:05:03 +01:00
praisonai-triage-agent[bot]andMervin Praison e57b01a098 fix: preserve resolved YAML path in guarded legacy run delegation
Greptile #5606: the re-entrancy-guarded legacy ``run`` branch overwrote the
constructor-provided (resolved, possibly absolute) ``agent_file`` with the
relative ``sys.argv`` token. For ``run <file>.yaml --worktree`` the modern
runner resolves the target to an absolute path before chdir'ing into the
isolated worktree; discarding it broke runs of untracked/ignored YAML files
absent from the fresh worktree. Keep the pristine ``self.agent_file`` when it
is non-default, falling back to the CLI token otherwise. Adds a regression test.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-10-02 08:58:11 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 9009dba498 fix: prevent infinite recursion on CLI YAML run (fixes #5597)
The modern Typer `run` command delegates YAML execution to the legacy
PraisonAI class, which re-parses sys.argv (`run <file>.yaml`) and
dispatched the `run` command straight back into the modern run_app —
calling the legacy executor again and looping forever until
`maximum recursion depth exceeded`.

Add a process-level re-entrancy sentinel (PRAISONAI_IN_MODERN_RUN) set
while the modern runner delegates; the legacy `run` branch now executes
the YAML directly when the sentinel is present instead of re-entering
the modern app. The sentinel is restored in a finally block so it never
leaks into a later in-process invocation.

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

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-10-02 08:46:05 +00:00
praisonai-triage-agent[bot]andMervin Praison f025fece62 test: assert warning emitted on invalid UTF-8 load (PR #5568 review)
Addresses greptile finding: the regression test for Issue #5566 verified
return value and byte preservation but not the warning it describes. Now
asserts a WARNING identifying the failed key is logged, so removing the
warn path would fail the test.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-02 02:32:25 +00:00
praisonai-triage-agent[bot]anddajiaohuang ab91468561 fix: handle invalid UTF-8 in FileBackend.load (fixes #5566)
Treat undecodable files like malformed JSON: warn and return None
instead of raising UnicodeDecodeError. UnicodeDecodeError subclasses
ValueError, so it previously escaped the (JSONDecodeError, IOError)
handler in FileBackend.load.

Co-authored-by: dajiaohuang <[email protected]>
2026-10-02 02:26:04 +00:00
praisonai-triage-agent[bot]andMervin Praison cf02bd497c fix: sort legacy JSON filenames for deterministic migration order
Addresses greptile review: the bad-first regression test relied on
os.listdir() order, which is unspecified, so it may never have exercised
the invalid-file-first scenario. Sorting filenames makes migration order
deterministic (AGENTS.md §4.6 deterministic-tests invariant) and the
bad-first test now provably processes the invalid file before valid ones.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-02 01:44:20 +00:00
praisonai-triage-agent[bot]anddajiaohuang b68084fc5b fix: skip invalid UTF-8 legacy files during SQLite transcript migration (fixes #5553)
Co-authored-by: dajiaohuang <[email protected]>
2026-10-02 01:39:16 +00:00
MervinPraison 54715e811d docs: auto-update documentation parity trackers [skip ci] 2026-10-01 17:15:20 +00:00
Mervin Praison 0257054c72 Merge pull request #5489 from MervinPraison/claude/issue-5486-20261001-1536
fix: preserve shared SQLite memory adapter on thread cleanup
2026-10-01 18:14:43 +01:00
Mervin Praison 617f555237 Merge pull request #5493 from MervinPraison/claude/issue-5491-20261001-1549
fix: clean up FileMemory atomic staging file on serialization error
2026-10-01 18:14:34 +01:00
MervinPraisonandCursor ff33f176e6 test(compute): wait for fire-and-forget finalizer before asserting reclaim
Matches the bounded wait on #5493 so agents-suite unit tests do not race
AsyncBridge shutdown after gc.collect() on CI.

Co-authored-by: Cursor <[email protected]>
2026-10-01 18:07:28 +01:00
praisonai-triage-agent[bot]andMervin Praison 9d38a4ef9b fix: harden SQLite thread-close and cover open-connection path (#5486)
Only drop a connection from the registry and clear its thread-local ref after close() succeeds; on failure keep it tracked for a later retry and log the error. Add tests covering the open-connection close path and the failing-close retry path.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-01 17:58:15 +01:00
praisonai-triage-agent[bot]anddajiaohuang f5c6a42709 fix: preserve shared SQLite memory adapter on thread cleanup (fixes #5486)
Memory.close_connections() is thread-scoped by contract (#1562/#1563), but its
generic adapter-shutdown block cleared self.memory_adapter unconditionally. For
the shared SqliteMemoryAdapter this made subsequent searches fall back to legacy
SQL against short_mem/long_mem and raise OperationalError: no such table.

Now the SQLite adapter is preserved across thread cleanup; only the calling
thread's adapter connections are closed via a new close_thread_connections(),
and the adapter's connection getters treat a thread-local None as closed so the
thread lazily reopens on next access. Non-SQLite shutdown semantics unchanged.

Co-authored-by: dajiaohuang <[email protected]>
2026-10-01 17:58:15 +01:00
praisonai-triage-agent[bot]andMervin Praison 5a0d4e8cfd test(compute): wait for fire-and-forget finalizer before asserting reclaim
test_a_hosted_backend_reclaims_its_instance_when_collected asserted
synchronously right after gc.collect(), but weakref.finalize reclaims the
instance fire-and-forget on the shared AsyncBridge background loop during
live GC (by design). Bounded-wait for the shutdown to land before asserting,
fixing the flaky "instance outlived the backend" failure without weakening
what the test guards.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-01 17:58:10 +01:00
praisonai-triage-agent[bot]andMervin Praison b42217228e test(memory): cover FileMemory atomic staging cleanup on serialization error
Regression coverage for #5491: verify a non-serializable value leaves no
.*.tmp staging file behind, preserves the existing destination file, and
that a successful atomic write also removes the temp file.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-01 17:58:10 +01:00
praisonai-triage-agent[bot]anddajiaohuang b9da2ffa19 fix: clean up FileMemory atomic staging file on serialization error (fixes #5491)
Move the temp-file cleanup in _write_json() into a finally block so a
JSON TypeError (or any non-IOError/OSError) no longer leaves a
.short_term.json.*.tmp staging file behind. The original exception still
propagates, the existing destination file is preserved, and successful
atomic os.replace() is unaffected (the post-replace unlink raises
FileNotFoundError which is swallowed).

Co-authored-by: dajiaohuang <[email protected]>
2026-10-01 17:58:10 +01:00
Mervin Praison 64ffb567aa Merge pull request #5506 from MervinPraison/fix/compaction-wiring-unit-test
fix(test): compaction wiring budget trigger on CI
2026-10-01 17:48:47 +01:00
MervinPraisonandCursor e1373d1aa3 fix(test): size compaction overflow for tiktoken BPE
Repeated-character fixtures underestimated tokens when the accurate
tokeniser is available, so needs_compaction stayed false on CI.

Co-authored-by: Cursor <[email protected]>
2026-10-01 17:48:28 +01: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]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
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]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
Mervin Praison bb470c786b Merge pull request #5398 from Jonathan-Improving/aea-698-tag
feat(persistence): tag Valkey connections with CLIENT SETINFO LIB-NAME
2026-09-30 19:38:30 +01:00
praisonai-triage-agent[bot]andMervin Praison bec5e5baab test(persistence): cover create_valkey_client factory handshake
Add a focused test that the shared Valkey client factory passes
client_info_tag="praisonai" (and keeps client_name/database_id) to
GlideClientConfiguration, plus the missing-dependency ImportError path.
The existing backend tests inject mock clients and bypass the factory,
so the new CLIENT SETINFO LIB-NAME handshake was untested (Greptile P2).

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 17:54:53 +00:00
Jonathan Neufeld 9000cb6442 feat(persistence): tag the Valkey client via CLIENT SETINFO LIB-NAME
Set client_info_tag="praisonai" on the shared GlideClientSync factory so
the server reports lib-name=GlidePySync(praisonai) for PraisonAI's
ValkeyStateStore, ValkeyVectorKnowledgeStore and ValkeyStorageAdapter
connections. client_name is unchanged.

Raise the valkey extra's floor to valkey-glide-sync>=2.5.2, the first
release with client_info_tag.

Signed-off-by: Jonathan Neufeld <[email protected]>
2026-09-30 11:48:10 -06:00
Mervin Praison fc047552f6 Merge pull request #5391 from MervinPraison/fix/pr-review-openai-key-skip
fix(ci): skip PraisonAI PR review without OpenAI key
2026-09-30 12:34:00 +01:00
MervinPraisonandCursor e83f562f91 fix(ci): skip PraisonAI PR review without OpenAI key
Avoid 401 failures when GitHub App credentials exist but OPENAI_API_KEY is unset or a placeholder.

Co-authored-by: Cursor <[email protected]>
2026-09-30 12:33:20 +01:00
MervinPraison 77a81bbd0c docs: auto-update documentation parity trackers [skip ci] 2026-09-30 11:17:19 +00:00
Mervin Praison f46a72704f Merge pull request #5313 from MervinPraison/claude/issue-5312-20260925-0915
fix: resolve real client IP behind trusted proxies for gateway rate-limiting
2026-09-30 12:16:46 +01:00
MervinPraisonandCursor 75c8ca1965 Merge origin/main; keep ingress attribution and server request channel imports.
Co-authored-by: Cursor <[email protected]>
2026-09-30 12:16:36 +01:00
Mervin Praison a756ee86c1 Merge pull request #5388 from MervinPraison/claude/issue-5385-20260930-0947
fix: record missed recurring occurrences with a misfire grace window
2026-09-30 12:16:12 +01:00
MervinPraisonandCursor c3ec51ede2 Merge origin/main into PR branch; keep misfire grace and quota hold.
Co-authored-by: Cursor <[email protected]>
2026-09-30 12:15:58 +01:00
MervinPraison afd2f717ca docs: auto-update feature parity trackers [skip ci] 2026-09-30 11:15:49 +00:00
Mervin Praison 65d1a24804 Merge pull request #5227 from MervinPraison/claude/issue-5059-20260923-1221
fix: warn when sync .start()/.run() cannot parallelize async_execution tasks
2026-09-30 12:15:11 +01:00
Mervin Praison 05552f5491 Merge pull request #5231 from MervinPraison/claude/issue-5070-20260923-1222
fix: recipe rate-limiter race/bypass/workers + atomic multiedit writes
2026-09-30 12:15:07 +01:00
Mervin Praison 5fc01ff1f8 Merge pull request #5235 from MervinPraison/claude/issue-5152-20260923-1224
fix: defer inbound journal completion until reply delivered
2026-09-30 12:15:02 +01:00
Mervin Praison 1aae110534 Merge pull request #5237 from MervinPraison/claude/issue-5153-20260923-1224
fix: shed live gateway sessions under cgroup memory pressure
2026-09-30 12:14:57 +01:00
Mervin Praison 1ad7142dfc Merge pull request #5239 from MervinPraison/claude/issue-5157-20260923-1226
fix: per-load-unique sys.modules key in safe loader (multi-tenant isolation)
2026-09-30 12:14:52 +01:00
Mervin Praison 4044d5e2ab Merge pull request #5249 from MervinPraison/claude/issue-5159-20260923-1246
fix: bridge gateway/schedule/kanban/compaction lifecycle events to Plugin methods
2026-09-30 12:14:48 +01:00
Mervin Praison 29387c5ceb Merge pull request #5266 from MervinPraison/claude/issue-5265-20260924-0951
fix: add startup-phase watchdog for gateway pre-loop window
2026-09-30 12:14:43 +01:00
Mervin Praison 5dab44ee76 Merge pull request #5303 from georgeatparallel/george/parallel-search
Add Parallel Search MCP as an opt-in web search provider
2026-09-30 12:14:38 +01:00
Mervin Praison 2542b64d43 Merge pull request #5307 from MervinPraison/claude/issue-5306-20260925-0830
fix: wrapper gaps — store-aware DB detection, transient adapter-probe recovery, authoritative agents→roles promotion
2026-09-30 12:14:32 +01:00
Mervin Praison a7b289e98d Merge pull request #5315 from MervinPraison/claude/issue-5314-20260925-0951
fix: add opt-in privacy-safe activity surface for bot long tool runs
2026-09-30 12:14:28 +01:00
Mervin Praison 226e4fe9f1 Merge pull request #5318 from MervinPraison/claude/issue-5317-20260925-1617
fix: make inbuilt_tools PRAISONAI_TOOLS_AVAILABLE reflect real availability
2026-09-30 12:14:23 +01:00
Mervin Praison 0c300d5ff4 Merge pull request #5320 from MervinPraison/claude/issue-5319-20260926-0721
fix: knowledge tenant-scope/filters, MCP isError detection, async loop CSV fallback
2026-09-30 12:14:18 +01:00
Mervin Praison 093f6317c4 Merge pull request #5322 from MervinPraison/claude/issue-5321-20260926-0830
fix: wrapper adapter-registry parity, CLI AsyncBridge routing, scheduler stats lock
2026-09-30 12:14:13 +01:00
Mervin Praison 6f6f84172b Merge pull request #5325 from MervinPraison/claude/issue-5323-20260926-0914
feat: notification policy for gateway push (categories, quiet hours, detail levels)
2026-09-30 12:14:08 +01:00
Mervin Praison 936f1bebbe Merge pull request #5348 from MervinPraison/claude/issue-5347-20260928-0821
fix: bound voice rate-limiter, isolate persistence close(), unpin compute release
2026-09-30 12:14:03 +01:00
Mervin Praison 4a62a147c3 Merge pull request #5367 from MervinPraison/claude/issue-5362-20260929-1001
fix: GatewayConfig drain/watchdog fields for CLI+YAML+Python parity
2026-09-30 12:13:59 +01:00
Mervin Praison 48bda9fbb0 Merge pull request #5369 from MervinPraison/claude/issue-5366-20260929-1024
fix: add operator inspect/retry/purge surface for the outbound queue
2026-09-30 12:13:54 +01:00
Mervin Praison ae4b2802b4 Merge pull request #5375 from MervinPraison/claude/issue-5374-20260930-0003
fix: emit EvalPort spec-conformant Suite/ResultSet documents
2026-09-30 12:13:49 +01:00
Mervin Praison dd6df79f87 Merge pull request #5377 from MervinPraison/claude/issue-5376-20260930-0717
fix: sync→async bridge works in running loop, preserves contextvars, enforces timeout
2026-09-30 12:13:44 +01:00
Mervin Praison 4a2df03e3b Merge pull request #5380 from MervinPraison/claude/issue-5379-20260930-0821
fix: guardrails fail closed + export_session async-store dispatch
2026-09-30 12:13:40 +01:00
Mervin Praison 6ee10b4ac4 Merge pull request #5382 from MervinPraison/claude/issue-5381-20260930-0901
fix: size in-run compaction trigger to model window not flat 8000
2026-09-30 12:13:35 +01:00
Mervin Praison 5d8fb9b09f Merge pull request #5384 from MervinPraison/claude/issue-5383-20260930-0911
fix: canonical session-address grammar + session.resolve method
2026-09-30 12:13:31 +01:00
Mervin Praison 7e93c07d4f Merge pull request #5389 from MervinPraison/claude/issue-5387-20260930-0948
fix: detect, back up, and repair malformed SQLite stores on open
2026-09-30 12:13:23 +01:00
Mervin Praison 3383e8ad73 Merge pull request #5390 from MervinPraison/claude/issue-5386-20260930-0947
fix: park scheduled jobs past provider rate-limit window
2026-09-30 12:13:13 +01:00
praisonai-triage-agent[bot]andMervin Praison 4d5e57c85e fix: close quota-hold gaps in BotOS loop, HTTP-date resets, hold notice
Addresses reviewer findings on the provider-quota-aware schedule hold (#5386):

- BotOS second scheduler path (`_run_schedule_loop`) now parks a job past a
  429/quota reset window via the core `quota_hold_from_failure` decision, so a
  benched provider is no longer hammered every tick on that path (greptile #1).
- `extract_retry_after` now parses an RFC 7231 HTTP-date `Retry-After` (not just
  delta-seconds), so an absolute reset instant still yields a park; a
  past/unparseable date falls through unchanged (greptile #2).
- The "held until N" notice is no longer gated on the failure `alert_after_failures`
  threshold — a hold is a distinct once-per-window state, so it is always emitted
  once while failure/recovery incident accounting stays consistent (greptile #3).
- Make `test_dispatch_skill_review_background_runs_off_path` deterministic
  (event-gated ordering instead of a wall-clock `< 0.1s` bound that flaked on
  slow CI), per AGENTS.md §4.6.

Tests: +HTTP-date park round-trip (core), +BotOS hold decision (bot),
+hold-notice-below-threshold (wrapper). Core scheduler 283 passed; wrapper
scheduler 413 passed; bot scheduler 43 passed.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 10:10:16 +00:00
praisonai-triage-agent[bot]andMervin Praison 60478a7398 fix(scheduler): harden misfire policy — coerce grace, exempt first cron/at run
Address reviewer findings on PR #5388:
- Coerce misfire_grace_seconds via _coerce_optional_float in from_dict so a
  hand-edited quoted YAML value ('3600') no longer raises TypeError and aborts
  the whole claim pass; is_misfire also fails safe on a mis-typed value.
- Exempt a first run (last_run_at is None) from the misfire check for all kinds
  so a never-run cron/at job past a stale slot still fires its initial run
  instead of being marked missed unfired.
- Add 6 regression tests (string/junk grace, cron first-run).

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 10:03:55 +00:00
praisonai-triage-agent[bot]andMervin Praison 69d26ade50 fix: quarantine unrepairable SQLite DBs and harden guard against locks/backup-loss
Address reviewer findings on the #5387 integrity guard:
- Quarantine an unrepairable malformed DB (after forensic backup) so the
  durable store opens a fresh usable database instead of crashing on the first
  CREATE TABLE (the core #5387 durable-path fix).
- Treat "database is locked/busy" as transient, not corruption, so a peer's
  momentary lock never triggers a needless backup + repair.
- Fail closed: skip repair when a requested forensic backup could not be taken,
  so repair's atomic promotion never destroys the only copy of the bad bytes.
- Use a per-attempt-unique repair snapshot so concurrent repairs cannot share
  or clobber each other's in-flight rebuild.
- Prune orphaned -wal/-shm forensic sidecars with their primary copy so WAL
  backups cannot escape the retention cap.

Adds tests for quarantine-and-boot, lock-not-corruption, failed-backup-stops-
repair, and an end-to-end transcript-store recovery on a malformed DB.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 10:03:50 +00:00
praisonai-triage-agent[bot]andMervinPraison df4fa6d412 fix: park scheduled jobs past provider rate-limit window (fixes #5386)
A recurring job whose model provider is 429/quota-limited kept firing on
every interval — each tick built an agent, called the provider, recorded a
failed run, and did it all again next tick, burning requests against a
benched provider until a human intervened.

Add a provider-quota-aware hold:
- Core: a single `hold_until` field on `ScheduleJob` (serialised only when
  parked) + a pure `quota_hold_from_failure()` decision helper next to
  `is_due`, which now returns False while `now < hold_until` so intervening
  fires coalesce into the first legal instant after the window.
- Reuse the existing error classifier (`classify_error` / `extract_retry_after`)
  — no new parsing surface; `extract_retry_after` gains an optional cap so the
  hold honours a long reset window (bounded to 24h) instead of the 5-min
  in-tick backoff cap.
- Wrapper `RunPolicy`: opt-in `hold_on_rate_limit` (default True) +
  `hold_slack_seconds`.
- Bot executor: park on a rate-limit failure (agent + backend paths), clear
  the hold on the first run that reaches the model, and emit one "held until N"
  notice via the existing de-duplicating incident path.

Co-authored-by: MervinPraison <[email protected]>
2026-09-30 09:55:32 +00:00
praisonai-triage-agent[bot]andMervinPraison 4b49b0cb87 fix: record missed recurring occurrences with a misfire grace window (fixes #5385)
Recurring jobs that came due while the process was down previously fired a
stale run on recovery (or, for the intended slot, silently resumed from now)
with no record of the missed occurrence. Add a per-job misfire_grace_seconds:
an occurrence older than the grace window on recovery is recorded as an
observable `missed` RunRecord instead of firing late or vanishing. Within the
window it coalesces into one normal fire (unchanged). None (default) keeps
existing behaviour byte-for-byte.

Co-authored-by: MervinPraison <[email protected]>
2026-09-30 09:54:03 +00:00
praisonai-triage-agent[bot]andMervinPraison 8b8664d043 fix: detect, back up, and repair malformed SQLite stores on open (fixes #5387)
Add an opt-in integrity guard to the central storage.sqlite.connect()
chokepoint that every core durable SQLite store already routes through.
On open (gated, not hot-path) it runs a cheap quick_check; on a malformed
file it takes a retention-capped, disk-space-aware forensic backup
(+WAL/SHM sidecars, fails closed on low disk) and attempts a bounded,
least-destructive online-backup repair promoted only when the rebuilt
snapshot itself passes quick_check, so canonical bytes are never touched
by a failed attempt. A sidecar attempt-ledger bounds persistent retry so
a permanently-bad file is not re-repaired every boot.

The core session index and transcript SQLite stores opt in via guard=True
so a corrupt durable file recovers instead of crashing the durable path
with an uncaught sqlite3.DatabaseError. Guard defaults off, keeping all
existing callers unchanged.

Co-authored-by: MervinPraison <[email protected]>
2026-09-30 09:54:03 +00:00
praisonai-triage-agent[bot]andMervin Praison 32b847176f fix: harden session-address grammar (file-like keys + surplus segments)
Address Greptile review on PR #5384:
- P1: anchor hex-tail regex to end-of-string so file-like keys with an
  embedded hex run (e.g. report-deadbeef.js) round-trip via the literal
  escape hatch instead of silently collapsing to the short id.
- P3: parse_session_path now rejects paths with more than 3 segments;
  the builder always escapes namespace/agent into single segments, so a
  longer path is malformed and must not resolve to the wrong agent.
- Add regression tests for both cases.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 09:23:26 +00:00
praisonai-triage-agent[bot]andMervinPraison 7bd363608f fix: add canonical session-address grammar and session.resolve method (fixes #5383)
Adds a pure, dependency-free session-address grammar in core
(praisonaiagents.gateway.addressing): build_session_path/parse_session_path
map an opaque session_key to a stable, human-readable, reconnect-survivable
path (chat/<agentId>/<slug>-<shortId>) so no whole UUID leaks into URLs.
Includes short-id derivation, label slug, reserved-name sentinels, and a
literal-key escape hatch that keeps file-like keys from being mistaken for
static assets.

Registers the read-only session.resolve method in the Gateway method
registry so a friendly reference can be bridged to a concrete session id by
the wrapper's store-backed implementation behind this core contract.

Co-authored-by: MervinPraison <[email protected]>
2026-09-30 09:15:17 +00:00
praisonai-triage-agent[bot]andMervin Praison 9940c80548 test: cover sync+async compaction entry-point budget wiring (P2)
Add tests that exercise both _apply_context_compaction and
_apply_context_compaction_async with an unset max_context_tokens,
asserting each forwards the resolved model-aware budget to
ContextCompactor. Guards against an entry point silently reverting
to a flat default (greptile P2).

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 09:12:00 +00:00
praisonai-triage-agent[bot]andMervin Praison 3c36def58d fix: size in-run compaction trigger to model window not flat 8000 (fixes #5381)
Reuse budgeter.get_model_limit/get_output_reserve in the compaction hook
path so unconfigured agents on large-context models are not compacted at
8k. Explicit ExecutionConfig.max_context_tokens still wins; static 8000
retained only as offline fallback.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 09:04:14 +00:00
praisonai-triage-agent[bot]andMervin Praison 96eb9999bf test: add regression coverage for guardrail fail-closed + async-store session export
- TestGuardrailFailClosed: exercises fail-closed defaults (missing/non-bool
  passed, empty/non-dict response, invalid JSON), on_error block/allow/raise,
  and both sync + async apply paths via a stubbed litellm module.
- Async-store export/import: export_session dispatch, empty-miss, and
  aexport/aimport round-trip against an async conversation store.

Addresses greptile P2 review feedback on PR #5380 (behavioural tests for the
two shipped fixes).

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 08:34:08 +00:00
praisonai-triage-agent[bot]andMervinPraison f948a7acf0 fix: guardrails fail closed + export_session async-store dispatch (fixes #5379)
- capabilities/guardrails: flip default to fail-CLOSED with on_error=
  ("block"|"allow"|"raise") knob and strict schema-check on the LLM
  response, so a 429/timeout/invalid-JSON/missing-passed no longer
  silently green-lights content. Applies to both apply_guardrail and
  aapply_guardrail via shared helpers.
- db/adapter: route export_session through _call_store so it works on
  async conversation stores (mode="async") instead of leaking un-awaited
  coroutines; add aexport_session/aimport_session async twins.

Rejected issue item #3 (InjectionDefense classifier/source_resolver/
after-tool hook) as scope creep: 3 new params/exports with no live
consumer; composition is already achievable via existing extra_patterns.

Co-authored-by: MervinPraison <[email protected]>
2026-09-30 08:25:51 +00:00
Mervin Praison 42b2b1723d Merge pull request #5378 from MervinPraison/fix/ollama-ci-version-prefix
fix(ci): unprefixed OLLAMA_VERSION for extended-ollama install
2026-09-30 08:34:42 +01:00
praisonai-triage-agent[bot]andMervin Praison 654ae660cc docs: sync extended-ollama CI recipe with unprefixed OLLAMA_VERSION
Align docs/local-model-layer/05-live-ci-job.md with the actual workflow:
- OLLAMA_VERSION uses unprefixed "0.33.2" (avoids vv0.33.2 404)
- Install step uses official installer instead of direct release download

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 07:34:25 +00:00
MervinPraisonandCursor 8bdeb7b024 fix(ci): use unprefixed OLLAMA_VERSION for install.sh
install.sh passes ?version=$OLLAMA_VERSION to ollama.com; a leading v
produces vv0.33.2 GitHub release URLs and 404 on extended-ollama installs.

Co-authored-by: Cursor <[email protected]>
2026-09-30 08:30:19 +01:00
praisonai-triage-agent[bot]andMervin Praison c34da486e0 fix(async-bridge): hard timeout backstop + preserve sync-tool no-timeout
Address reviewer findings on PR #5377:
- Bound the caller's future.result() with the same timeout as a backstop so a
  coroutine that ignores cancellation cannot pin the caller past the deadline.
- Sync tool-calling path delegates with timeout=None to avoid a new 300s
  deadline on tools that previously had no limit (backward compat).
- Correct the compaction comment: the bridge removes the spurious asyncio.run
  RuntimeError fallback; provider errors remain best-effort in the summariser.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 07:29:19 +00:00
praisonai-triage-agent[bot]andMervinPraison e12e0a8dc8 fix: make sync→async bridge work in a running loop, preserve contextvars, enforce timeout (fixes #5376)
Finding 1 of the issue:

- utils/async_bridge.run_coroutine_from_any_context now honours its name: in a
  running loop it offloads to a worker thread with its own loop instead of
  raising, copies the caller's contextvars across (trace/session/approval
  context is visible inside async guardrails/callbacks/tools), enforces the
  timeout inside the worker loop via asyncio.wait_for, and never blocks on a
  stuck worker (shutdown(wait=False)). Fixes 1a across all call sites
  (Agent/Task guardrails, execute_callback_sync, planning approval, bus) and
  1b (contextvars lost + timeout not enforced).
- agent/async_safety.run_async_in_sync_context is now a thin delegate to that
  single bridge so the sync tool-calling path shares one implementation.
- chat_mixin compaction drives the async summariser through the bridge and no
  longer catches bare RuntimeError, so a real error from the summariser
  surfaces instead of being silently downgraded to naive truncation (1c).

Co-authored-by: MervinPraison <[email protected]>
2026-09-30 07:21:20 +00:00
praisonai-triage-agent[bot]andMervin Praison b2f6534cd4 fix(eval): surface per-criterion grader_results and preserve edge-case timeouts
Addresses reviewer findings on EvalPort export:
- Emit one grader_result per criterion (matching Suite grader refs) so
  consumers can diff criteria across tools, not just the verdict grader.
- Every case now references the verdict grader alongside its criteria.
- Preserve nonpositive timeout_seconds under metadata.praisonai instead of
  silently applying the 30s default on import (spec timeout_ms must be >= 1).
- Reserve praisonai_verdict grader id (deduped from case criteria).
- Allow valid empty packages to export a spec-shaped empty suite.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 00:14:59 +00:00
praisonai-triage-agent[bot]andMervin Praison 2b6beeb4fd fix: pop user-module sys.modules entry after exec to prevent accumulation (#5157)
Address greptile P1: successful UUID-qualified loads previously stayed in
sys.modules forever, so a long-lived multi-tenant `praisonai serve` process
leaked one dead module (and its globals) per reload. The registration is only
needed *during* exec_module (so decorators/dataclasses that consult
sys.modules[__name__] resolve). Move the pop into a `finally` in both
load_user_module and load_user_module_strict so the entry is removed on both
success and failure; callers already bind the returned object and never read
sys.modules[qualified]. Per-load isolation and failed-exec-only cleanup are
preserved.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 00:14:31 +00:00
praisonai-triage-agent[bot]andMervin Praison c52fab829e fix: namespace user-module sys.modules key per-load to prevent multi-tenant clobber (fixes #5157)
Gap 3: load_user_module/load_user_module_strict registered under a fixed,
non-unique sys.modules name (e.g. "tools", "tools_module"), so two concurrent
user-tool loads in one process (multi-tenant `praisonai serve`) could clobber
each other and a failed exec could pop another tenant's live module. Register
under a per-load-unique qualified key instead; every reachable caller already
binds the returned module object rather than reading sys.modules[name].

Gaps 1 and 2 from the issue were already resolved on this branch.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 00:14:11 +00:00
praisonai-triage-agent[bot]andMervin Praison e1e38fbfb1 fix: make LLM agent attribution task-local to stop concurrent token misattribution (#5052, #3933)
A single LLM instance is frequently shared by multiple concurrently-running
agents. Storing current_agent_name as a plain instance attribute let one
agent's set_current_agent() clobber another's mid-await, misattributing token
spend, and made the ContextVar-free instance non-deep-copyable for cloned
agents.

- Back current_agent_name with a contextvars.ContextVar (_current_agent_name_var)
  so each asyncio task / thread reads and writes its own value; keep the public
  current_agent_name attribute and set_current_agent() API via a property.
- Add LLM.__deepcopy__ so Agent cloning (per gateway channel) hands the clone a
  fresh ContextVar instead of raising "cannot pickle ContextVar".

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 00:14:04 +00:00
praisonai-triage-agent[bot]andMervin Praison d3364e236c fix: make LLM agent-name attribution task-local via ContextVar (#5052)
Concurrent agents sharing one LLM instance overwrote each other's
attribution (plain instance attr), and deep-copying an Agent with a
built LLM raised because the value was not copyable. Back
current_agent_name with a per-task ContextVar and add LLM.__deepcopy__
that gives clones a fresh ContextVar. Fixes the two red core tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 00:13:42 +00:00
praisonai-triage-agent[bot]andMervin Praison 0f1a8fd616 fix: task-local LLM attribution + unbounded CLI coro + shell interrupt safety
- praisonaiagents/llm: back current_agent_name with a ContextVar so concurrent
  agents sharing one LLM no longer misattribute tokens; add LLM.__deepcopy__
  giving clones a fresh (non-copyable) ContextVar. Fixes the two CI failures
  (test_default_token_tracking, test_agent_clone) which exposed a real
  multi-agent-safety bug (AGENTS.md 4.5).
- _async_bridge.run_cli_coro: default to timeout=None (unbounded) to preserve
  prior asyncio.run semantics; the 300s run_sync default would abort long CLI
  commands (Greptile P1).
- sandbox_cli: keep the interactive shell's blocking input() and Ctrl+C on the
  main thread, dispatching only the async sandbox ops through the bridge, so
  KeyboardInterrupt still lands in the shell and never blocks the shared loop
  (Greptile P1).

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 00:13:35 +00:00
praisonai-triage-agent[bot]andMervinPraison d0782954c9 fix: wrapper adapter-registry parity, CLI AsyncBridge routing, scheduler stats lock (fixes #5321)
- Single-source register_default_adapters() in AgentsGenerator.__init__ so
  YAML retriever:/reader:/reranker: resolve on CLI, serve, and eval paths too,
  not just praisonai.run()/arun(). arun pre-warms wiring off the event loop.
- Add _async_bridge.run_cli_coro() and migrate the 10 cli/ asyncio.run() sites
  onto the shared bridge so per-loop pools survive and scoped_bridge() is honoured.
- Snapshot delivery counters under _delivery_lock in _build_stats and hoist a
  shared get_stats_sync onto the base so both scheduler surfaces return the same
  dict shape (runtime_seconds/cost_per_execution) with a consistent counter pair.

Co-authored-by: MervinPraison <[email protected]>
2026-09-30 00:13:07 +00:00
praisonai-triage-agent[bot]andMervin Praison a6af191716 fix: correct StartupWatchdog progress budget accounting and late-exit race
Address PR #5266 review findings on the startup-phase watchdog:

- Progress budget accounting is now performed by the watchdog thread when it
  drains queued reports, not per-call in report_startup_progress(). Batched
  reports landing between two wakes each still cost one extension AND each earn
  a fresh deadline window, so a later slow-but-progressing phase can no longer
  find the budget spent on resets it never received.
- Add a final stop.is_set() re-check immediately before os._exit so a
  confirm_loop_live() that lands while the pre-exit stderr.flush() blocks can
  still cancel the irreversible exit.
- Tests: update bounded-extension accounting to assert post-drain; add coverage
  for the active exit path and confirm-during-flush suppression.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 00:12:57 +00:00
praisonai-triage-agent[bot]andMervinPraison 46e0872007 fix: add startup-phase watchdog for gateway pre-loop window (fixes #5265)
Adds StartupWatchdog to praisonaiagents/gateway/liveness.py, completing the
watchdog ladder (running-loop #3385, shutdown #5079) with a front rung that
covers imports/config-load/adapter-connect before the event loop is live.

Stdlib-only, opt-in, fail-open: arms a wall-clock deadline on a daemon thread
before heavy startup, extends it via bounded report_startup_progress() for a
slow-but-alive boot, and on a true wedge dumps all-thread stacks via
faulthandler then os._exit(75) so a supervisor restarts the process instead
of leaving a silent zombie. confirm_loop_live() disarms it once the serving
loop begins, at which point LoopWatchdog takes over.

Co-authored-by: MervinPraison <[email protected]>
2026-09-30 00:12:57 +00:00
praisonai-triage-agent[bot]andMervin Praison e261f19cb7 fix: address reviewer gaps — transient adapter recovery for find_spec negatives, URL-less backend overrides, safe store-URL scheme handling
- registry.is_available: treat ANY negative probe as transient (not just
  ImportError) so built-in optional adapters (crewai/autogen) that report
  absence via find_spec-style False recover after a mid-life `pip install`;
  drop the underlying _framework_availability memo before re-probing so the
  stale permanent False is not read back. Positive/structural stay permanent.
- PraisonAIDB._build_stores: build a store when only a *_backend override is
  given (no URL) so URL-less backends (memory/dynamodb/firestore) are reachable.
- PraisonAIDB URL schemes: drop gcs/cosmosdb schemes that cannot construct from
  a bare scheme URL — they raise a helpful error pointing at the *_backend=
  override + options. pgvector:// is rewritten to postgresql:// so psycopg2
  accepts the forwarded DSN.

CI failures (test_default_token_tracking, test_agent_clone) are pre-existing and
unrelated: they reference LLM._current_agent_name_var / per-agent token
attribution not present in core and not touched by this PR.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 00:12:52 +00:00
praisonai-triage-agent[bot]andMervin Praison 2d6ab9f1eb fix: wrapper gaps — store-aware DB detection, transient adapter-probe recovery, authoritative agents→roles promotion (fixes #5306)
- PraisonAIDB: add database_backend/state_backend/knowledge_backend overrides
  and make _detect_backend store-kind-aware so state_url/knowledge_url can reach
  registered backends (mongodb, dynamodb, chroma, pinecone, ...) beyond the
  http(s)-sniffed handful.
- FrameworkAdapterRegistry.is_available: cache transient ImportError negatives
  with a cool-down so long-lived servers recover after a mid-life pip install;
  structural/positive results stay memoised.
- _prepare_for_run: make agents:->roles: promotion authoritative (merge both
  buckets, roles wins on conflict, drop agents:) so validators and executor
  read one source of truth.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-30 00:12:52 +00:00
praisonai-triage-agent[bot]andadhabnr-ux 101308347f fix: emit EvalPort spec-conformant Suite/ResultSet documents (fixes #5374)
Align to_evalport/report_to_evalport with the EvalPort spec so their output
passes validate_suite/validate_result_set and the JSON Schemas. Uses spec
field names (id/test_cases/graders, suite_id/run_id/started_at/grader_results),
keeps PraisonAI-only data under metadata.praisonai, clamps out-of-range scores,
and stays dependency-free and backward-compatible (legacy suites still import).

Co-authored-by: adhabnr-ux <[email protected]>
2026-09-30 00:06:07 +00:00
MervinPraison d9453cb8e3 docs: auto-update documentation parity trackers [skip ci] 2026-09-29 10:57:32 +00:00
Mervin Praison e7455e5a35 Merge pull request #5370 from MervinPraison/fix/async-safe-yaml-post-5359
fix(agent): reentrant history lock and YAML reflection attrs (post #5359)
2026-09-29 11:56:42 +01:00
MervinPraisonandCursor 8005635510 fix(agent): reentrant history lock and YAML reflection attrs
Route AsyncSafeState __enter__ through DualLock.sync so nested chat_history
setters do not deadlock, and stash legacy reflect_* YAML fields on agents.

Co-authored-by: Cursor <[email protected]>
2026-09-29 11:55:57 +01:00
praisonai-triage-agent[bot] 0807964727 Release v4.7.11 2026-09-29 10:43:30 +00:00
praisonai-triage-agent[bot]andMervin Praison 297095f348 fix: harden outbox operator surface against live-state corruption
Address reviewer findings on the outbound queue operator commands:
- read_only mode: list/stats no longer flip in-flight 'sending' rows to
  'recovered', preventing a running drain from re-claiming/duplicating a
  message during inspection.
- Full-key verification: retry/purge_entry match the whole
  target:idempotency:id key against the stored row instead of trusting the
  trailing id alone, so a stale/mistyped key cannot mutate the wrong entry.
- retry excludes 'recovered' entries: their crash-duplicate safeguard is
  preserved for the next drain's reconcile/annotate path.
- purge_entry refuses in-flight 'sending' rows so a delete cannot race an
  active send's terminal write.
- CLI prints the full copyable tracking key (no ellipsis over the id).

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-29 10:41:21 +00:00
praisonai-triage-agent[bot]andMervin Praison 90b59e2e89 fix: honour explicit YAML watchdog/drain over GatewayConfig fields (#5362)
Address greptile P1 review findings on the drain/watchdog parity change:
- watchdog_timeout now survives to_dict->from_dict (read scalar key, not
  just the block-derived budget)
- explicit scalar ``watchdog: false`` beats GatewayConfig(watchdog=True)
  via a yaml_present flag distinguishing an absent key from a scalar/block
- explicit ``drain_timeout: null`` (present-but-null) is honoured over the
  Python field instead of falling back to it

Co-authored-by: Mervin Praison <[email protected]>
2026-09-29 10:33:38 +00:00
praisonai-triage-agent[bot]andMervinPraison 8188b2983d fix: add operator inspect/retry/purge surface for outbound queue (fixes #5366)
Give the durable outbound delivery path operator parity with the inbound DLQ:
add read-mostly + targeted ops to OutboundQueue (list/stats/retry/purge_entry)
and a `praisonai gateway outbox list|stats|retry|purge` CLI mirroring
`praisonai bot dlq`. A `gateway status --deep` hint points operators at it.

Kept minimal per AGENTS.md: no core protocol addition (secondary/optional in
the issue, no live consumer) — wrapper-only surface over the existing SQLite
outbox.

Co-authored-by: MervinPraison <[email protected]>
2026-09-29 10:29:37 +00:00
Mervin Praison c61dc9a64c Merge pull request #5359 from MervinPraison/fix/main-ci-error-classifier-llm-helpers
fix(llm): restore Core Tests error classifier and tool-reliability helpers
2026-09-29 11:14:47 +01:00
MervinPraison 4531bee718 Merge origin/main into fix/main-ci-error-classifier-llm-helpers 2026-09-29 11:14:16 +01:00
praisonai-triage-agent[bot]andMervinPraison eeb0dcaf16 fix: add drain/watchdog fields to GatewayConfig for CLI+YAML+Python parity (fixes #5362)
Complete the gateway back-pressure/lifecycle posture on the public
GatewayConfig dataclass so drain_timeout / watchdog / watchdog_timeout can be
set declaratively from Python and gateway.yaml, matching the existing CLI
flags. The wrapper server now falls back to these config fields when no CLI
override or YAML key is present, and the YAML loader populates them from the
existing gateway.drain_timeout / gateway.watchdog keys.

Co-authored-by: MervinPraison <[email protected]>
2026-09-29 10:06:55 +00:00
MervinPraisonandCursor 30be9a8ab3 fix(llm): restore task-local agent_id for token tracking
Wire current_agent_id through a per-instance ContextVar and
set_current_agent so token collector calls succeed; deep-copy the id
ContextVar alongside name attribution.

Co-authored-by: Cursor <[email protected]>
2026-09-29 10:48:30 +01:00
Mervin Praison 228a2e8e92 Merge pull request #5364 from MervinPraison/fix/pipeline-sync-fork-pr-labels
Fix pipeline label sync for fork PR heads
2026-09-29 10:19:09 +01:00
MervinPraisonandCursor 4b4265b317 Fix pipeline label sync for fork PR heads.
Scheduled Pipeline Status Sync skipped fork PRs entirely, so labels such as pipeline/external-example never applied on cross-repo contributions like #5355.

Co-authored-by: Cursor <[email protected]>
2026-09-29 10:18:19 +01:00
praisonai-triage-agent[bot]andMervinPraison 49e77e0761 fix: close activity-surface leaks and retain config in schema (#5314)
- Never fall back to the raw tool name when a curated catalogue is opted
  in but lacks the matched category and a default phrase; use a safe
  generic line instead (privacy leak fix).
- Skip the raw feed (which folds tool names/summaries) when
  activity_phrases is enabled so feed style cannot bypass the curated
  surface (privacy leak fix).
- Retain activity_status/activity_phrases on StreamingConfigSchema so the
  documented YAML block survives validation and reaches from_dict.
- Add tests covering partial-catalogue no-leak, feed no-bypass, and
  schema retention.

Co-authored-by: MervinPraison <[email protected]>
2026-09-29 09:09:00 +00:00
praisonai-triage-agent[bot]andMervinPraison 6c708aad00 fix: add opt-in privacy-safe activity surface for bot long tool runs (fixes #5314)
Adds a curated, category-keyed phrase catalogue to DraftStreamer so a
human-readable activity line (e.g. "Searching the web…") can be shown during
an output-silent tool run instead of the raw tool name. Off by default;
falls back to existing behaviour when unconfigured. Tool names/args/URLs are
never surfaced when a catalogue is set.

Co-authored-by: MervinPraison <[email protected]>
2026-09-29 09:09:00 +00:00
MervinPraisonandCursor 8c021ad12f Merge origin/main into fix/main-ci-error-classifier-llm-helpers
Resolve llm.py conflicts (classifier keywords, auth failover comment),
dedupe LLM.__deepcopy__, and copy threading locks on LLM subclasses for
ScriptedModel clone tests.

Co-authored-by: Cursor <[email protected]>
2026-09-29 10:08:11 +01:00
MervinPraisonandCursor d3778f8917 chore: ignore local .worktrees directory
Co-authored-by: Cursor <[email protected]>
2026-09-29 10:03:59 +01:00
praisonai-triage-agent[bot]andMervin Praison ed69eaae1a fix: enforce IP cap post-insert, exit-safe compute release, retryable store close
Address reviewer feedback on PR #5348 (wrapper-tier robustness):

- call.py: split touch_ip/record_ip so the LRU eviction runs AFTER the live
  caller is pinned MRU — the map now holds at most max_tracked_ips (never
  max+1) and an active caller is never the eviction victim. Remove dead
  `global active_connections` (state now lives on CallAppState).
- compute_managed_agent.py: at interpreter exit (sys.is_finalizing()) block on
  the release future for a bounded window so a provisioned instance is actually
  reclaimed before the daemon bridge loop is killed; live GC stays
  fire-and-forget so the caller's loop is never pinned.
- orchestrator.py: reset a store field to None only on a successful close so a
  store that raised keeps its reference and can be retried, instead of leaking
  its pooled connections. Applies to both close() and aclose().

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-29 09:03:43 +00:00
praisonai-triage-agent[bot]andMervinPraison 25e6312b63 fix: bound voice rate-limiter, isolate persistence close(), unpin compute release (fixes #5347)
- call.py: move active_connections/client_ips rate-limit state onto
  CallAppState (per-app, multi-tenant isolated) with an LRU-bounded
  client_ips map so rotating IPs cannot grow it without bound.
- orchestrator.py: close() now isolates per-store errors and resets
  fields (idempotent); add aclose()/__aenter__/__aexit__ for async
  lifespans that must not block the running loop.
- compute_managed_agent.py: _release routes shutdown through the shared
  AsyncBridge (no fresh loop per teardown) and is fire-and-forget so the
  caller's event loop is never pinned for the cloud round-trip.

Co-authored-by: MervinPraison <[email protected]>
2026-09-29 09:03:43 +00:00
MervinPraison 44f35bc1a9 docs: auto-update feature parity trackers [skip ci] 2026-09-29 09:03:20 +00:00
Mervin Praison d22683aa64 Merge pull request #5233 from MervinPraison/claude/issue-5052-20260923-1220
fix: streaming tool-result serialization & concurrent token misattribution
2026-09-29 10:00:12 +01:00
MervinPraisonandCursor 4f0fc71a0e Merge origin/main into PR branch; dedupe llm token attribution with main
Co-authored-by: Cursor <[email protected]>
2026-09-29 09:59:57 +01:00
Mervin Praison 7b98896937 Merge pull request #5245 from MervinPraison/claude/issue-5069-20260923-1222
fix: cross-tenant token collisions, dropped success-callbacks, masked import failures
2026-09-29 09:59:16 +01:00
MervinPraisonandCursor 953f2a9b3c Merge origin/main into PR branch; align llm attribution with main
Co-authored-by: Cursor <[email protected]>
2026-09-29 09:59:07 +01:00
Mervin Praison 6066f63b04 Merge pull request #5228 from MervinPraison/claude/issue-5060-20260923-1221
fix: isolate sync workflow bridge and keep per-run closures off cli_config
2026-09-29 09:58:24 +01:00
MervinPraisonandCursor a556236c5a Merge origin/main into PR branch
Co-authored-by: Cursor <[email protected]>
2026-09-29 09:58:16 +01:00
MervinPraisonandCursor 14beec26b7 Merge origin/main into PR branch
Co-authored-by: Cursor <[email protected]>
2026-09-29 09:58:04 +01:00
Mervin Praison 19d828004d Merge pull request #5258 from MervinPraison/claude/issue-5257-20260924-0724
fix: async-safe auto-save, publish_async history parity, concurrency release-by-handle
2026-09-29 09:57:48 +01:00
MervinPraisonandCursor c2d87421c2 Merge origin/main into PR branch; use DynamicLimiter concurrency from main
Co-authored-by: Cursor <[email protected]>
2026-09-29 09:57:37 +01:00
MervinPraisonandCursor 2096c2babf Merge origin/main into claude/issue-5257 (PR #5258)
Resolve concurrency and llm.py conflicts after main landed _DynamicLimiter.

Co-authored-by: Cursor <[email protected]>
2026-09-29 09:57:12 +01:00
MervinPraison de9320b49d docs: auto-update documentation parity trackers [skip ci] 2026-09-29 08:56:33 +00:00
MervinPraison f48320ade7 docs: auto-update api.md [skip ci] 2026-09-29 08:55:39 +00:00
praisonai-triage-agent[bot]andMervin Praison 88f058e788 fix(llm): tolerate missing max_tool_repairs in _tool_repair_message
Read max_tool_repairs via getattr so LLM instances built with __new__
(test stubs) don't raise AttributeError, matching the getattr tolerance
already used in _should_force_tool_usage. Restores parity after rebase.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-29 08:55:34 +00:00
Mervin Praison c9f4b35c6a Merge pull request #5260 from MervinPraison/claude/issue-5259-20260924-0826
fix: harden workflow generators + tool-timeout pool lifecycle
2026-09-29 09:55:21 +01:00
MervinPraisonandCursor 019d099364 Merge origin/main into PR branch
Co-authored-by: Cursor <[email protected]>
2026-09-29 09:55:12 +01:00
Mervin Praison 0d04ae3230 Merge pull request #5326 from MervinPraison/claude/issue-5324-20260926-0914
fix: add session-projection reducer for gateway clients
2026-09-29 09:55:06 +01:00
MervinPraisonandCursor edcd3b9708 Merge origin/main into PR branch
Co-authored-by: Cursor <[email protected]>
2026-09-29 09:54:58 +01:00
Mervin Praison e006e073bb Merge pull request #5330 from MervinPraison/claude/issue-5329-20260927-0718
fix: repair three silently-broken core-SDK reliability guarantees
2026-09-29 09:54:52 +01:00
MervinPraisonandCursor 17f535ac05 Merge origin/main into PR branch
Co-authored-by: Cursor <[email protected]>
2026-09-29 09:54:45 +01:00
Mervin Praison e020813533 Merge pull request #5332 from MervinPraison/claude/issue-5331-20260927-0821
fix: repair GCS state store, atomic memory writes, drift-aware tool allowlist
2026-09-29 09:54:39 +01:00
MervinPraisonandCursor 84c322b7c8 Block auto-merge for external-only example PRs.
Label diffs confined to examples/tools/external/, keep them off merge-ready unless maintainers opt in, and teach FINAL review to redirect to PraisonAI-Tools.

Co-authored-by: Cursor <[email protected]>
2026-09-29 09:54:26 +01:00
MervinPraisonandCursor bb1030d6d7 Merge origin/main into PR branch
Co-authored-by: Cursor <[email protected]>
2026-09-29 09:54:25 +01:00
MervinPraison 1c6a23882e docs: auto-update documentation parity trackers [skip ci] 2026-09-29 08:54:09 +00:00
Mervin Praison 4c638953e0 Merge pull request #5350 from MervinPraison/claude/issue-5349-20260928-0918
fix: incremental token streaming on gateway OpenAI-compatible SSE surface
2026-09-29 09:53:16 +01:00
MervinPraisonandCursor 68e4e3faa1 Merge origin/main; keep streaming ContextVar import, align llm attribution with main
Co-authored-by: Cursor <[email protected]>
2026-09-29 09:53:06 +01:00
praisonai-triage-agent[bot]andMervin Praison 5a32d3b584 fix(llm): task-local agent attribution, deepcopy-safe LLM, wired string-arg guard
- Back current_agent_name with a per-task ContextVar (_current_agent_name_var)
  so concurrent agents sharing one LLM no longer misattribute tokens (#5052);
  add LLM.__deepcopy__ that gives clones a fresh ContextVar.
- Make _tool_repair_message / _should_force_tool_usage tolerant of
  partially-built LLM instances via getattr defaults (fixes async
  tool-reliability AttributeError on max_tool_repairs/force_tool_usage).
- Wire _param_accepts_string into _resolve_ollama_chained_args so a declared
  string argument matching a prior tool name is preserved instead of being
  overwritten with an unrelated result (Greptile P1); add end-to-end tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-29 08:52:39 +00:00
Mervin Praison 7be7e2274f Merge pull request #5352 from MervinPraison/claude/issue-5351-20260928-0952
fix: reconnect-safe server→client interactive request primitive
2026-09-29 09:52:18 +01:00
MervinPraison 8c58ca5aa9 docs: auto-update feature parity trackers [skip ci] 2026-09-29 08:52:11 +00:00
MervinPraisonandCursor 9a247a1cc3 fix(llm): restore task-local agent attribution and JSON tool recovery
Use a per-instance ContextVar for token attribution and deepcopy-safe clones,
and accept full-content JSON tool-call payloads without allowlist filtering.

Co-authored-by: Cursor <[email protected]>
2026-09-29 08:51:57 +00:00
MervinPraisonandCursor d8d6672d32 Merge origin/main into PR branch; resolve llm.py attribution overlap with main
Co-authored-by: Cursor <[email protected]>
2026-09-29 09:51:55 +01:00
MervinPraison dd2c326bc1 docs: auto-update api.md [skip ci] 2026-09-29 08:51:10 +00:00
MervinPraisonandCursor c5a02d2105 fix(llm): tolerate partial LLM stubs in tool-repair helpers
Use getattr for max_tool_repairs and force_tool_usage so __new__ test doubles
and deep-copied paths do not raise when shared reliability helpers run.

Co-authored-by: Cursor <[email protected]>
2026-09-29 08:50:53 +00:00
MervinPraisonandCursor 836b7991c5 fix(llm): restore error classifier keywords and shared tool-reliability helpers
PR #5021 dropped LLM helpers and async force/repair wiring that Core Tests
expect; re-align classify_error_kind with the unit suite and surface auth
failures when no failover manager is configured.

Co-authored-by: Cursor <[email protected]>
2026-09-29 08:50:53 +00:00
Mervin Praison c4e56141a5 Merge pull request #5229 from MervinPraison/claude/issue-5067-20260923-1222
fix: make orphaned room turn-planner usable via RoomConfig + pure Room runtime
2026-09-29 09:49:59 +01:00
Mervin Praison 885d3802cb Merge pull request #5230 from MervinPraison/claude/issue-5053-20260923-1221
fix: managed-agent resource races, silent scheduler failures, PraisonAIDB kwarg cross-contamination
2026-09-29 09:49:54 +01:00
Mervin Praison 400199afb3 Merge pull request #5232 from MervinPraison/claude/issue-5054-20260923-1221
fix: unified, authorised agent-callable message-action surface (edit/delete/thread)
2026-09-29 09:49:49 +01:00
Mervin Praison 4cf0bf9311 Merge pull request #5234 from MervinPraison/claude/issue-5065-20260923-1222
fix: per-run DI scoping for strict-validation and tool-timeout-worker toggles
2026-09-29 09:49:42 +01:00
Mervin Praison fddf6ee28b Merge pull request #5236 from MervinPraison/claude/issue-5055-20260923-1221
fix: scrub secrets from outbound gateway reply path
2026-09-29 09:49:38 +01:00
Mervin Praison 98801bd546 Merge pull request #5238 from MervinPraison/claude/issue-5064-20260923-1221
fix: gate code-mode tools, lock BackgroundRunner tasks, unblock Gemini async
2026-09-29 09:49:33 +01:00
Mervin Praison c48cb2d14a Merge pull request #5240 from MervinPraison/claude/issue-5150-20260923-1224
fix: multi-tenant chat_history bleed, per-loop httpx pool leak, transient availability pin
2026-09-29 09:49:28 +01:00
Mervin Praison 1a9642db1a Merge pull request #5241 from MervinPraison/claude/issue-5154-20260923-1226
fix: cluster-wide Redis inbound idempotency store (exactly-once admission)
2026-09-29 09:49:24 +01:00
Mervin Praison 40e766bccc Merge pull request #5242 from MervinPraison/claude/issue-5062-20260923-1221
fix: wire BotLoopGuard into inbound path + config surface
2026-09-29 09:49:20 +01:00
Mervin Praison d6d84975f0 Merge pull request #5246 from MervinPraison/claude/issue-5146-20260923-1223
fix: shared HTTP ingress for webhook channels
2026-09-29 09:49:12 +01:00
Mervin Praison 8fab828805 Merge pull request #5247 from MervinPraison/claude/issue-5149-20260923-1223
fix: default-path rollback, async guardrail offload, per-agent hook isolation
2026-09-29 09:49:08 +01:00
Mervin Praison 8d2314b2a1 Merge pull request #5248 from MervinPraison/claude/issue-5157-20260923-1240
fix: namespace user-module sys.modules key to prevent cross-tenant clobber
2026-09-29 09:49:04 +01:00
Mervin Praison 692984a872 Merge pull request #5263 from MervinPraison/claude/issue-5261-20260924-0915
fix: honor hook priority for deterministic execution order
2026-09-29 09:48:54 +01:00
Mervin Praison 4d7cdecaca Merge pull request #5267 from MervinPraison/claude/issue-5264-20260924-0950
fix: harden core SQLite stores with WAL-safe-fallback connector
2026-09-29 09:48:50 +01:00
Mervin Praison 22e4e63fbd Merge pull request #5305 from MervinPraison/claude/issue-5304-20260925-0721
fix: wire workspace_root, async guardrails, async return_token_usage (#5304)
2026-09-29 09:48:45 +01:00
Mervin Praison 6cde2dcff0 Merge pull request #5310 from MervinPraison/claude/issue-5308-20260925-0903
fix: seed checkpoint shadow-git from project repo + tune for large repos
2026-09-29 09:48:40 +01:00
Mervin Praison 1165e53fcd Merge pull request #5328 from MervinPraison/claude/issue-5327-20260926-0948
fix: wire plugin GUARDRAIL/POLICY/SKILL types into typed subsystems
2026-09-29 09:48:28 +01:00
Mervin Praison ab016e1f46 Merge pull request #5334 from MervinPraison/claude/issue-5333-20260927-0911
fix: add network-egress dimension to core PolicyEngine
2026-09-29 09:48:20 +01:00
Mervin Praison 0f03856ce0 Merge pull request #5336 from MervinPraison/claude/issue-5335-20260927-0955
fix: add background/async runs to OpenAI-compat /v1/responses
2026-09-29 09:48:15 +01:00
Mervin Praison 91c9e95be7 Merge pull request #5338 from MervinPraison/claude/issue-5337-20260928-0722
fix: wire dropped YAML agent fields, reachable doom-loop check, real DualLock mutual exclusion
2026-09-29 09:48:10 +01:00
Mervin Praison f405ac0537 Merge pull request #5357 from MervinPraison/claude/issue-5356-20260929-0716
fix: approval timeout race, non-atomic state writes, hung-tool pool starvation
2026-09-29 09:48:00 +01:00
Mervin Praison b63198ee9c Merge pull request #5360 from MervinPraison/claude/issue-5358-20260929-0821
fix: webhook TOCTOU/SSRF, explicit --max-tokens, sync workflow bridge isolation
2026-09-29 09:47:52 +01:00
praisonai-triage-agent[bot]andMervin Praison 09e6aac2ad fix: pin webhook delivery to validated IP and surface unfollowed redirects
Closes the remaining DNS-rebinding gap flagged by Greptile (P1): the
executor now pins the HTTP connection to a just-screened public IP
(preserving Host header and TLS SNI) instead of letting the kernel
re-resolve the hostname at connect time. Adds resolve_validated_webhook_targets()
to jobs/models.py which returns the screened IPs from the single validator.

Also surfaces unfollowed 3xx responses (P2): a redirect from a webhook
endpoint is now logged as an undelivered warning instead of a silent
"Webhook sent" success.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-29 08:41:51 +00:00
praisonai-triage-agent[bot]andMervinPraison 4e0fff6623 fix: close webhook TOCTOU/SSRF, honour explicit --max-tokens, isolate sync workflow bridge (fixes #5358)
- jobs: single shared validate_webhook_url helper using getaddrinfo (all
  A/AAAA records); re-validate immediately before delivery and disable
  redirect-following to close the DNS-rebinding/TOCTOU exfiltration window.
- cli: use a sentinel default for --max-tokens and write a _max_tokens_explicit
  marker at parse time so an explicit "--max-tokens 16000" is no longer dropped
  by the value-equality shortcut.
- agents_generator: wrap the sync YAML workflow path in scoped_bridge() to match
  the sequential/hierarchical and async workflow paths for per-tenant isolation.

Co-authored-by: MervinPraison <[email protected]>
2026-09-29 08:26:30 +00:00
praisonai-triage-agent[bot]andMervin Praison 4985015475 fix: restore task-local LLM attribution + harden approval-timeout routing & orphan-pool accounting
- llm.py: restore ContextVar-backed current_agent_name (property/setter) and
  __deepcopy__ so concurrent agents sharing one LLM don't misattribute tokens
  and deepcopy of a built LLM no longer raises (fixes both CI failures; the
  ContextVar impl was lost in a prior merge while its guarding tests remained).
- tool_execution.py: replace call-and-catch TypeError approval fallback with
  side-effect-free signature introspection (_approval_backend_accepts_timeout)
  so a backend/user-callback that raises its own TypeError is never re-invoked,
  and legacy backends that DO accept timeout still receive the per-call value.
- tool_execution.py: fix orphan-pool undercount — recover an orphan slot once
  per retired POOL (on its last in-flight future, one-shot guarded) instead of
  once per completed future, so a multi-worker pool can't steal another
  orphaned pool's slot.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-29 07:39:46 +00:00
praisonai-triage-agent[bot]andMervinPraison 1790d044be fix: approval timeout race, non-atomic state writes, hung-tool pool starvation (fixes #5356)
- Stop mutating shared approval backend `_timeout`; pass per-call timeout
  explicitly via `request_approval_sync(request, timeout=...)` with a
  TypeError fallback for legacy backends.
- Add utils/atomic_io.py (atomic_write_json/atomic_write_text) and route
  permission rules/approvals, workflow checkpoints, planning storage,
  cost reports and async storage writes through it so a crash or a
  serialisation error never truncates the previous good file.
- Track per-executor in-flight count; once the orphan cap is hit, refuse
  to queue a healthy tool behind hung workers (tool_pool_exhausted) and
  recover orphan slots as hung threads finish, so the pool self-heals.

Co-authored-by: MervinPraison <[email protected]>
2026-09-29 07:28:19 +00:00
praisonai-triage-agent[bot]andMervinPraison 6cb57dfa3d fix: secure server_reply channel + task-local LLM token attribution
Address review + CI on the reconnect-safe server→client request primitive
(#5351):

- CI root cause (issue #5052, unrelated to this PR's diff): a shared LLM's
  current_agent_name was a plain instance attribute, so concurrent agents
  misattributed tokens, and the non-picklable state broke Agent deepcopy.
  Move attribution to a per-instance ContextVar (task-local) exposed via a
  current_agent_name property, and add LLM.__deepcopy__ giving each clone a
  fresh ContextVar. Fixes test_concurrent_agents_sharing_one_llm... and
  test_deepcopy_agent_with_built_llm_instance_does_not_raise.

- Security (greptile P1): server_reply now validates session ownership — a
  WRITE client cannot answer another session's request by guessing its id.
- Security (greptile P1): an approval reply requires the APPROVALS scope
  (parity with approvals.resolve); choice/input still require WRITE.
- Contract (greptile P2): reply value is validated against the request kind
  (allow/deny for approval, an offered option for choice).
- Routing (greptile P1): the legacy join path now rebinds session._client_id
  like hello, so a reconnecting client becomes the delivery target.

Adds tests/unit/gateway/test_server_request_channel.py covering the shapes,
end-to-end request/reply/timeout, reconnect replay, and every security path.

Co-authored-by: MervinPraison <[email protected]>
2026-09-28 10:20:58 +00:00
praisonai-triage-agent[bot]andMervinPraison da9d1fdc04 fix: reconnect-safe server→client interactive request primitive (fixes #5351)
Add a transport-level HITL request/reply channel so a blocked approval /
choice / input turn can reach a generic gateway client and — critically —
survive reconnect.

Core owns only the shape: GatewayServerRequest / GatewayServerReply,
the GatewayRequestChannelProtocol seam (with an open_requests contract the
resume path honours), and a task-local register/get/clear_request_channel
slot mirroring the existing outbound-messenger / conversation-requester
bindings. No heavy import lives in core.

The praisonai-bot gateway binds the implementation: delivers a typed
server_request frame, correlates the client's server_reply, tracks the
still-open (unanswered) requests per session, and re-issues only those on
hello/join resume so a dropped connection never wedges a turn.

Co-authored-by: MervinPraison <[email protected]>
2026-09-28 10:04:53 +00:00
praisonai-triage-agent[bot]andMervin Praison ff8362cd66 fix: isolate gateway token streams + task-local LLM agent attribution
Gateway SSE streaming (api_endpoints._dispatch_stream):
- Include FIRST_TOKEN so the opening answer piece is never dropped (Greptile P1)
- Drop is_reasoning deltas so private reasoning never leaks as answer text (Greptile P1)
- Isolate concurrent streams on a shared agent emitter via a per-turn ContextVar
  token so callers never receive each other's tokens (Greptile P1)
- Emit a content frame even for an empty reply so the frame shape matches the
  buffered single-chunk path exactly (Greptile P2)

Core LLM (llm.py) — fixes pre-existing CI failures (#5052):
- Back current_agent_name with a task-local ContextVar so concurrent agents
  sharing one LLM instance don't misattribute tokens
- Add LLM.__deepcopy__ giving clones a fresh ContextVar (ContextVars are not
  copyable), so deep-copying an agent with a built LLM no longer raises

Tests: +4 gateway tests (first-token/reasoning, empty-reply frame, incremental
before-completion delivery, concurrent no-mix). Full gateway suite 31 passed;
llm+clone+streaming+agent+telemetry suites green.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-28 09:36:38 +00:00
praisonai-triage-agent[bot]andMervin Praison 5cc2240915 fix: wire agent token deltas to gateway SSE hot path for incremental streaming (fixes #5349)
The OpenAI-compatible /v1/chat/completions SSE surface buffered the whole
turn and emitted it as one chunk. Add opt-in gateway.api.stream that wires
the agent's existing stream_emitter DELTA_TEXT events through the request
hot path so each token arrives as its own chat.completion.chunk frame in
real time. Off by default: streaming stays byte-for-byte the buffered path,
so non-streaming latency and correctness never regress. Falls back to the
single buffered chunk when an agent emits no deltas.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-28 09:21:53 +00:00
praisonai-triage-agent[bot]andMervin Praison ad3b58f3c3 fix: resolve DualLock handoff deadlock, count doom-loop once, wire YAML callbacks
- async_safety: async_lock() now registers the event-loop thread as sync owner
  so the nested async->sync handoff seeding path is re-entrant instead of
  deadlocking on the shared mutex; add cancellation-safe mutex acquire so a
  cancelled acquire never strands the lock; fail-fast the copying guard before
  blocking on the mutex (fixes async-deepcopy-rejection regression).
- tool_execution: split doom-loop recording out of the deny gate into a
  dedicated check invoked exactly once per approved call with final args, so
  rejected attempts and arg rewrites are not counted twice.
- yaml_parser: wire resolved lifecycle callbacks into Workflow(hooks=...) so a
  registered on_step_complete/on_workflow_start etc. actually fires.
- tests: update stale _yaml_* assertions to the real wired Agent behaviour; add
  DualLock deadlock/mutual-exclusion guards, callback-wiring and doom-loop-gate
  tests.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-28 07:57:36 +00:00
praisonai-triage-agent[bot]andMervin Praison 5eb8d606e6 fix: wire dropped YAML agent fields, reachable doom-loop check, real DualLock mutual exclusion (fixes #5337)
- yaml_parser: pass planning/reasoning/allow_delegation/cache/max_iter/
  max_execution_time/reflect_*/templates through the real Agent constructor
  kwargs instead of dead _yaml_* attributes; resolve callback func_name via
  the registry and warn on unresolved names.
- tool_execution: consult PermissionManager.check_doom_loop() at the live
  permission gate so a configured PermissionManager actually delivers the
  doom-loop protection it advertises (was unreachable dead code).
- async_safety.DualLock: back sync() and async_lock() with one shared mutex so
  they are genuinely mutually exclusive across sync/async callers; keep
  same-thread sync() reentrancy via an owner/depth guard; async acquire is
  offloaded to a worker thread so the event loop is never blocked.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-28 07:37:24 +00:00
praisonai-triage-agent[bot]andMervin Praison b2d45f3a03 fix: address Greptile review on background /v1/responses (ownership, bounded store, rejection status, stable msg id, empty-output shape)
- Bind each background/stored Response to its submitting caller; retrieval and
  cancel now authorize by owner (stable session/bearer keys) not by id alone,
  so one client cannot read/cancel another's run. Anon (no-auth) callers keep
  id-only lookup since they are stateless by design (Greptile P1 security).
- Bound the in-process response store (cap + oldest-terminal eviction) so a
  sustained submission stream cannot grow memory without bound (Greptile P1).
- Report admission-gate rejections as `failed` (with the busy message), never
  `completed`, so pollers do not mistake a capacity rejection for success
  (Greptile P1).
- A `completed` response always carries an assistant message even when the text
  is empty, preserving the prior output[0] shape for empty answers (Greptile P1).
- Persist a stable output `message_id` so repeated retrievals of a completed
  run return the same message identity (Greptile P2).
- Tests: 6 new cases covering all of the above.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-27 10:10:46 +00:00
praisonai-triage-agent[bot]andMervinPraison 7e497cf0c6 fix: add background/async runs to OpenAI-compat /v1/responses (fixes #5335)
Expose async/background runs on the gateway's OpenAI-compatible HTTP
surface. POST /v1/responses with background/store returns 202 with a
persisted, retrievable id; GET /v1/responses/{id} retrieves current
status/output; POST /v1/responses/{id}/cancel stops an in-flight run via
the existing InterruptController seam. Synchronous behaviour is unchanged
and remains the default, keeping existing clients backward compatible.

Co-authored-by: MervinPraison <[email protected]>
2026-09-27 09:59:23 +00:00
praisonai-triage-agent[bot]andMervin Praison dd0a3d44ed fix: normalize egress host + keep destination authoritative (PR #5334)
- Normalize hostnames (case-insensitive, strip trailing dot) in
  check_network and create_network_policy so API.EXAMPLE.COM /
  api.example.com. cannot bypass deny/allow rules.
- Make the destination host authoritative in the context passed to rule
  conditions: caller context can no longer override "host".
- Add regression tests for case/trailing-dot variants and context spoofing.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-27 09:19:51 +00:00
praisonai-triage-agent[bot]andMervinPraison b392b9afdb fix: add network-egress dimension to core PolicyEngine (fixes #5333)
Add check_network(host, context) mirroring check_tool/check_file, and a
create_network_policy(allow_hosts, deny_hosts) helper with deny-wins /
default-deny-on-nonempty-allow semantics. Gives PolicyEngine the missing
net:<host> resource so egress can be decided deterministically and
fail-closed via the same .check() path every consumer already reaches.

Co-authored-by: MervinPraison <[email protected]>
2026-09-27 09:14:14 +00:00
praisonai-triage-agent[bot]andMervin Praison ef0101b4b4 fix: make LLM agent-name attribution task-local via ContextVar
Concurrent agents sharing one LLM instance clobbered each other's
current_agent_name (a plain attribute), misattributing token usage, and
copy.deepcopy(agent) raised because the built LLM had no __deepcopy__.

- Back current_agent_name with a ContextVar so each async task/agent sees
  its own value (issue #5052 / #3933).
- Add LLM.__deepcopy__ giving the clone a fresh ContextVar (ContextVars are
  not copyable and their value is task-local) (issue #1746).

Fixes the two core test failures surfaced on this PR's CI without changing
any public signatures or adding Agent params.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-27 08:43:30 +00:00
praisonai-triage-agent[bot]andMervin Praison 515946a52e fix: preserve TTL/perms and honor write results in GCS+memory state stores
Address reviewer (Greptile) findings on PR #5332:
- GCS hset/hdel now preserve the existing key TTL instead of dropping it
- GCS scalar wrapper uses a private marker so a caller's own {"__value__": ...}
  dict round-trips instead of being unwrapped
- GCS expire/hdel honor the actual upload result rather than reporting success
  on a failed write; document the whole-object read-modify-write limitation
- MemoryStateStore._save preserves the existing file's permission bits across
  the atomic replace (mkstemp defaults to 0o600)

Co-authored-by: Mervin Praison <[email protected]>
2026-09-27 08:36:28 +00:00
praisonai-triage-agent[bot]andMervinPraison 13ab0389e4 fix: repair GCS state store, atomic memory writes, drift-aware tool allowlist (fixes #5331)
- GCSStateStore: implement the 7 missing StateStore abstract methods
  (keys/ttl/expire/hget/hset/hgetall/hdel) so the class can be
  instantiated, and align set() to the -> None ABC contract (wrap
  non-dict scalars, unwrap on get).
- MemoryStateStore._save: serialise first, then write via temp file +
  fsync + os.replace so a failed/interrupted save never truncates the
  on-disk state and silently loses all keys.
- auto.py tool allowlist: union the static code_execution list with
  code-exec tools discovered in the live ToolResolver registry (matched
  by name shape) so a new dangerous core tool is stripped by default.

Co-authored-by: MervinPraison <[email protected]>
2026-09-27 08:28:46 +00:00
praisonai-triage-agent[bot]andMervin Praison 81d4a0c95a fix: task-local LLM attribution + harden concurrency/circuit/failover
CI regression fix: LLM.current_agent_name was a plain instance attribute
on a shared LLM, so concurrent agents clobbered each other's token
attribution and it wasn't deepcopy-safe. Back it with a task-local
ContextVar (_current_agent_name_var) + property, and add LLM.__deepcopy__
so a clone gets a fresh, independent ContextVar (fixes #5052/#5329 CI).

Reviewer fixes:
- concurrency: keep the limiter alive while permits are held on
  remove_limit()/unlimited so acquire/release stay symmetric and a later
  set_limit() can never start from an empty count and breach the cap.
- circuit_breaker: on health-loop exit, only clear our own task ref and
  restart probing if the circuit re-OPENed, closing the reopen race that
  left an OPEN episode without probes; run sync health checks off-loop.
- failover: honor rotate_on_success for live shared LLMs by selecting the
  per-call profile via get_next_profile() (threaded through kwargs, never
  mutating shared state); default behaviour unchanged.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-27 07:33:11 +00:00
praisonai-triage-agent[bot]andMervinPraison b8ee3a3f7a fix: repair three silently-broken core-SDK reliability guarantees (fixes #5329)
- ConcurrencyRegistry: use one long-lived, in-place resizable _DynamicLimiter
  per agent instead of swapping a fresh Semaphore on set_limit(); retunes now
  take effect immediately for in-flight holders and the cap can never be
  silently exceeded.
- CircuitBreaker: start health-check-driven recovery from _on_failure() (so
  both call() and acall() get it) and clear _health_check_task when the loop
  exits so a later OPEN can restart probing.
- FailoverConfig.rotate_on_success: actually honour the flag by round-robining
  among the current top-priority tier in get_next_profile() and advancing
  _current_index in mark_success().

Co-authored-by: MervinPraison <[email protected]>
2026-09-27 07:21:49 +00:00
praisonai-triage-agent[bot]andMervin Praison ec514861ab fix: harden plugin typed-subsystem merge (policy/skill/guardrail)
Address reviewer feedback on PR #5328:
- POLICY: never let a plugin rule replace an existing same-named policy
  (PolicyEngine.add_policy overwrites by name); first/user policy wins.
- SKILL: skip non-string get_skills() entries (SkillManager loads paths);
  narrow the get_skills() contract docstring to path strings only.
- GUARDRAIL: compose plugin chain with a user guardrail instead of skipping
  it when one exists, so plugin validate_input/output run on the final
  response too (via a lightweight callable adapter).

Adds 3 regression tests. CI failures on this branch
(test_deepcopy_agent_with_built_llm_instance_does_not_raise,
test_concurrent_agents_sharing_one_llm_do_not_misattribute_tokens,
test_agent_execute_tool_from_registry) are pre-existing on the base commit
and unrelated to this PR.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-26 10:12:52 +00:00
praisonai-triage-agent[bot]andMervin Praison f2cc8b212b fix: wire plugin GUARDRAIL/POLICY/SKILL types into typed subsystems (fixes #5327)
PluginType.{GUARDRAIL,POLICY,SKILL} were decorative — nothing dispatched
on them, so a guardrail plugin could only rewrite the model's final text
(after_llm) and never see a tool call or raw tool result. This wires the
plugin path into the existing typed subsystems:

- Plugin base gains opt-in no-op seams as_guardrail()/get_skills()/
  get_policies() mirroring get_tools(); PluginInfo gains plugin_type.
- PluginManager gains PluginType-aware collectors get_all_guardrails/
  skills/policies().
- Agent._merge_plugin_subsystems() folds enabled GUARDRAIL plugins into
  the tool-call/tool-result GuardrailChain, POLICY rules into PolicyEngine,
  and SKILL dirs into skill discovery — closing the tool-call/result blind
  spot. No new params/deps; no-op when no such plugin is enabled.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-26 09:59:11 +00:00
praisonai-triage-agent[bot]andMervinPraison d3a47ee7c3 fix: task-local LLM agent attribution + gateway projection echo dedup
Fixes the two red CI checks and valid reviewer findings on PR #5326.

Root-cause (pre-existing regression exposed by CI, not from the gateway PR):
tests required LLM.current_agent_name to be per-async-task and deepcopy-safe,
but the implementation used a plain attribute with no ContextVar / __deepcopy__.

- llm.py: back current_agent_name with a contextvars.ContextVar so concurrent
  agents sharing one LLM keep independent token attribution; add LLM.__deepcopy__
  that gives the clone a fresh ContextVar (ContextVar is not copyable). Fixes
  test_concurrent_agents_sharing_one_llm_do_not_misattribute_tokens and
  test_deepcopy_agent_with_built_llm_instance_does_not_raise.
- gateway/session_projection.py: on optimistic reconciliation, drop the echo's
  stale message_id so a re-delivered echo cannot overwrite the durable row
  (Greptile P1 #6).
- praisonai-ts gateway reducer: parity for the stale-echo fix; accept snake_case
  gateway wire fields (message_id/run_id/request_id/...) via normalizeEntry so a
  TS consumer of real Python frames stays in sync (Greptile #5); export
  SessionProjection + types from the package root (Greptile #8).

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

Co-authored-by: MervinPraison <[email protected]>
2026-09-26 09:35:33 +00:00
praisonai-triage-agent[bot]andMervinPraison 136ebae8e2 fix: add session-projection reducer for gateway clients (fixes #5324)
Adds a pure, dependency-free SessionProjection reducer in core
(praisonaiagents/gateway/session_projection.py) that folds a snapshot plus
GatewayEvents into a de-duplicated, gap-aware, memory-bounded session view —
so every gateway client stops re-implementing snapshot+event reconciliation,
final-message de-duplication, optimistic echo reconciliation, gap healing and
bounded run retention.

- core: SessionProjection / SessionProjectionState / RunView, lazily exported
- wrapper: thin GatewayClient.project() helper driving the reducer from events()
- praisonai-ts: parity mirror of the reducer
- tests: 13 core unit tests covering dedup, optimistic reconcile, gap flag,
  snapshot convergence, delta accumulation and bounded retention

Co-authored-by: MervinPraison <[email protected]>
2026-09-26 09:23:52 +00:00
praisonai-triage-agent[bot]andMervinPraison 10078d5baf feat: add notification policy for gateway push (fixes #5323)
Adds a pure, dependency-free notification-policy layer to core
praisonaiagents.push: NotificationCategory taxonomy, DetailLevel
redaction, per-recipient NotificationPreference (categories, quiet
hours with timezone, urgent override), and a pure evaluate() decision
function returning a NotificationDecision. Exported from
praisonaiagents.push so the gateway delivery manager and other
notifiers can consult one canonical policy before delivery.

Co-authored-by: MervinPraison <[email protected]>
2026-09-26 09:17:19 +00:00
praisonai-triage-agent[bot]andMervin Praison d8775b22f2 fix: harden knowledge scope/filters, MCP empty-error, mongo protocol returns
- Chroma search: caller filters can no longer overwrite the tenant-scope
  constraint (filters={"user_id": "bob"} on an already-scoped query is dropped
  instead of widening onto another tenant's data).
- SQLite search: quote metadata keys in the json_extract path so a top-level
  key containing dots (e.g. "source.type") matches literally instead of being
  read as a nested path.
- MongoDB get/get_all/update now return SearchResultItem/SearchResult/AddResult
  to match KnowledgeStoreProtocol and the other adapters (was dict/list/bool).
- MCP call_tool: guarantee a non-empty error string when an isError result has
  an empty text block so retry/circuit-breaker/hooks classify it as a failure.
- tests: embedding-failure tests now pass a scope, matching the mem0-established
  add/search scope contract the adapters were brought into parity with.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-26 07:37:16 +00:00
praisonai-triage-agent[bot]andMervin Praison 0a4b7148e1 fix: enforce knowledge tenant-scope/filters, detect MCP isError, async loop CSV fallback (fixes #5319)
- Knowledge adapters: call require_scope in Chroma/SQLite search/add/get_all
  (previously only delete_all), apply the filters kwarg in SQLite and MongoDB
  search, and add the missing get_all/delete_all/update/get plus scope
  enforcement to MongoDBKnowledgeAdapter so every backend enforces the same
  tenant-scope and filter contract mem0 already did.
- MCP: MCPToolRunner.call_tool now checks CallToolResult.isError and returns a
  structured {"error": ..., "mcp_error": True} dict so a server-reported tool
  failure is no longer surfaced to the agent as a successful string result.
- aworkflow(): default a mid-workflow (non-start) loop task with no input_file
  to tasks.csv, mirroring the sync workflow(), so its subtasks are expanded
  instead of being silently skipped.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-26 07:27:45 +00:00
George Pickett a25369a8e6 fix: respect environment proxies in bounded MCP transport 2026-09-25 19:53:34 +00:00
praisonai-triage-agent[bot]andMervin Praison 7bf31faf11 docs: correct autogen_tools probe comment to reflect is_available delegation
The PRAISONAI_TOOLS_AVAILABLE accessor no longer depends on the probe import;
it delegates to _framework_availability.is_available. Update the comment so
future maintainers don't rely on the stale description (Greptile P2).

Co-authored-by: Mervin Praison <[email protected]>
2026-09-25 16:28:00 +00:00
praisonai-triage-agent[bot]andMervinPraison a1b9a3c2af fix: make inbuilt_tools PRAISONAI_TOOLS_AVAILABLE reflect real availability (fixes #5317)
Delegate the inbuilt_tools accessor to the single source of truth
(is_available("praisonai_tools")) instead of the always-true probe-import
check. Drop the unused TOOLS_AVAILABLE assignments in autogen_tools.py while
keeping the presence-probe import for the release smoke test.

Co-authored-by: MervinPraison <[email protected]>
2026-09-25 16:19:30 +00:00
praisonai-triage-agent[bot]andMervin Praison b0f22d7e23 fix: harden trusted-proxy ingress attribution + fix CLI OptionInfo crash (#5312)
- CLI: guard trusted_proxy against the typer OptionInfo sentinel when
  gateway_start is called directly (fixes 'OptionInfo object is not iterable'
  in test_gateway_exit_codes).
- server: honour live config reloads (removed/added proxies) and stop YAML
  omission from wiping a Python-supplied GatewayConfig.trusted_proxies;
  precedence CLI override > live YAML > self.config.
- core: validate the resolved client hop / X-Real-IP is a literal IP before
  keying per-IP policy — malformed hops now fail closed to the socket peer.
- tests: add malformed-hop, malformed-real-ip, and IPv6 cases.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-25 09:37:56 +00:00
praisonai-triage-agent[bot]andMervinPraison 674109b349 fix: resolve real client IP behind trusted proxies for gateway rate-limiting (fixes #5312)
Add a pure, import-free ingress-attribution contract to core
(resolve_ingress_attribution + IngressAttribution + parse_forwarded_for)
that resolves the real client IP only across operator-declared trusted
hops and fails closed for anything proxy-shaped but unattributable. Add a
trusted_proxies field to GatewayConfig (3-way: CLI --trusted-proxy, YAML
gateway.trusted_proxies, Python) and wire it into the wrapper gateway's
rate limiters, pre-auth connection budget, and operator identity so per-IP
defences keep their granularity behind a reverse proxy or tunnel.

Co-authored-by: MervinPraison <[email protected]>
2026-09-25 09:22:14 +00:00
praisonai-triage-agent[bot]andMervin Praison 710db58ac2 fix: make LLM.current_agent_name a task-local ContextVar (#5052)
Concurrent agents sharing one LLM instance overwrote a shared
current_agent_name attribute mid-completion, misattributing tokens.
Back it with a contextvars.ContextVar (task-local) and add LLM.__deepcopy__
so clones get a fresh, isolated var instead of failing to pickle the ContextVar.

Fixes pre-existing CI failures in test_default_token_tracking and
test_agent_clone that already expected this ContextVar contract.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-25 09:21:11 +00:00
praisonai-triage-agent[bot]andMervin Praison 5eb2389b04 fix: harden checkpoint size-skip + self-contained shadow store (PR #5310 review)
- types.py: move max_file_size after identity fields so positional
  CheckpointConfig(..., user_name, user_email) callers are unaffected
- service.py: skip symlinks and use lstat in oversized-file pass so a small
  link to a large target is never dropped from a checkpoint
- service.py: periodic repack -a -d (no --local) localizes borrowed alternate
  objects so checkpoint history stays readable if the project repo prunes

Addresses greptile findings #2, #4, #5; #1/#3 are inherent add-then-filter
trade-offs documented in the code.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-25 09:16:07 +00:00
praisonai-triage-agent[bot]andMervinPraison c8ae5ca563 fix: seed checkpoint shadow-git from project repo + tune for large repos (fixes #5308)
Seed the shadow object store from the project's real .git via a file-based
objects/info/alternates so the first checkpoint only stores changed files
instead of re-hashing the whole tree. Apply large-repo git config
(feature.manyFiles, index.version 4, core.untrackedCache, core.fsmonitor),
skip oversized files (default 2 MB, configurable via max_file_size) during
staging, and run a bounded git gc --auto to keep the store small. All changes
are best-effort and backward-compatible: no real repo falls back to the
existing standalone path unchanged.

Co-authored-by: MervinPraison <[email protected]>
2026-09-25 09:07:09 +00:00
praisonai-triage-agent[bot]andMervin Praison 5877ab83a4 fix: implement async tool-reliability parity + stream context mgmt (#5304)
Extracts the force-tool-usage and tool-repair reliability logic (already
present in the sync get_response) into shared _force_tool_usage_message /
_tool_repair_message helpers, and applies them in get_response_async after
the streaming/non-streaming branches converge so a streaming-with-tools
provider honours the same policy. Adds _param_accepts_string schema reader
and calls _manage_context_in_loop in the get_response_stream tool loop.

These satisfy the test_async_honours_tool_reliability and
test_result_substitution_and_stream_context suites merged (test-first) by
#5204 without their implementation. No new public API or Agent params.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-25 07:57:20 +00:00
praisonai-triage-agent[bot]andMervin Praison 110fe69fb6 fix: async token-usage tuple parity on cancel + Responses API usage keys
- Wrap all three async get_response_async cancellation returns with
  _prepare_return_value so return_token_usage=True keeps the (text, TokenUsage)
  tuple contract on /stop, matching the sync path (Greptile #4).
- _extract_token_usage now falls back to input_tokens/output_tokens (Responses
  API spelling) as well as prompt_tokens/completion_tokens, mirroring
  _track_token_usage, so Responses API usage is no longer reported as zero
  (Greptile #3). Benefits both sync and async paths.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-25 07:46:07 +00:00
praisonai-triage-agent[bot]andMervin Praison 24bf9c9168 fix: wire workspace_root, support async guardrails, add return_token_usage to async LLM (fixes #5304)
- Thread workspace_root=os.getcwd() into the Agent's PermissionManager so the
  external_dir:* out-of-workspace approval gate actually fires for Agent(approval=...).
- Detect and await async def guardrail functions in Task._process_guardrail and
  Agent._process_guardrail via the async_bridge, instead of calling a coroutine
  function synchronously (which crashed from_tuple and burned retries).
- Add return_token_usage to LLM.get_response_async with the same
  _prepare_return_value wrapping used by the sync get_response, tracking the
  final raw response for token-usage extraction.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-25 07:32:11 +00:00
George Pickett 85a7e27cbf test: cover missing Parallel Search package metadata 2026-09-24 23:39:24 +00:00
praisonai-triage-agent[bot]andMervin Praison f3a796caa2 fix: guard Parallel Search User-Agent against missing package metadata
version('praisonaiagents') raised PackageNotFoundError in source checkouts,
vendored, or frozen installs, crashing the entire parallel search path. Fall
back to an 'unknown' version instead.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-24 23:22:04 +00:00
George Pickett 1dee733f60 fix: preserve MCP tool errors in Parallel search 2026-09-24 23:03:03 +00:00
George Pickett 132071e36d feat: add opt-in Parallel Search MCP provider 2026-09-24 22:44:30 +00:00
praisonai-triage-agent[bot]andMervin Praison a6d1396fd8 fix: preserve FULL durability + resolve file: URI in WAL guard (#5264)
- Default synchronous=FULL in the SQLite factory so committed memory/LTM
  and session/transcript writes survive an OS/power crash (was silently
  downgraded to NORMAL under WAL); opt-in synchronous="NORMAL" retained.
- Resolve file: URIs to their backing path in the no-downgrade header
  probe so uri=True connections don't bypass the WAL guard.
- Add integration coverage through connect(): FULL default, NORMAL opt-in,
  injected WAL-rejection -> DELETE fallback, and URI probe resolution.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-24 10:06:06 +00:00
praisonai-triage-agent[bot]andMervinPraison eed210456f fix: harden core SQLite stores with WAL-safe-fallback connector (fixes #5264)
Add a single stdlib-only connection factory
(praisonaiagents/storage/sqlite.py) that attempts WAL and transparently
falls back to journal_mode=DELETE on filesystems that reject WAL
(NFS/SMB/FUSE/overlayfs/virtiofs), while never live-downgrading a DB
whose on-disk header already reports WAL. Wire it into the core session,
transcript, memory and storage-backend stores so the hardening lives in
one place instead of the duplicated raw PRAGMA journal_mode=WAL.

Scope kept minimal per lightweight-core rule: the speculative
read-connection FD-budget pool and OFD advisory-lock re-assertion from
the issue are intentionally omitted (no live consumer).

Co-authored-by: MervinPraison <[email protected]>
2026-09-24 09:56:48 +00:00
praisonai-triage-agent[bot]andMervin Praison 36ab24f966 fix: honor hook priority across parallel/sequential boundary + unify add_hook default
Addresses reviewer (greptile) findings on PR #5263:

- runner.py (P1): the runner ran ALL parallel hooks before ANY sequential
  hooks, discarding the global (priority, _seq) order the registry produces.
  A lower-priority sequential redaction could run AFTER a higher-priority
  parallel tracer, leaking unredacted input. Now walk the already-sorted list
  and execute each contiguous same-mode run in place, so priority order is
  preserved across the parallel/sequential boundary. Adjacent parallel hooks
  are still gathered concurrently; a denial in a sequential run stops the chain.

- registry.py (P2): add_hook() defaulted to priority=10 while
  register_function/on default to 100, so hooks registered through the two
  APIs on a shared event reordered relative to each other with no priority
  requested. Unify add_hook default to 100.

Tests: +2 (runner cross-boundary ordering, add_hook default parity).
Hooks suite: 153 passed; plugins suite: 186 passed.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-24 09:33:57 +00:00
praisonai-triage-agent[bot]andMervinPraison 7ff330d32d fix: honor hook priority for deterministic execution order (fixes #5261)
Store a first-class `priority` field on HookDefinition and sort get_hooks()
by (priority, registration_seq) so guardrail/policy composition can be
sequenced. Lower priority runs earlier; equal priority falls back to a
monotonic registration counter for stable, reproducible ordering. Priority
is threaded through register_function/register_command/on/add_hook and
surfaced in list_hooks(). Fully backward compatible (default bucket 100).

Co-authored-by: MervinPraison <[email protected]>
2026-09-24 09:20:26 +00:00
praisonai-triage-agent[bot]andMervin Praison ee49a1c34f fix: make LLM.current_agent_name task-local + add LLM.__deepcopy__
Two core tests failed on CI (and on the parent commit) because they
guard a ContextVar-based per-agent token-attribution contract that
llm.py never implemented:

- test_concurrent_agents_sharing_one_llm_do_not_misattribute_tokens
  (KeyError: 'SlowAgent') — concurrent agents sharing one LLM overwrote
  a plain current_agent_name attribute, misattributing tokens (#5052).
- test_deepcopy_agent_with_built_llm_instance_does_not_raise
  (AttributeError: '_current_agent_name_var') — deep-copying an agent
  with a built LLM had no ContextVar to reset (#1746).

Back the attribution with a ContextVar (task-local), expose it via a
backward-compatible current_agent_name property, and add LLM.__deepcopy__
so a clone gets a fresh, empty ContextVar (ContextVars are not copyable).
No new Agent params; API surface unchanged.

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

Co-Authored-By: Mervin Praison <[email protected]>
2026-09-24 08:50:18 +00:00
praisonai-triage-agent[bot]andMervin Praison 4c64d73f41 fix: close 4 reviewer-flagged gaps in workflow generators + timeout wrapper
Addresses greptile P1 findings on PR #5260:

1. Job agent-step tools bypassed the exec gate: JobWorkflowAutoGenerator
   ._save_workflow now strips code_execution tools (execute_command, etc.)
   from generated agent steps unless the topic opted in — mirroring the
   run-step gate and the WorkflowAutoGenerator agent-tool gate.
2. Topic could forge the <TOPIC> fence: _fence_topic() now neutralises any
   literal <TOPIC>/</TOPIC> (case/whitespace-insensitive) so a crafted topic
   cannot close the fence and smuggle instructions after it.
3. Concurrent pool recycle (shutdown cancel_futures) lost queued tool calls:
   _sync_wrapped now retries once on CancelledError against a fresh pool
   instead of immediately reporting a timeout.
4. Shutdown race escaped the timeout wrapper: executor_factory() is now
   inside the try, so a close() landing between attempts surfaces as the
   contractual ToolTimeoutError instead of leaking RuntimeError.

Adds regression tests for all four in test_hybrid_workflow.py and
test_tool_timeout_enforcement.py (159 wrapper tests pass).

Co-authored-by: Mervin Praison <[email protected]>
2026-09-24 08:44:25 +00:00
praisonai-triage-agent[bot]andMervin Praison fed972b0f7 fix: harden workflow generators + tool-timeout pool lifecycle (fixes #5259)
Finding #1: WorkflowAutoGenerator and JobWorkflowAutoGenerator interpolated
the untrusted topic raw into prompts, skipping the <TOPIC> fence and tool
allow-list that the sibling AutoGenerator applies. A poisoned topic could
smuggle a `run:` shell step (RCE on `workflow run`) or dangerous agent tools.
Now fence the topic in every generator prompt and add authoritative
server-side gates: drop `run` steps and code_execution agent tools unless the
topic explicitly opted into code execution (same signal _enforce_tool_allowlist
uses). Shared helpers _fence_topic/_topic_requests_code_execution on
BaseAutoGenerator.

Finding #2: AgentsGenerator.close() cleared the executor slot but left
_owns_tool_timeout_executor True and set no _closed flag, so a tool invoked
after close() silently resurrected an unowned 32-thread pool that leaked per
request. Now poison ownership and set _closed; _get_tool_timeout_executor
refuses to build a new pool once closed (mirrors AsyncBridge._closed).

Finding #3: _sync_wrapped captured the executor before submit and lost the race
to a concurrent pool recycle, raising RuntimeError (submit after shutdown) or
CancelledError (cancel_futures) instead of the contractual ToolTimeoutError.
Now retry once against a fresh pool on RuntimeError and surface CancelledError
as ToolTimeoutError, preserving the tool return-type contract.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-24 08:31:49 +00:00
praisonai-triage-agent[bot]andMervin Praison 9eab8ceb93 fix: task-local LLM agent attribution, auto-save index lock, throttle release-by-handle
CI root-cause fixes (#5052 latent tests):
- LLM.current_agent_name is now a task-local ContextVar (_current_agent_name_var)
  exposed via a backward-compatible property, so concurrent async agents sharing
  one LLM no longer misattribute tokens. Added LLM.__deepcopy__ to give clones a
  fresh ContextVar (fixes deepcopy TypeError on the un-picklable ContextVar).

Reviewer findings:
- Auto-save read-modify-write of _auto_save_last_index is now guarded by a
  per-Agent _auto_save_lock, since auto-save runs via asyncio.to_thread and two
  concurrent runs could otherwise duplicate turns (greptile P1).
- throttle() only releases when it actually acquired a permit; unlimited-at-entry
  blocks no longer fall back to a by-name release that a concurrent set_limit()
  could redirect onto a fresh semaphore, inflating the cap (greptile P1).

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-24 07:42:07 +00:00
praisonai-triage-agent[bot]andMervin Praison b4a0c9c038 fix: async-safe auto-save, publish_async history parity, concurrency release-by-handle (fixes #5257)
- Offload _auto_save_session() to a worker thread at the four async call
  sites (astart, _adelegate_to_backend, run_autonomous_async x2) so the
  synchronous FileLock/JSON read-modify-write never stalls the shared
  event loop.
- Remove publish_async's pre-history no-subscriber early return so events
  published with no subscribers are still recorded in get_history(),
  matching the already-fixed sync publish_event path; update get_history
  docstring accordingly.
- ConcurrencyRegistry.acquire()/throttle() now return and release the
  exact semaphore instance, so a concurrent set_limit()/remove_limit()
  can no longer redirect a release onto a fresh semaphore and inflate the
  cap. release() stays backward-compatible (name-only fallback).

Co-authored-by: Mervin Praison <[email protected]>
2026-09-24 07:30:02 +00:00
praisonai-triage-agent[bot]andMervin Praison acc870950f fix: complete error-kind taxonomy, auth-without-failover surface, PyYAML/croniter core-dep escalation
Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 14:02:55 +00:00
praisonai-triage-agent[bot]andClaude b7b4e3100a fix: atomic non-Lua idempotency fallback + degraded-state recovery (#5154)
Address the two remaining Greptile reviews on the Redis inbound idempotency
store:

- P1 (fallback can overwrite claims): the non-EVAL record/release fallbacks
  did a racy get+set / get+del. Replace with an atomic owner-checked
  WATCH/MULTI optimistic transaction (bounded retries) so a concurrent reclaim
  between the ownership read and the write aborts instead of clobbering the new
  owner — same exactly-once safety as the Lua path.
- P2 (degraded state never recovers): a runtime Redis outage set
  _runtime_degraded permanently. Add _mark_runtime_recovered(), called on every
  successful reserve/record/release, to clear the flag and the durability
  registry entry so gateway status/doctor stop reporting best-effort dedup once
  Redis recovers (and a later outage re-warns).

Tests: +4 (atomic no-EVAL record/release fallback, owner-checked, and
degraded-then-recovered). 38 passed.

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

Co-Authored-By: Claude <[email protected]>
2026-09-23 13:51:40 +00:00
praisonai-triage-agent[bot]andMervin Praison 48fde4792b fix: code-mode positional tool calls break when a policy hook allows without rewrite
_run_policy_hook is handed a copy of the bound kwargs, so its return was never
identical to effective_kwargs; the `is not` check therefore forced keyword-only
redispatch on every policy-gated call, corrupting positional calls to decorated
tools (value mapped onto *args/**kwargs param names). Compare by value so an
allow-with-no-rewrite dispatches the original call unchanged; guardrail rewrites
and denials are unaffected. Adds regression tests for positional-allow, rewrite,
deny, and legacy bare-string hook shapes.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:45:00 +00:00
praisonai-triage-agent[bot]andMervin Praison fa5ea65d53 fix: complete error-kind classifier terms + surface auth without failover
classify_error_kind was missing several terms the taxonomy tests assert:
"subscription expired" (billing), "no content"/"blank output"/"null response"
(empty_response), "server busy"/"try again later" (overloaded), and
"parsing error"/"decode error" (format_error). "malformed response" also sat
under empty_response, masking the format_error path — moved so "malformed"
classifies as format_error. resolve_failover_decision now surfaces an auth
error immediately when no failover manager is configured (blind retry cannot
succeed without an alternate credential); the credential-refresh fast path is
unaffected since it keys on reason=="auth" and overrides retryability itself.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:44:29 +00:00
praisonai-triage-agent[bot]andMervin Praison 71befe9608 fix: keep shared webhook registry synced across reload and teardown (#5146)
Two lifecycle gaps in the shared webhook ingress:

- _start_single_channel (selective reload/restart) never re-mounted the
  replacement adapter, so a reloaded webhook channel fell back to its
  standalone port and /webhooks/<channel> 404'd until a full restart.
  Now calls _maybe_mount_shared_webhook on the replacement, matching the
  start_channels path.
- stop_channels() cleared _channel_bots but not _webhook_channels, leaving
  a removed webhook channel reachable at /webhooks/<channel> after teardown.
  Now clears the shared webhook registry on full teardown.

Adds regression tests for both paths.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:43:30 +00:00
praisonai-triage-agent[bot]andMervin Praison 834b61625d fix: retain gateway session when persist reports failed write via falsy return
_evict_persisted_session only guarded the exception path, but
SessionStoreProtocol.add_message returns bool and both built-in stores
return False on a failed write. A False result therefore dropped the
session's only in-memory copy under pressure. Now treat a falsy return
identically to a raised exception (None kept as success for legacy stores
that persist without reporting), and add a regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:43:24 +00:00
ac319e3c09 fix: task-local token attribution, retain id-less OpenAI usage, scope lazy-import escalation to core deps
Addresses Greptile P1s on PR #5245 and the CI failures it introduced.

- llm.py: store agent attribution (name/id) in task-local ContextVars instead
  of shared instance attributes, so concurrent agents sharing one LLM no longer
  clobber each other's identity mid-await (issue #5052). Add LLM.__deepcopy__ so
  clones get fresh ContextVars (ContextVar is not copyable). current_agent_name/
  current_agent_id are now read-only properties over the ContextVars.
- agents.py: _scoped_token_summary retains id-less buckets (native OpenAI-client
  path records agent name with no agent_id) when scoping by identity, so real
  OpenAI usage is no longer zeroed from agent totals.
- _lazy.py: only escalate a lazily-imported target's ImportError to fatal when
  the failing module is a declared *core* runtime dependency; optional nested
  integrations whose own optional SDKs are absent keep the documented None
  fallback.
- tests: regression coverage for id-less OpenAI usage scoping and lazy-import
  ImportError classification (core vs optional).

The 5 test_error_classification failures in CI are pre-existing on the parent
commit and unrelated to this PR (missing keyword taxonomy in classify_error_kind);
left untouched to avoid scope creep.

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

Co-Authored-By: Claude <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:41:18 +00:00
praisonai-triage-agent[bot]andMervin Praison 592be82bcd fix: drop user-module sys.modules entry after successful exec + add isolation tests
Addresses Greptile P2 findings on #5248:
- Successful loads no longer leak a sys.modules entry per load (unbounded
  growth in long-lived TUI/serve processes). The per-load-unique key still
  isolates the module during exec; callers bind the returned object directly.
- Add regression tests covering distinct identities for overlapping loads,
  no fixed tools/tools_module key leak, no accumulation, failed-exec isolation,
  and strict-loader parity.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:39:19 +00:00
praisonai-triage-agent[bot]andMervin Praison 455a32f786 fix: LLM.__deepcopy__ rebuilds threading locks for lock-bearing subclasses
Deep-copying an Agent backed by a lock-bearing LLM subclass (e.g.
ScriptedModel, whose _script_lock is a threading.RLock) raised
TypeError: cannot pickle '_thread.RLock' object, because LLM.__deepcopy__
deep-copied every attribute. Replace threading Lock/RLock attributes with
fresh instances of the same kind on the clone, mirroring Agent.__deepcopy__,
so the clone is independently synchronised. Adds a regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:38:46 +00:00
praisonai-triage-agent[bot]andMervin Praison 4d2668914b fix: end async turn tracking in finally so terminal turns don't leak live ownership
The async default/custom-LLM chat path began per-turn ownership tracking but
only relied on the *next* turn's _clear_turn_tracking() to unregister it from
the process-wide live-turn registry. A terminal achat/arun/astart therefore
left its completed ownership list globally registered, so a later ephemeral()
cleanup treated its committed messages as still live (Greptile P1). Now
_achat_impl ends tracking in a finally (mirroring the sync path), unregistering
the ownership list on both success and failure. Rollback in the except blocks
still reads ownership before the finally runs. Adds a regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:38:41 +00:00
praisonai-triage-agent[bot]andMervin Praison 3e5d6f6edb fix: close bot-loop guard gaps (gateway telegram, CLI, slack self, invalid policy) (#5062)
Address 4 reviewer P1s on the BotLoopGuard wiring:

- Telegram gateway bypass: move the bot_loop_allows check into the shared
  process_inbound_telegram_message pipeline so gateway polling gets the same
  enforcement as the standalone adapter (drop the now-redundant standalone check).
- CLI drops bot settings: add allow_bots/bot_loop_protection to BotCapabilities,
  populate from validated channel config, and fold into _policy_kwargs so
  `praisonai bot start` honours the same fields as the gateway.
- Slack self-messages: capture the authenticated bot_id at start and drop our
  own echoed posts explicitly, since inbound events key sender on bot_id (not
  user_id).
- Invalid policy crash: BotLoopPolicy.from_dict now coerces malformed numeric
  values to safe defaults instead of raising; guard construction is wrapped so a
  bad config can never crash the inbound path.

Adds wrapper tests for malformed/non-dict policy tolerance and CLI kwargs
forwarding. Core (15) and wiring (13) suites pass.

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

Co-Authored-By: Mervin Praison <[email protected]>
2026-09-23 13:33:05 +00:00
praisonai-triage-agent[bot]andMervin Praison b86bf7f969 fix: owner-checked record + runtime fail-open for RedisIdempotencyStore (#5154)
Address Greptile P1 findings on the cluster-wide inbound idempotency store:

- record() is now owner-checked (compare-and-set Lua): it only commits the
  long-lived `recorded` value when the key still holds our in-flight token,
  is already `recorded`, or is gone. If our inflight lease expired and another
  replica reclaimed the key, the commit is a no-op so we never clobber the
  replica that took over — closing the stale-owner race that admitted a
  delivery twice.
- reserve()/record()/release() fail open on a *runtime* Redis outage (client
  reachable at build time but dropped since): reserve admits and records a
  degraded fact instead of 500-ing the hook path; record/release swallow the
  error (the orphaned claim self-heals via the inflight lease). Mirrors the
  turn-lock's fail-open — degradation is reported via the durability registry,
  never silent.

Tests: stale-owner no-clobber, reserve fail-open records degraded, and
record/release swallow runtime outage. All idempotency tests pass.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:28:50 +00:00
praisonai-triage-agent[bot]andMervin Praison d7e96b2ab7 fix: self-heal per-loop httpx cache, anchor availability cooldown, deflake client-id test
Addresses reviewer findings on #5240:
- passthrough: evict + drain clients bound to dead/reused-id event loops so the
  asyncio.run(apassthrough(...)) hot path no longer leaks connection pools; add
  _release_orphan for sync socket release when the owning loop is gone.
- registry: anchor the transient-failure cooldown to a post-probe time.monotonic()
  so a probe that itself blocks >= _NEG_CACHE_TTL cannot write an already-expired
  deadline and defeat the cooldown.
- tests: retain client objects instead of comparing freed id()s; add regression
  tests for reused-loop-id self-heal.

CI 'review' job failure was an OpenAI 401 (invalid key) in the reviewer bot
workflow, not a code/test regression.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:24:32 +00:00
praisonai-triage-agent[bot]andMervin Praison 09608db8af fix: harden gateway pressure eviction (no data loss, no stranded clients, liveness-independent)
Addresses reviewer findings on PR #5237:
- Never evict a session with a live WebSocket bound to it (ordinary message
  frames don't rehydrate, only hello/join) — would strand the client.
- Add _evict_persisted_session: only drop a session from memory after its
  final snapshot is durably persisted; a failed persist retains it in memory
  (no data loss), unlike close_session which best-effort persists then drops.
- Run the pressure sweep on the housekeeping loop even when liveness ping/reap
  is disabled (interval_ms == 0), so the OOM guard is not silently disabled.

Adds 4 tests covering bound-client protection, mixed shed/keep, failed-persist
retention, and resume-TTL stamping.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:22:38 +00:00
praisonai-triage-agent[bot]andMervin Praison 044dba0079 fix: close relay-reply redaction bypass and unify outbound policy (#5055)
Address reviewer-found gaps in the outbound secret-scrub fix:

- RelayAdapter.send_message now scrubs content before send_outbound, so
  relay-backed replies (which bypass fire_message_sending / DraftStreamer /
  DeliveryRouter) no longer leak registered secrets or credential-shaped
  tokens (greptile P1).
- Streaming and proactive-delivery seams now consult the bot/adapter's
  injected _outbound_redactor and _redact_secrets_outbound opt-out, so one
  consistent policy (incl. an injected PII redactor) governs every outbound
  seam (greptile P2). An injected redactor is honoured only when it returns a
  str, otherwise the core primitive runs — a stray/mock attribute can never
  drop the scrub or yield a coroutine.
- Add tests covering the relay seam (registered/shape/opt-out/ordinary) and
  injected-redactor + opt-out consistency across relay/streaming/delivery.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:20:32 +00:00
praisonai-triage-agent[bot]andMervin Praison a8bf3c09a3 fix: make deferred journal completion race-safe with explicit key (#5152)
Fixes the shared mutable _last_journal_key slot so concurrent turns
(different users run under different per-storage-key locks) can no longer
settle one another's inbound. complete_last_journal_entry() now accepts an
explicit journal_key, and deferred keys are tracked in a per-turn set. Adds
a regression test for out-of-order delivery acknowledgement.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:17:34 +00:00
praisonai-triage-agent[bot]andMervin Praison 6b1b97265e fix: align code-mode policy gate with direct path (guardrail rewrites, approval re-check, bypass)
Addresses the three P1 findings on PR #5238:
- Honour guardrail argument rewrites in code mode instead of discarding them
- Re-run the policy hook after an approval backend modifies arguments
- Skip the permission-deny gate under PermissionMode.BYPASS, matching the
  direct tool-call contract

The policy_hook contract now returns (denial_reason, rewritten_args); the
proxy dispatches with the returned args. Bare-string/None returns from
older/third-party hooks stay supported.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:14:31 +00:00
praisonai-triage-agent[bot]andMervin Praison 3680ad5643 fix: thread per-run strictness into YAML normalization (fixes greptile P1)
_normalize_yaml_config()/_list_to_dict() read PRAISONAI_VALIDATE_STRICT
directly, so YAML normalization ignored the per-run strict_validation
override applied to ConfigValidator. That split-brain let strict_validation
=False still raise during normalization (and =True skip those checks) when
the process env disagreed. Now _load_config passes self._is_strict() through
an optional strict param; module-level callers keep the env-var fallback.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:14:28 +00:00
praisonai-triage-agent[bot]andMervin Praison 02f38c6eb4 fix: declare supports_delete on adapters with real delete_message (#5054)
Telegram/Discord/Slack ship a working delete_message transport but left
PlatformCapabilities.supports_delete at its default False, so the delivery
router's capability gate returned `unsupported` before ever calling the
primitive — silently disabling the newly advertised delete/unsend verb on
every real channel (Greptile P1). Set supports_delete=True on those three
adapters; stub adapters (WhatsApp/Signal/email/agentmail) correctly keep it
False. Adds a regression test asserting capability/primitive consistency for
production adapters (the prior fake-adapter tests set the flag directly and
missed this wiring gap).

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:14:09 +00:00
praisonai-triage-agent[bot]andMervin Praison 7044390caa fix: make agent-attribution ContextVar per-instance + add LLM.__deepcopy__
Greptile P1: a class-level ContextVar is shared by every LLM instance, so
distinct LLMs (and an original vs. its clone) leak attribution to the last
writer. Move it to a per-instance ContextVar (self._current_agent_name_var)
and add LLM.__deepcopy__ that hands the clone a fresh ContextVar (ContextVars
are not deep-copyable and carry task-local runtime state). Restores the
clone-isolation contract in test_agent_clone.py.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:11:49 +00:00
praisonai-triage-agent[bot]andMervin Praison e7b2d9fc12 fix: serialize room drain, guard custom-planner rounds, validate roster
Addresses reviewer findings on PR #5229:
- P1 concurrent drains duplicating turns: wrap on_message append+drain in a
  per-room asyncio.Lock so overlapping messages cannot interleave the shared
  transcript and double-execute an agent.
- P1 custom planners mislabeled rounds: only apply RoundRobinRoomPlanner round
  semantics when the planner is a RoundRobinRoomPlanner; otherwise tag turns
  with the activity's current round to avoid mislabeling and unbounded loops.
- P2 scalar roster split into chars: RoomConfig.from_dict now rejects str/bytes
  and mappings with an actionable ValueError instead of splitting into
  character-sized roster entries.

Adds 3 regression tests (23 -> 26 passing rooms tests).

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:04:31 +00:00
praisonai-triage-agent[bot]andMervin Praison 4697a738df fix: preserve caller-supplied Turso auth_token in conversation_options
Only override conversation_options['auth_token'] when a token was actually
resolved from turso_auth_token= or TURSO_AUTH_TOKEN. Previously a None result
unconditionally overwrote a credential the caller placed directly in
conversation_options, breaking authenticated Turso connections (Greptile P1).

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:04:29 +00:00
praisonai-triage-agent[bot]andMervin Praison 1c9e3b767b fix: address PR review — backward-compat RateLimiter API, per-worker quota split, ownership preservation
- Restore RateLimiter.check as the synchronous public API (thread-safe via
  threading.Lock) so existing sync callers unpacking its tuple keep working;
  add check_async for the middleware hot path; keep check_sync as an alias.
- Divide the configured rate_limit across worker processes (with a warning)
  so workers>1 no longer multiplies the effective quota ~N×.
- multiedit atomic write now also preserves uid/gid best-effort (not just
  mode) so replacing a group/shared-owned file doesn't silently change owner.
- Add tests: async concurrent-burst enforcement, check_sync alias,
  _per_worker_rate_limit split, and atomic-write mode preservation.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:03:43 +00:00
praisonai-triage-agent[bot]andMervin Praison 593457b03f test: add regression coverage for scoped workflow bridge and dispatch merge
Closes greptile coverage gaps on PR #5228:
- assert _run_yaml_workflow drives workflow.start inside scoped_bridge()
  (isolation + unbind on success and on raise)
- assert _dispatch_cli_config merges per-run closures without mutating the
  caller-owned cli_config, and returns base when no run ctx

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 13:01:50 +00:00
praisonai-triage-agent[bot]andMervin Praison 8ecf6a23c7 fix: warning message references only existing team.astart() entry point
AgentTeam exposes astart() (async) but no arun(); recommending arun()
would raise AttributeError. Point callers only to await team.astart()
for parallel async_execution fan-out.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:59:37 +00:00
praisonai-triage-agent[bot]andMervin Praison 819a9b121e fix: bridge gateway/schedule/kanban/compaction lifecycle events to Plugin methods (fixes #5159)
Add a generic, declaration-driven tail to _adapt_plugin_hooks so any
HookEvent a plugin declares in PluginInfo.hooks with a genuinely overridden
matching method (event.value) is bridged as an observe-only hook. This closes
gateway_*, schedule_*, kanban_*, before/after_compaction, subagent_stop and
model_fallback in one place — the bundled GatewayForensicsPlugin now actually
fires — and keeps future events first-class by default. Adds matching no-op
base methods on Plugin for discoverability.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:51:12 +00:00
praisonai-triage-agent[bot]andMervin Praison 11227b6f3d fix: namespace user-module sys.modules key to prevent cross-tenant clobber (fixes #5157)
load_user_module / load_user_module_strict registered each user .py under a
fixed name ("tools", "tools_module", ...). Concurrent loads in one process
(multi-tenant `praisonai serve`) could clobber each other's sys.modules slot,
and a failed exec_module could pop another tenant's live module. Register each
load under a per-load-unique key instead.

Gap 1 (_delivery_lock lazy-create race) and Gap 2 (async execute_once dropping
DeliveryOutcome / skipping callbacks) were already fixed on this branch.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:42:11 +00:00
praisonai-triage-agent[bot]andMervin Praison 2951c7f451 fix: default-path rollback, async guardrail offload, per-agent hook isolation (fixes #5149)
- chat_history rollback: begin per-turn ownership tracking on the default
  (OpenAI) chat/achat paths too, so a failed turn removes only its own
  messages by identity instead of the positional del that erased a
  concurrent turn's interleaved messages.
- tool guardrails: offload the sync policy/guardrail and tool-result
  guardrail calls to a worker thread in execute_tool_async so a blocking
  user guardrail can no longer stall the event loop.
- hooks: add HooksConfig(registry=...) so an agent's event hooks stay on a
  private HookRegistry and never fire on unrelated agents that share the
  process-wide default registry.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:35:07 +00:00
praisonai-triage-agent[bot]andMervin Praison 67c7642439 fix: shared HTTP ingress for webhook channels (fixes #5146)
Serve every mode: webhook channel through the gateway's single Starlette
listener at /webhooks/<channel> — one port, one public URL, routed by
path — instead of each adapter binding its own private port (which
silently collided on 8080).

- _config_schema.py: webhook_port default 8080 -> None (shared-listener
  mode). An explicit port keeps the standalone server (opt-out).
- webhook.py: transport-neutral handle_shared_request() reused by both
  the standalone aiohttp server and the shared listener; mark_shared_mounted()
  so a mounted channel's start() is socket-free while a standalone bot still
  binds a default port.
- gateway/server.py: /webhooks/{channel} route dispatching to a registry
  populated in start_channels() for adapters reporting accepts_webhooks with
  no explicit port; per-channel verification via WebhookVerifierProtocol,
  404 isolation for unknown paths, registry cleanup on channel restart.
- agentmail.py / bots_cli.py: preserve historical 8080 default for adapters
  that always bind their own server (backward-compatible).

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:34:17 +00:00
praisonai-triage-agent[bot]andMervin Praison 3b4cfb9504 fix: cross-tenant token collisions, dropped success-callbacks, masked import failures (fixes #5069)
- token_collector: aggregate per stable agent identity (agent_id) instead of
  bare display name so unrelated agents sharing a name (e.g. default "Agent")
  no longer merge into one cost bucket; public by_agent stays keyed by name,
  add by_agent_id for collision-free scoping. Thread agent_id through LLM and
  the agents.py token-report scoping.
- llm._setup_event_tracking: remove litellm.success_callback/_async_success_callback
  entries only when this instance registered them (ownership check), matching
  the existing litellm.callbacks pattern, and guard the shared read-modify-write
  with a module-level lock.
- _lazy: re-raise ImportError when a required transitive dependency of the
  target module is broken instead of masking it as a NoneType error.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:32:03 +00:00
praisonai-triage-agent[bot] 34cf106eec fix: wire BotLoopGuard into inbound path + config surface (fixes #5062)
The core BotLoopGuard/BotLoopPolicy primitive shipped in praisonaiagents but
was never invoked on the gateway inbound path and had no config surface, so the
runaway bot-to-bot reply-loop protection was unreachable.

- Core BotConfig: add allow_bots + bot_loop_protection (default-off no-op).
- Wrapper ChannelConfigSchema: accept allow_bots + bot_loop_protection.
- MessageHookMixin.bot_loop_allows(): shared helper that builds a lazy
  per-adapter BotLoopGuard and returns the dispatch verdict (drops bot-authored
  messages unless allow_bots, then breaks the A<->B loop once the sliding-window
  pair budget is exceeded). Human senders are a zero-cost no-op.
- Gateway _apply_bot_loop_config(): wire allow_bots/bot_loop_protection from
  gateway.yaml channels into BotConfig on both start and hot-reload.
- Telegram/Slack/Discord: consult the guard at their existing bot-author
  decision points (Slack/Discord previously hard-dropped all bots).
2026-09-23 12:30:42 +00:00
praisonai-triage-agent[bot]andMervin Praison 7ad6ea6eb3 fix: implement cluster-wide Redis inbound idempotency store (fixes #5154)
The gateway's inbound dedup only held across replicas when they shared one
SQLite file; without a shared volume the same webhook fanned to two replicas
was admitted on each and processed twice. Implement the already-declared
store_backend='redis' path as a real RedisIdempotencyStore giving cluster-wide
exactly-once admission, reusing the proven RedisTurnLock lease pattern
(SET NX PX reserve, owner-token compare-and-del release) and failing open to
the durable SQLite store with a reported degraded fact when Redis is
unreachable.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:30:25 +00:00
praisonai-triage-agent[bot]andMervin Praison 7b096b3986 fix: multi-tenant chat_history bleed, per-loop httpx pool leak, transient availability pin (fixes #5150)
1. bind_session() helper unifies the session-isolation contract across
   /invoke (agent_invoke.py) and the agentos chat host, and fails loudly
   when chat_history reset raises instead of silently leaking a template's
   history into another session.
2. passthrough async httpx client is now cached per event loop (keyed by
   id(loop)) so it no longer leaks the previous loop's connection pool or
   crashes callers that resume on their own loop.
3. FrameworkAdapterRegistry.is_available caches positives / structural
   ImportError for the process but only caches transient probe failures for
   _NEG_CACHE_TTL seconds, so one flaky probe no longer pins a framework to
   "unavailable" forever.

Adds TestIssue5150WrapperGaps regression coverage.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:30:00 +00:00
praisonai-triage-agent[bot]andMervin Praison 7baf5e3cbe fix: gate code-mode tools, lock BackgroundRunner tasks, unblock Gemini async (fixes #5064)
- Thread a policy_hook through the code-mode tool proxy (in-process,
  bare-name namespace, ToolProxy, and the isolated LocalProcessBridge) so
  code-mode tool calls run the same PolicyEngine / permission-deny /
  per-tool input_guardrails as the direct tool-call loop, closing a bypass
  where @tool(input_guardrails=...) was skipped when reached via code mode.
- Guard every read/write of BackgroundRunner._tasks with a threading.Lock
  so concurrent insert (on the background-loop thread) and iterate/delete
  (on caller/cleanup threads) on the shared singleton can no longer raise
  "dictionary changed size during iteration".
- Run the synchronous google-genai HTTP calls in _research_gemini_async via
  asyncio.to_thread so Deep Research polling no longer blocks the event loop.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:29:31 +00:00
praisonai-triage-agent[bot]andMervin Praison 5389408696 fix: shed live gateway sessions under cgroup memory pressure (fixes #5153)
Wire the existing core planner (plan_pressure_evictions) and the stdlib
CgroupMemoryPressure reader — already used by the bot warm-agent cache —
into WebSocketGateway. On the existing liveness housekeeping tick, when
anonymous RSS approaches the cgroup budget, the gateway now soft-evicts the
coldest (LRU) sessions whose transcripts are already durable, so they
transparently rehydrate on the next join instead of the process being
OOM-killed. Persistence-gated (no store => no-op) and never aborts an
executing turn; a no-op on hosts without a cgroup limit.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:29:30 +00:00
praisonai-triage-agent[bot]andMervin Praison a2c4377ed1 fix: scrub secrets from outbound gateway reply path (fixes #5055)
Add a safe-by-default outbound redaction seam so the gateway never delivers
a registered secret or credential-shaped token to a chat user.

Core (praisonaiagents/secrets.py):
- redact_outbound(text): combines the existing registered-secret registry
  (redact_secrets) with a credential-shape regex (sk-…, AKIA…, gh*_…, bearer,
  PEM private-key blocks) so an unregistered token in a tool result is caught.
- OutboundRedactor protocol so deployments can override (e.g. add PII policy).

Wrapper (praisonai-bot): apply at every outbound seam before dispatch:
- fire_message_sending — the single reply decision point all adapters funnel
  through (opt-out via _redact_secrets_outbound, injectable via
  _outbound_redactor).
- DraftStreamer render + finalize — the streaming/progressive-edit path.
- DeliveryRouter.deliver — the proactive/scheduled send_message path (also
  covers messaging_tools.send_message via BotOutboundMessenger).

Additive and best-effort: absent a leak, ordinary text is unchanged; a
scrubber error never blocks delivery.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:28:52 +00:00
praisonai-triage-agent[bot]andMervin Praison 7648b92c74 fix: defer inbound journal completion until reply delivered (fixes #5152)
Add an opt-in defer_journal_completion flag to BotSessionManager. When
enabled, chat() no longer marks the durable inbound journal entry complete
on the agent's clean exit; it stashes the journal key so the adapter can
call the existing complete_last_journal_entry() seam only after the reply
is durably delivered. This activates the already-shipped deferred-completion
seam and closes the window where a crash between "agent finished" and
"reply sent" would settle the inbound (no replay) yet lose the user's reply.

Default False keeps legacy settle-on-agent-completion behaviour unchanged.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:28:32 +00:00
praisonai-triage-agent[bot]andMervin Praison d6619cbaa1 fix: make strict-validation and tool-timeout-worker toggles per-run DI-scoped (fixes #5065)
Gap 3: PRAISONAI_VALIDATE_STRICT and PRAISONAI_TOOL_TIMEOUT_WORKERS were
readable only from process-global os.environ, so multi-tenant `praisonai
serve` hosts could not scope validation strictness or the timeout pool size
per tenant/request.

Add optional `strict_validation` and `tool_timeout_workers` constructor
params plus matching cli_config keys, resolved via the DI precedence the
constructor already uses everywhere else: explicit arg > cli_config > env
var fallback. Route the instance-side readers through new `_is_strict()` /
`_resolve_tool_timeout_workers()` helpers. Module-level YAML-normalization
helpers keep the env fallback (no instance exists yet). No new public API.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:27:55 +00:00
praisonai-triage-agent[bot]andMervin Praison 1baa85ae86 fix: streaming tool-result serialization and concurrent token misattribution (fixes #5052)
#1 openai_client.chat_completion_with_tools_stream split its single try/except
so tool execution errors are handled separately from JSON serialization,
adding the (TypeError, ValueError) fallback the sync/async loops already have.
A tool that succeeds but returns a non-serializable value (datetime, set,
bytes, DataFrame, custom object) is now stringified into {"result": ...}
instead of being mislabelled "Error executing function".

#2 LLM.current_agent_name is now backed by a contextvars.ContextVar (mirroring
the existing _tools_scope_depths fix) and exposed via a backward-compatible
property + set_current_agent(). Concurrently-gathered tasks sharing one LLM
instance each keep their own agent attribution across the await, so token/cost
no longer leaks to the wrong agent under asyncio.gather.

Adds tests/unit/llm/test_stream_tool_result_serialization.py covering both the
non-serializable-success and real-failure streaming paths.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:27:37 +00:00
praisonai-triage-agent[bot]andMervin Praison ecf613f8bc fix: unify agent-callable message-action surface with per-action authority (fixes #5054)
Add edit/delete verbs and a closed, fail-closed action vocabulary to the
built-in send_message tool so a running agent can manage the conversation it
is in — edit its own status message, retract a mistake, open a thread — safely
and uniformly across channels, alongside the existing send/list/react/thread.

Core (praisonaiagents):
- MESSAGE_ACTION_AUTHORITY: every verb must declare its authority
  (read-only / current-conversation / own-prior-message); an unknown verb is
  rejected fail-closed instead of inheriting send's guard.
- Surface thread/edit/delete in send_message with typed outcomes; forward an
  explicit message_id to react.
- Extend OutboundMessengerProtocol with optional edit/delete verbs and a shared
  MessageActionResult typed outcome (mirrors ReactionResult/ThreadResult).

Bot (praisonai-bot):
- BotOutboundMessenger.edit/delete + DeliveryRouter.edit/delete dispatch to the
  adapter's existing edit_message/delete_message, gated on supports_edit/
  supports_delete, degrading to a typed unsupported outcome.

pin/unpin deliberately omitted — no adapter primitive exists, so adding one
would be new heavy surface rather than exposing an existing capability.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:27:23 +00:00
praisonai-triage-agent[bot]andMervin Praison ab669b5845 fix: recipe rate-limiter race/bypass/workers + atomic multiedit writes (fixes #5070)
Gap 1 (recipe/serve.py):
- guard the sliding-window with an asyncio.Lock so overlapping requests for the
  same client can no longer both pass the length check before appending
- bucket rate limiting only on an auth-validated identity (jwt sub / validated
  api-key / client IP), never on the raw client-controlled X-API-Key header, so
  rotating the header can no longer defeat the limiter in none/jwt modes
- honour workers>1 by handing uvicorn an app-factory import string (config
  travels to worker processes via env) instead of a live app object that
  silently degrades to a single worker

Gap 2 (tools/multiedit.py):
- write edited files atomically (tempfile -> fsync -> os.replace) so an interrupt
  mid-write no longer truncates the target to zero/partial bytes; preserve mode
- take an advisory fcntl lock over the read-modify-write window to close the
  lost-update race between concurrent agent edits (no-op where fcntl absent)

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:27:04 +00:00
praisonai-triage-agent[bot]andMervin Praison 7f1927362f fix: managed-agent resource races, silent scheduler failures, PraisonAIDB kwarg cross-contamination (fixes #5053)
- managed_agents.py / managed_local.py: add per-instance locks with
  double-checked locking around _get_client / _ensure_agent /
  _ensure_environment / _ensure_session so concurrent execute() calls no
  longer duplicate & orphan Anthropic agents/environments/sessions (or
  corrupt _session_history mid-serialisation). Dependent _ensure_* calls
  are resolved before taking the non-reentrant lock to avoid deadlock.
- schedules_runner.py: split the blanket DEBUG catch into an ImportError
  leg (optional dep, DEBUG) and a real-failure leg (log.exception), and
  raise the executor-unavailable branch to WARNING so scheduled jobs that
  will never fire are visible to operators.
- db/adapter.py: give PraisonAIDB per-store option dicts
  (conversation_options / state_options / knowledge_options) so
  backend-specific kwargs no longer broadcast to unrelated stores; keep
  legacy **options with a DeprecationWarning; scope TursoDB auth_token to
  the conversation store only.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:26:57 +00:00
praisonai-triage-agent[bot]andMervin Praison 23061342e5 fix: make orphaned room turn-planner usable via RoomConfig + pure Room runtime (fixes #5067)
The pure RoundRobinRoomPlanner (praisonaiagents.rooms) shipped under #4917 but
had no consumer. Add a lightweight, stdlib-only RoomConfig (roster + planner
caps) and a storage-agnostic Room runtime that enacts the planner: it owns an
in-memory transcript and drains plan_next(), running each scheduled agent via an
injected async callable. No heavy imports or I/O — durable persistence/delivery
and CLI/YAML remain a wrapper concern (the wrapper subclasses/wraps Room and
persists Room.transcript; planner determinism guarantees identical replay).

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:26:41 +00:00
praisonai-triage-agent[bot]andMervin Praison deb93c9ee7 fix: isolate sync workflow bridge and keep per-run closures off cli_config (fixes #5060)
Gap 1: wrap sync _run_yaml_workflow's workflow.start() in scoped_bridge()
so a stuck workflow-tool coroutine cannot park the shared process-wide
bridge for other tenants, mirroring generate_crew_and_kickoff.

Gap 3: stash the per-run tool-timeout wrap/resolver closures on a private
self._run_ctx instead of stamping them onto the user-facing self.cli_config
dict, and merge them into the cli_config kwarg only at dispatch via
_dispatch_cli_config(). Adapters read the same keys unchanged.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:25:41 +00:00
praisonai-triage-agent[bot]andMervin Praison 17b9c21251 fix: warn when sync .start()/.run() cannot parallelize async_execution tasks (fixes #5059)
The synchronous entry point runs tasks sequentially and cannot batch
async_execution=True tasks via asyncio.gather like arun_all_tasks does.
Emit a clear warning pointing callers at .astart()/.arun() so the parallel
fan-out feature advertised by the constructor docstring/ValueError does not
silently degrade to sequential execution.

Findings 1 (workflow async context freeze) and 3 (_tool_executor race) were
already resolved on this branch via the _execution_context_builder deferred
resolution and the _get_tool_executor_lock DualLock guard respectively.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:25:40 +00:00
praisonai-triage-agent[bot]andMervin Praison 5c7d76d72d fix: import-time process hijacking, output-singleton race, ImageAgent fake-async (#5162) (#5219)
* fix: stop import-time process hijacking, lock output singleton, unblock ImageAgent async (fixes #5162)

- _logging: use os.environ.setdefault so host-exported env vars are preserved;
  stop forcing the host's own "asyncio" logger to CRITICAL.
- runtime/resolve: start the cache-cleanup daemon thread lazily on first cache
  write instead of at module import (no thread from a bare import / fork-safe).
- output/trace + output/status: actually acquire the declared _output_lock around
  the shared read-modify-write timing, token/cost and call-count state so
  concurrent agents don't corrupt each other's metrics.
- agent/image_agent: offload agenerate_image/agenerate/aedit/avariation via
  run_in_executor so they don't block the event loop.

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

* fix: close cleanup-thread race and unlocked llm_start_time write (#5162)

- runtime/resolve.py: start the lazy cache-cleanup daemon inside the cache
  lock so the check-and-start is atomic; two concurrent first-writers can no
  longer each spawn a redundant daemon thread (greptile finding).
- output/status.py: guard the _llm_start_time write in llm_start and its read
  in llm_end with _output_lock, matching the locking already applied to the
  other shared timing/counter state.

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

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-09-23 13:07:43 +01:00
praisonai-triage-agent[bot]andMervin Praison 361a707309 fix: add first-class attachment contract to gateway protocol (#5208)
* fix: add first-class attachment contract to gateway protocol (fixes #5207)

Adds an additive, optional, backward-compatible attachment contract so any
custom /ws gateway client (not just platform bots) can exchange files with an
agent. Core-only, dependency-free:

- AttachmentRef dataclass (inline base64 or store ref_id) with validating
  from_dict/to_dict
- AttachmentStoreProtocol seam (reserve/put_chunk/close/open) for chunked
  large-file uploads; concrete store stays a wrapper/bot concern
- Optional attachments: list[AttachmentRef] on MessageParams (defaults empty,
  so a frame without attachments decodes exactly as before)
- AttachmentConfig ceilings (max_attachment_bytes / max_attachments /
  chunk_bytes / allowed_types) on GatewayConfig, advertised via
  HelloResult.policy so a client self-limits and chunks before sending

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

* fix: reject ambiguous dual-carrier attachments and advertise attachment policy

- AttachmentRef.from_dict now rejects a frame carrying BOTH inline 'data'
  and a store 'ref_id', requiring exactly one carrier so the wire contract
  is unambiguous (greptile finding #3).
- Wire AttachmentConfig.to_policy() into the wrapper /ws hello handshake so
  clients receive the attachment ceilings and can self-limit/chunk before
  sending; additive, only adds keys when attachments are enabled (finding #1).
- Add codec test for the both-carriers rejection case.

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-09-23 13:00:14 +01:00
praisonai-triage-agent[bot]andMervin Praison 604ed80199 fix: wire declarative scope registry into gateway dispatch (#5217)
* fix: wire declarative scope registry into gateway dispatch (fixes #5166)

Add a core, default-deny authorize_method() guard driven by
resolve_required_scope() and wire it into the live WebSocket dispatch so
every gateway method — including plugin-registered ones — is gated by the
single declarative source of truth instead of hand-rolled per-endpoint
checks. Unclassified methods are ADMIN-only by omission (fail closed).

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

* fix: READ baseline + fail-closed malformed method in gateway authz (#5166)

Address two reviewer findings on the central authorize_method gate:

- P1 (regression): treat READ as the baseline lifecycle/observation scope so
  an operator holding any actionable scope (WRITE/APPROVALS/PAIRING) can still
  complete READ-classified hello/join/leave/status — matching pre-guard
  behaviour where those frames had no per-endpoint scope check. Prevents
  write-only operators from being locked out of the session lifecycle.
- P2: resolve_required_scope() now treats a non-string/unhashable method as
  unclassified (ADMIN, fail closed) instead of raising TypeError on
  GATEWAY_METHODS.get(), so a malformed frame like {"type": []} yields a
  deterministic insufficient_scope denial rather than tearing down the socket.

Adds core regression tests for both.

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-09-23 13:00:04 +01:00
Dante MengandLewis 83a2b543b2 ci: fix cross-repo workflow action references (#5183)
* ci: fix cross-repo workflow action references

* ci: restrict integration workflow token to read-only

---------

Co-authored-by: Lewis <[email protected]>
2026-09-23 12:59:43 +01:00
MervinPraison b7447195e2 docs: auto-update feature parity trackers [skip ci] 2026-09-23 11:54:10 +00:00
Dante MengandLewis db49ed570f fix: offload blocking ImageAgent calls from async methods (#5187)
* fix: offload blocking ImageAgent calls from async methods

* docs: explain image wrapper thread offloading

---------

Co-authored-by: Lewis <[email protected]>
2026-09-23 12:52:44 +01:00
praisonai-triage-agent[bot]andMervin Praison ecb0b28620 fix: surface quoted-reply context to gateway agents (#5224)
* fix: surface quoted-reply context to gateway agents (fixes #5223)

When a user replies to or quotes an earlier message, resolve the referenced
content and render it into the agent prompt as a compact quoted block, so the
agent honours the referent instead of answering context-blind.

- core: add QuotedRef + BotMessage.quoted field + prompt_text render helper
- telegram: resolve reply_to_message text/caption/quote into msg.quoted on the
  inbound path (peer and bot messages), no new stateful store or config knob

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

* fix: route quoted-reply context through inbound hook + gateway (fixes #5223)

Close two P1 gaps in the quoted-reply surfacing (Greptile review):

- Gateway drops quoted context: gateway Telegram handler now renders
  message.prompt_text (folds the quoted block) instead of message.content,
  matching the standalone adapter so gateway agents honour the referent.
- Quoted text bypasses hooks: fire_message_received now exposes the rendered
  turn (content + quoted block) to the MESSAGE_RECEIVED hook so a
  redaction/deny hook can inspect and veto quoted text; when a hook rewrites
  content the separately-resolved quoted ref is cleared so unredacted quoted
  text cannot be re-appended post-hook.

Adds 4 tests covering hook visibility, deny, redaction-clears-quote, and the
no-quote passthrough.

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-09-23 12:52:27 +01:00
Dante MengandLewis c0ee77a120 fix: enforce tool loop step limits on continuations (#5186)
* fix: enforce tool loop step limits on continuations

* fix: retain async budget summaries in output processing

---------

Co-authored-by: Lewis <[email protected]>
2026-09-23 12:51:30 +01:00
Dante MengandLewis 49e44b0e1e fix: contain native Chroma memory initialization failures (#5188)
* fix: contain native Chroma memory initialization failures

* fix: preserve cancellation during Chroma initialization

---------

Co-authored-by: Lewis <[email protected]>
2026-09-23 12:51:12 +01:00
praisonai-triage-agent[bot]andMervin Praison f9d5db5d53 fix: remove dead run_coroutine_safely import in agents/agents.py (fixes #5181) (#5209)
The module-level import of run_coroutine_safely (with an ImportError
fallback) was never referenced anywhere in agents.py. Its "hot-path usage"
comment was misleading. The real consumers import it lazily where used,
and the utility itself in approval/utils.py is untouched.

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 12:50:51 +01:00
praisonai-triage-agent[bot]andMervin Praison 5c15a22c0a fix: salvage text-format tool calls in-process (#5160) (#5215)
* fix: salvage text-format tool calls in-process (fixes #5160)

Strengthen the existing recover_tool_calls_from_text seam so local/
open-weight models that emit a tool call as text (<tool_call>{...}</tool_call>
or <function=name>{...}</function>) are salvaged and promoted to a native
tool-call block in-process, avoiding an extra re-prompt round trip. Salvage is
gated by an allowlist of advertised tool names and protects fenced code blocks
so an example call in documentation is never executed.

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

* fix: harden text tool-call salvage (ordering, case, fences)

Address valid reviewer findings on the embedded-dialect salvage path:
- Order mixed <tool_call>/<function=> blocks by position in the text so an
  interleaved sequence executes in the order the model wrote it, not with all
  tool_call blocks front-loaded ahead of function blocks.
- Make the marker-presence probe case-insensitive to match the tag regexes, so
  <TOOL_CALL>/<FUNCTION=...> recover like their lowercase forms.
- Extend fenced-code masking to tilde (~~~) fences and unclosed fences (run to
  end-of-text) so a truncated or tilde-fenced example naming a real tool is not
  promoted to an executable call.

The nested-arguments finding was not a defect: the lazy {...} pattern backtracks
to the brace preceding </tool_call>, so nested objects already parse.

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-09-23 12:50:41 +01:00
praisonai-triage-agent[bot]andMervin Praison dddb18ebe4 fix: per-tenant/per-scope concurrency admission fairness (#5216)
* fix: add per-tenant/per-scope concurrency admission fairness (fixes #5168)

Make the gateway concurrency admission decision scope-aware, mirroring the
existing per-tenant `scope` axis of RateLimitPolicyProtocol /
SpendBudgetPolicyProtocol. A `max_concurrent_runs_per_scope` sub-limit caps
concurrency per tenant within the global ceiling so a noisy tenant cannot
starve all run slots. Fail-open and byte-for-byte backward compatible when
unset (0).

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

* fix: wire per-scope concurrency sub-limit into live admission gate (#5168)

Closes the greptile P1: the max_concurrent_runs_per_scope config/policy
primitives were unreachable because the wrapper AdmissionGate never fed the
scope facts to the policy. Now the gate tracks per-scope in-flight/queued
counters (keyed by the session_id already passed at every call site), passes
scope/scope_in_flight/scope_queued into policy.decide, and threads the new
knob through build_admission_gate, BotOS.__init__/from_config, and the gateway
server config reader. Counters are pruned at zero so distinct tenants cannot
grow the map unbounded. Fully backward compatible: 0 (default) keeps the
byte-for-byte global-only path.

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

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-09-23 12:50:26 +01:00
praisonai-triage-agent[bot]andMervin Praison 57e43be410 fix: gate conversation-content plugin hooks behind a least-privilege grant (#5221)
* fix: gate conversation-content plugin hooks behind a least-privilege grant (fixes #5167)

Conversation-content hooks (before_llm/after_llm/before_agent/after_agent/
message_received) carry — and can rewrite — every user's prompt/messages.
They were dispatched to any enabled, self-declaring plugin, so an
auto-discovered pip entry-point plugin could silently read or tamper with all
conversations. Add a default-deny capability gate in the plugin manager (the
sole hook-dispatch chokepoint): a plugin receives these hooks only if it is
first-party (bundled under praisonaiagents) or the operator grants it via the
existing per-plugin config options map (plugins.<name>.allow_conversation).
Tool/channel/utility hooks are unaffected. The unified plugin registry now
surfaces requested-vs-granted conversation access for operator visibility.

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

* fix: harden conversation gate against module spoofing and cover outbound messages

- _is_first_party now verifies real on-disk package provenance instead of a
  self-asserted __module__ string, so a third-party plugin cannot claim
  first-party trust by naming its module praisonaiagents.* (Greptile #1).
- CONVERSATION_HOOKS now includes MESSAGE_SENDING/MESSAGE_SENT/
  MESSAGE_UNDELIVERED so ungranted plugins cannot read or rewrite outbound
  assistant replies (Greptile #2).
- get_plugin_registry surfaces requests_conversation/conversation_granted on
  the entry-point and single-file paths too (Greptile #3).
- Tests: spoof-resistance, genuine first-party, outbound gating; updated the
  conversation-hooks set assertion.

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-09-23 12:50:16 +01:00
MervinPraison 56c52084dc docs: auto-update feature parity trackers [skip ci] 2026-09-23 11:50:00 +00:00
43146d637c fix: preserve async scheduler delivery outcomes and callbacks (#5189)
* fix: account for one-time async scheduler delivery outcomes

* fix: await one-time callbacks and guard delivery lock creation

* test: flush finalizers in module-reload test to prevent flaky KeyError

The v4 silent-alias test swaps praisonaiagents out of sys.modules and
reimports it fresh. A pending weakref finalizer left by an earlier test
(circuit-breaker / live-turn-owner cleanup) could fire during the reload
against the freshly imported module's empty id-keyed registries, raising a
spurious KeyError: <id>. Force gc.collect() before the swap and after the
fresh import so finalizers run against the intact module state. No assertion
changed; test coverage is preserved.

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

* chore: drop accidental knowledge.db test artifact

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

---------

Co-authored-by: Lewis <[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-09-23 12:48:17 +01:00
2b36d01632 feat(desktop): Creative Studio tab for image and video generation (#5200)
* Add Creative Studio tab to PraisonAI Desktop.

Flow-like projects with image and video generation via the local engine,
headless LiteLLM paths for Windows, and HTTP integration tests.

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

* fix(desktop): CSP-safe Studio markup, serialize project writes, broaden error handling

- Move 3 new inline style= attributes to CSS classes (fixes frontend CSP test)
- Serialize project.json read-modify-write + atomic os.replace so concurrent
  generation variants no longer clobber each other's assets (Greptile P1)
- Translate TypeError/KeyError/OSError in /studio/* dispatch to JSON errors
  instead of dropping the connection (Greptile P2)
- Add concurrency regression test for asset appends

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

Co-Authored-By: Mervin Praison <[email protected]>

---------

Co-authored-by: Dhivya-Bharathy <[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-09-23 12:48:04 +01:00
praisonai-triage-agent[bot]andMervin Praison 47fe5408d0 fix: bound recurring schedules with max_runs/until stop conditions (#5195)
* fix: bound recurring schedules with max_runs/until stop conditions (fixes #5194)

Add optional max_runs and until stop conditions to ScheduleJob so a
recurring every/cron job can self-retire after N fires or past an end
instant, instead of running forever until manually removed.

- ScheduleJob gains max_runs/run_count/until (fully backward-compatible,
  persisted only when set) plus a single should_retire() helper.
- Centralise the retire decision through should_retire() in all three
  claim paths (runner.mark_run, store.claim_due, config_store.claim_due),
  replacing the triplicated delete_after_run check; delete_after_run
  becomes sugar for max_runs=1.
- Increment run_count on each claim and short-circuit is_due() once the
  run-count budget or until instant is met, so a spent job is neither due
  nor delivered.
- Surface max_runs/until on the schedule_add agent tool as a thin
  pass-through.

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

* fix: correct run_count double-count and retire spent 'until' schedules

Address three P1 review findings on bounded recurring schedules:

- run_count was incremented by both claim_due and mark_run, so a
  max_runs=N job retired after N/2 fires. claim_due now flags the fire
  via a transient _run_counted so mark_run counts each fire exactly once
  across both the atomic-claim and non-atomic paths.
- An expired 'until' job stopped being due but was never removed, so it
  lingered in listings forever. should_retire(now) now evaluates 'until'
  and the claim path actively retires elapsed jobs without firing them.
- A malformed 'until' previously fell through and ran forever. is_due and
  should_retire now fail safe: an unparseable bound retires the job rather
  than running it unbounded.

Adds regression tests for count-once, max_runs=2, delete_after_run parity,
future/past/invalid 'until', and unbounded-job serialisation.

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-09-23 12:47:50 +01:00
praisonai-triage-agent[bot]andMervin Praison 6cebf13474 fix: close PluginManager lock gap, unify launch server registry, dedupe mem0 search (#5204)
* fix: close PluginManager lock gap, unify launch server registry, dedupe mem0 search (fixes #5203)

- plugins/manager.py: execute_hook now snapshots enabled plugins under the
  lock (mirroring get_all_tools_with_sources); unregister/enable/disable now
  mutate their dicts under the lock too, honoring the documented thread-safe
  contract and preventing "dictionary changed size during iteration" crashes.
- agent/server_registry.py: new shared _AgentServerRegistry + singleton. Both
  Agent.launch() and PraisonAIAgents.launch() now use the same per-port
  registry (one FastAPI app, one uvicorn server, one route table), removing the
  divergent module-level dicts and adopting the registry's readiness signaling.
- knowledge/adapters/mem0_adapter.py: _safe_mem0_search now delegates to the
  shared memory.adapters.factories.safe_mem0_search, collapsing the duplicated
  upstream-bug workaround to a single source of truth.

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

* fix: signal launch readiness only after bind, recover poisoned ports, atomic route teardown

Addresses greptile P1 findings on PR #5204:

- Readiness now signalled only after uvicorn reports server.started (socket
  bound), so callers observing readiness never hit connection-refused. On
  bind/startup failure the port's _started flag is cleared and start_server_if_needed
  returns the real bind result, so a later launch() can retry instead of
  registering routes on a dead server.
- Route cleanup moved into _AgentServerRegistry.teardown_routes_for, which
  performs the endpoint-registry removal AND the FastAPI route filter under the
  registry lock, closing the race where a concurrent launch's freshly-registered
  POST route could be deleted by an in-flight cleanup.

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

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-09-23 12:47:37 +01:00
MervinPraison 79248a081b docs: auto-update documentation parity trackers [skip ci] 2026-09-23 11:47:24 +00:00
cea70e555d fix: synchronize timed tool executor ownership (#5184)
* fix: synchronize timed tool executor ownership

* fix: initialize executor locks for standalone mixin users

* fix: guard tool executor cleanup with the ownership lock

close()/aclose() mutated _tool_executor without the lock that guards
timeout retirement and replacement, so cleanup could shut down a
replacement pool or the async lambda could resolve _tool_executor late.
Capture the executor under the lock, detach it atomically, then shut it
down outside the critical section.

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

---------

Co-authored-by: Lewis <[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-09-23 12:46:34 +01:00
praisonai-triage-agent[bot] a316b70157 Merge pull request #5222 from MervinPraison/claude/issue-5161-20260923-0928
feat: surface inbound platform events (reactions, edits, deletions, membership, threads) to the agent/hooks
2026-09-23 11:46:07 +00:00
Dante MengandLewis 240c35363e fix: preserve async workflow predecessor context (#5182)
* fix: preserve async workflow predecessor context

* test: add opt-in real-model workflow context regression

---------

Co-authored-by: Lewis <[email protected]>
2026-09-23 12:45:53 +01:00
胡文涛 5ab8cbf820 fix(mcp): release server names from the registry on shutdown (#5155)
* fix(mcp): release server names from the registry on shutdown

`MCP._active_server_names` is the process-level registry that lets skills'
CapabilityValidator discover connected MCP servers (issue #3307). It only ever
grew: `with_tool_prefix()` registered names, but `shutdown()` tore down the
stdio/SSE/HTTP-stream/WebSocket clients without removing them.

Because `Agent.remove_mcp_server()` routes through `shutdown()`, a removed
server stayed visible to the validator, so a skill requirement kept passing
against a server that was no longer connected.

Registration is now counted instead of collected: `_active_server_names`
becomes a name -> count mapping, `with_tool_prefix()` increments it and
`shutdown()` decrements it, dropping a name only once the last client holding
it goes away. Reference counting rather than a plain discard keeps two clients
that declare the same server name independent -- closing one must not hide the
one still connected.

The release sits in `shutdown()` rather than in `Agent` because `shutdown()` is
the common sink for every teardown path: `Agent.remove_mcp_server()`,
`Agent._shutdown_runtime_mcp_servers()`, the context manager `__exit__` and
`__del__`.

`list_active_server_names()` still returns a plain set of live names, so
consumers in skills/ are unchanged.

* test(mcp): cover server-registry lifecycle (issue #5135)

Regression tests for the MCP server-name registry: a name must disappear once
its last client shuts down, so a disconnected server stops satisfying a skill
requirement.

Covers the happy path, idempotent shutdown, two clients sharing one name,
clients that never registered, the ValueError path, prefix re-application and
replacement, both name spellings, the unchanged public return type, concurrent
churn, and the end-to-end Agent.add_mcp_server/remove_mcp_server path through
CapabilityValidator.

10 of the 11 cases fail against the unpatched tree.

* docs(skills): note that the MCP registry now releases names

`CapabilityValidator._get_available_servers()` reads the registry live; now
that clients release their names on shutdown (issue #5135), the live read also
observes removals instead of gating on a server that is no longer connected.

Docstring only, no behaviour change.

* fix(mcp): make server-name registration transitions atomic

Addresses review feedback on #5155.

Greptile found that a rename sanitizing to the same prefix was skipped as a
no-op: re-prefixing "my-server" as "my server" sanitizes both to `my_server`,
so the early return left the new original spelling unregistered and a skill
requirement naming that spelling was not satisfied. The no-op check now
compares the full spelling tuple, not just the sanitized prefix.

Qodo found that `_release_registered_names()` read and cleared the instance's
names in two unsynchronised steps, so concurrent lifecycle calls on the same
instance could each claim the same registration and decrement the shared count
twice -- hiding a client that still held the name. Claiming and decrementing
now share one critical section under the registry lock, and
`_adopt_registered_names()` performs the release/adopt/register transition
atomically so a concurrent shutdown cannot release the previous names twice.

`_register_server_names()` / `_unregister_server_names()` are replaced by
`_adjust_server_name_counts()`, which takes no lock of its own and runs inside
its callers' critical sections.

* test(mcp): cover rename-to-same-prefix and same-instance shutdown races

Both cases come from review feedback on #5155:

- a rename that sanitizes to the same prefix must still register the new
  original spelling, because that is what a skill requirement may name
  (`test_rename_sanitizing_to_the_same_prefix_refreshes_the_spelling`);
- concurrent lifecycle calls on one instance must release its registration
  exactly once, leaving a second client's count intact
  (`test_concurrent_shutdown_on_one_instance_releases_only_once`).

The concurrency case shrinks the GIL switch interval and asserts the surviving
client's count after every round. A set-valued check at the end would hide the
drift, because a later round re-registers the name.

Both fail against the previous revision and pass with the fix.
2026-09-23 12:45:16 +01:00
Dante MengandLewis 38ab4e1d36 ci: check reviewer credentials in a step (#5185)
Co-authored-by: Lewis <[email protected]>
2026-09-23 12:44:55 +01:00
MervinPraison f8a7c113ca docs: auto-update documentation parity trackers [skip ci] 2026-09-23 11:34:35 +00:00
praisonai-triage-agent[bot] 38c30423e5 Merge pull request #5220 from MervinPraison/claude/issue-5165-20260923-0927
fix: make shell command parser PowerShell/Windows-aware for permission gating
2026-09-23 11:33:32 +00:00
dymuxanddymux 48f301ef12 docs: remove broken MCP Registry badge from README (#5147)
The badge linked to a non-existent MCP Registry entry
(io.github.MervinPraison/praisonai) which returns 404.
The PraisonAI MCP server is not listed in the registry.

Co-authored-by: dymux <[email protected]>
2026-09-23 12:33:27 +01:00
praisonai-triage-agent[bot]andMervin Praison 32c8250bfb fix: gateway hot-reload candidate validation & rollback (#5145)
* fix: validate reload candidate before cutover with rollback (fixes #5144)

A structural (full-restart) hot-reload drained the live channels before the
new config was proven to work; a schema-valid but runtime-invalid edit (bad
model/tool/guardrails wiring, adapter that throws on load) took a healthy
always-on gateway offline with no rollback.

Core (praisonaiagents.gateway.config): add the pure CandidateReport dataclass
and ReloadValidationProtocol beside classify_reload/ReloadStatus, so the
"never cut over to an unvalidated candidate" invariant is a canonical contract.

Wrapper (praisonai-bot server.py): the full_restart branch now builds the
candidate agents via _validate_candidate BEFORE stop_channels — on failure the
previous config keeps serving and the reload is recorded as failed in health().
If the new channels fail to start after cutover, _restore_previous_config brings
the last-known-good config back up. Snapshot/restore keeps live agents intact on
a rejected candidate.

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

* fix: stage candidate off-live, conform to protocol, roll back on all-channel failure (#5144)

Addresses the three P1 review findings on the gateway hot-reload cutover:
- greptile #1: build candidate agents into an isolated registry and only
  activate them after stop_channels() quiesces ingress, so in-flight turns
  during the drain window never observe the not-yet-committed candidate.
- greptile #2: add public async validate_candidate() so WebSocketGateway
  actually satisfies ReloadValidationProtocol (isinstance now holds).
- greptile #3: after start_channels(), if channels were configured but none
  came up live (all degraded/skipped), restore the previous config instead of
  committing an ok reload.

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

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-09-23 12:33:12 +01:00
praisonai-triage-agent[bot]andMervin Praison fe5332eb24 fix: auto-title CLI sessions from first exchange (#5142)
* fix: auto-title CLI sessions from first exchange (fixes #5141)

Wire the already-shipped generate_title_async helper into the
DefaultSessionStore CLI path. After the first user<->assistant exchange
in a run/code session (when no explicit --title and no title yet), a
short title is generated and persisted to metadata["title"] so
`session list`/resume pickers show meaningful names instead of opaque
ids. Best-effort: failure degrades silently to the existing fallback.

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

* fix: call supported async LLM API in title generation + harden auto-title

- title.py: replace nonexistent LLM.aget_response with get_response_async
  (verbose=False, stream=False). This was the blocking bug: every eligible
  first exchange silently failed with AttributeError and fell back to the
  user-message snippet instead of a generated title (Issue #5141).
- project_sessions.py: re-check the title under the freshest read before
  rename_session so a concurrent manual rename is never clobbered.
- Add core regression test exercising the real generate_title_async against
  a stub LLM exposing only get_response_async (fails on wrong internal API).
- Add command-level tests asserting run/code post-run boundaries invoke the
  auto-title hook so wiring removal is caught.

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

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-09-23 12:33:01 +01:00
praisonai-triage-agent[bot]andMervin Praison c078bf21ca fix: MCP registry refcount, async goal gate, and FileLock mtime staleness (#5136)
* fix: MCP registry refcount, async goal gate, and FileLock mtime staleness (fixes #5135)

- MCP._active_server_names is now a per-name refcount released in shutdown(),
  so a skill's STRICT server-gate stops passing once no live instance holds
  the name (was a one-way set that never shrank).
- Add GoalLoopMixin._goal_gate_async and call it from run_autonomous_async so
  the blocking judge LLM call / hooks are offloaded to a worker thread instead
  of stalling the shared event loop (mirrors _verification_gate_async).
- FileLock staleness is now judged by the lock file's own mtime, not the
  waiter's queue time, so a legitimately-held lock is no longer busted out from
  under its live holder.

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

* fix: close 3 concurrency gaps in MCP registry, async goal gate, FileLock

- MCP: dedupe raw/sanitized prefix pair and skip already-owned names so an
  already-safe prefix (e.g. "fs"/"docs") no longer leaves a residual refcount
  after shutdown() — STRICT capability gates stop passing for dead servers.
- Goal loop: _goal_gate_async now joins the worker thread on cancellation so
  the in-flight gate finishes its shared-state mutations before run_goal_async
  teardown clears _goal_state (no orphaned thread racing a newer run).
- FileLock: a live holder now refreshes the lock file's mtime via a heartbeat,
  so a legitimately long critical section is not busted out from under it;
  only a crashed holder (heartbeat gone) ages out and is reclaimed.
- Add regression tests for all three paths (95 passing).

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-09-23 12:32:50 +01:00
1c5dd7f344 fix(llm): preserve Ollama tool result chaining across async and stream (#5021)
* fix(llm): preserve Ollama tool result chaining

* fix(llm): preserve exact Ollama chained results

* fix(llm): skip failed Ollama results in chains

* fix(llm): wire Ollama adapter hooks cleanly

* fix(llm): revalidate substituted Ollama arguments

* refactor(llm): append resolved Ollama plan entry once

Build the async call-plan entry with its final resolved and validated
arguments in a single append instead of appending and then overwriting
the same entry (CodeRabbit nitpick, llm.py 5503-5512 on the review
head). Conditions are unchanged: the sequential Ollama path still
resolves and re-validates immediately before dispatch.

* fix(llm): scope Ollama chaining to each turn

* test(llm): accept cancel_token in streaming executor mock

Align the stream dispatch regression mock with main's execute_batch
signature after merging cancel_token forwarding.

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

* fix(llm): stop cancelled Ollama tool follow-ups

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-09-23 12:32:25 +01:00
0fcc6e23e3 fix(cli): derive output budget from model limits (#5020)
* fix(cli): derive output budget from model limits

* fix(cli): honor model output limits and YAML LLM options

* fix(cli): address model budget review findings

* fix(cli): preserve custom and YAML output budgets

* fix(llm): coerce invalid max_output_tokens metadata to None

Keep the merged accessor aligned with PR #5020 regression coverage when
LiteLLM returns non-numeric output-limit metadata.

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

* fix: preserve YAML output budgets and reject fractions

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-09-23 12:31:45 +01:00
MervinPraison 3675df8403 docs: auto-update documentation parity trackers [skip ci] 2026-09-23 11:20:19 +00:00
praisonai-triage-agent[bot] cd27aaf493 Merge pull request #5211 from MervinPraison/claude/issue-5173-20260923-0927
feat(video): OpenRouter video support via VideoAgent
2026-09-23 11:19:40 +00:00
praisonai-triage-agent[bot] d7952aa5c3 Merge pull request #5213 from MervinPraison/claude/issue-5179-20260923-0926
fix: add video model picker to desktop app
2026-09-23 11:19:39 +00:00
praisonai-triage-agent[bot] 841e8a47a1 Merge pull request #5218 from MervinPraison/claude/issue-5170-20260923-0927
fix: add opt-in gateway idle-session out-of-band compaction
2026-09-23 10:59:02 +00:00
praisonai-triage-agent[bot] 0a6b3b7a0a Merge pull request #5212 from MervinPraison/claude/issue-5163-20260923-0927
fix: symmetric scoped_bridge() isolation on async generate_crew_and_kickoff
2026-09-23 10:50:27 +00:00
praisonai-triage-agent[bot] 570d39c0ae Merge pull request #5214 from MervinPraison/claude/issue-5164-20260923-0927
fix: interactive session picker for session resume/run when no id supplied
2026-09-23 10:50:24 +00:00
praisonai-triage-agent[bot] 7e107fff60 Merge pull request #5210 from MervinPraison/claude/issue-5172-20260923-0927
fix: normalize video default model to openai/sora-2 across surfaces
2026-09-23 10:33:49 +00:00
praisonai-triage-agent[bot]andMervin Praison 7dfb7bf7f8 fix: propagate inbound event opt-in from gateway config and filter bot-owned reaction removals (#5161)
- gateway: carry channels[].events into BotConfig.metadata['events'] on both
  start_channels and hot-reload, mirroring the stt/voice/learn passthrough, so
  the advertised opt-in actually reaches DiscordBot._event_classes()
- discord: ignore the bot's own reaction removals in on_raw_reaction_remove so
  ack/done cleanup is never surfaced as an inbound REACTION_RECEIVED event
  (symmetric with the add handler)
- tests: add regression coverage for both fixes

Addresses greptile P1 findings.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 10:19:41 +00:00
praisonai-triage-agent[bot]andMervin Praison 594cdef3fb fix: close PowerShell/cmd wrapper permission-gating bypasses (PR #5220)
Addresses three P1 security findings from reviewer feedback:

- Wrapper options bypass: switches before the command flag (e.g.
  `powershell -NoProfile -Command "..."`, `cmd /d /c "..."`) are now
  skipped so the inner command and its Windows paths are gated instead
  of collapsing to an opaque wrapper op.
- Encoded commands: `-EncodedCommand`/`-e`/`-ec` base64 UTF-16LE payloads
  are decoded and inspected; undecodable payloads fail closed to an
  opaque wrapper op (real executable preserved) rather than exposing the
  base64 token as a bogus executable.
- Scriptblocks: leading `& { ... }` / `. { ... }` call-operator +
  scriptblock scaffolding is stripped so the real cmdlet surfaces as the
  executable and command-specific deny rules match.

POSIX parsing remains byte-for-byte unchanged. Added 11 tests
(option-prefixed unwrap, encoded decode + fail-closed, scriptblock,
external-dir boundary asks). 199 permissions tests pass.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 10:11:13 +00:00
praisonai-triage-agent[bot]andMervin Praison 4fb2d46738 fix: harden gateway idle-compaction sweep against review findings (#5170)
- Reject non-positive idle_compaction config values so a zero/negative
  sweep interval never busy-spins or crashes the loop.
- Offload synchronous store I/O (list_sessions, get_working_history,
  append_compaction_checkpoint) via asyncio.to_thread so a sweep never
  pauses the gateway event loop.
- Guard the stale-checkpoint race: capture the transcript length at
  selection and re-check before committing so a turn appended during
  summarisation is not dropped from resume context.
- Add configurable scan_limit (default 5000) so older idle sessions on a
  busy gateway are scanned instead of only the newest page.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 10:05:30 +00:00
praisonai-triage-agent[bot]andMervin Praison 7372bbf671 fix: enable SDK video preview via data_url (Sora/Veo)
Successful Sora/Veo generations returned url=None with no preview,
so the Video tab only showed a "saved" toast with nothing to watch.
Mirror generate_image()'s base64 data_url so the saved MP4 previews
in place -- no new route or job store. UI falls back to data_url.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 10:04:16 +00:00
praisonai-triage-agent[bot]andMervin Praison a5e4307fa2 fix: abort run --pick on cancel and distinguish listing failures in picker
Address reviewer feedback on the interactive session picker (PR #5214):

- run --pick now exits(1) when the picker returns None (cancel / no sessions)
  instead of silently proceeding as a fresh run, honouring the selection gate
  the user explicitly requested (greptile P1).
- _pick_session no longer swallows every exception as "No sessions found":
  expected storage/parse errors (OSError/ValueError/KeyError) surface a clear
  error distinct from an empty store, and unexpected exceptions propagate for
  diagnostics (greptile P2).
- Add tests/unit/test_session_pick.py covering select/cancel/empty/failure
  paths, the non-TTY id-required gate, and TTY picker resolution.

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

Co-Authored-By: Mervin Praison <[email protected]>
2026-09-23 09:54:17 +00:00
praisonai-triage-agent[bot]andMervin Praison 23dce66d84 test: add video default-model parity regression test (#5172)
Guards against cross-surface default drift flagged in PR #5210 review:
asserts the wrapper video_generate/avideo_generate defaults stay aligned
with the canonical VideoAgent.DEFAULT_MODEL (openai/sora-2). Test-only
import of core VideoAgent — no production coupling, no new export.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 09:52:35 +00:00
praisonai-triage-agent[bot]andMervin Praison ef0aaa125a test(video): add OpenRouter contract tests for LiteLLM video route
Addresses greptile "provider route untested" feedback by verifying
generate() forwards the openrouter/ model, attribution headers, and
provider body fields into LiteLLM's video_generation, plus a route-level
check that LiteLLM maps openrouter/ to the openrouter provider.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 09:52:32 +00:00
praisonai-triage-agent[bot]andMervin Praison e55cc608b0 fix: validate video model + verify SDK job success in desktop engine
- Reject unknown/typo video model ids instead of silently routing every
  replicate/* id to the hardcoded MiniMax endpoint (Greptile P2).
- Verify VideoAgent.start() returned bytes and wrote a non-empty file
  before reporting success, so a failed provider job no longer shows a
  "Video saved" toast pointing at a nonexistent path (Greptile P1).
- Add tests for unknown-model and arbitrary-replicate-id rejection.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 09:51:52 +00:00
praisonai-triage-agent[bot]andMervin Praison 39768eee59 fix: async-safe scoped bridge teardown in agenerate_crew_and_kickoff (#5163)
Address Greptile P1: the sync scoped_bridge() __exit__ calls
bridge.shutdown() (up to ~10s cancel+join) on the event loop thread when
used from async def, re-parking the loop for all tenants on scope exit.

Add async_scoped_bridge() that offloads the blocking teardown via
asyncio.to_thread + asyncio.shield, and use it in both branches of
agenerate_crew_and_kickoff. Reuses the existing AsyncBridge/shutdown seam;
no new public agent surface.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 09:46:52 +00:00
praisonai-triage-agent[bot]andMervin Praison 2725de0d04 feat: surface inbound platform events (reactions, edits, deletions, membership, threads) to the agent/hooks (fixes #5161)
Add an opt-in, normalised inbound platform-event surface so the gateway can
hear more than text.

Core (praisonaiagents):
- 6 new HookEvent members: REACTION_RECEIVED, MESSAGE_EDITED, MESSAGE_DELETED,
  MEMBER_JOINED, MEMBER_LEFT, THREAD_CREATED
- Normalised PlatformEvent dataclass + PlatformEventKind in bots/protocols.py
- PlatformEventInput hook payload in hooks/events.py
- Exported from bots/__init__.py and hooks/__init__.py

Wrapper (praisonai-bot):
- MessageHookMixin.fire_platform_event(): the single DRY emission seam every
  adapter calls to route a PlatformEvent.kind to its HookEvent
- Discord adapter registers native reaction/edit/delete/member/thread handlers,
  translating each to a core PlatformEvent; opt-in via config.metadata['events']
  and capability-gated (privileged intents only enabled when requested)

Additive and backward-compatible: no existing path changes; platforms that
cannot deliver an event simply never emit it.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 09:37:41 +00:00
praisonai-triage-agent[bot]andMervin Praison 4888edb340 fix: make shell command parser PowerShell/Windows-aware for permission gating (fixes #5165)
Adds an optional dialect notion (posix|powershell|cmd) to the permission
command parser so Windows commands are gated as precisely as POSIX ones:

- parse_command(cmd, *, dialect="posix"): POSIX path byte-for-byte unchanged.
- Unwrap powershell/pwsh -Command|-c "..." and cmd /c|/k "..." wrappers from
  the raw segment (before shlex) and parse the inner command in the matching
  dialect, so the inner cmdlet + its paths are gated, not the opaque wrapper.
- Windows dialects tokenize with posix=False so backslash paths survive, and
  recognise drive-letter/UNC/backslash path shapes in path_args.
- is_mutating_executable() exposes a PowerShell cmdlet/alias + cmd built-in
  file-mutation vocabulary for the classifier.
- arity.py generalises powershell/pwsh/cmd wrappers and Verb-Noun cmdlets to
  sensible "always" prefixes.
- Conservative single-op fallback retained for unparseable input.

Adds Windows-shaped unit tests alongside the existing POSIX parser tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 09:34:37 +00:00
praisonai-triage-agent[bot]andMervin Praison 5025528319 fix: add opt-in gateway idle-session out-of-band compaction (fixes #5170)
Add a wrapper-only background sweep (lifecycle.idle_compaction) that
compacts idle gateway sessions off the turn critical path, so a returning
user resumes an already-compacted session with no first-message latency
spike and idle transcripts stay bounded. Reuses the existing core
primitives (ContextCompactor, get_working_history, append_compaction_
checkpoint); off by default with a per-session cooldown to avoid retrying
sessions it cannot shrink.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 09:33:14 +00:00
praisonai-triage-agent[bot]andMervin Praison cb450f5ef7 fix: interactive session picker for resume/run when no id supplied (fixes #5164)
Make `session resume`'s id optional: on an interactive TTY (and not --json)
it opens a picker of recent project-scoped sessions and resumes the chosen
one via the existing rehydrate path. Add `--pick`/`--interactive-session` to
`run` that resolves an id through the same picker before the turn. Non-TTY/
--json/non-interactive invocations keep today's id-required behaviour so
scripts and CI are unaffected. Selection front-end only — reuses
list_project_sessions + rehydrate_session, no new deps.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 09:32:10 +00:00
praisonai-triage-agent[bot]andMervin Praison 556c6924be fix: add video model picker to desktop app (fixes #5179)
Desktop video generation was hardcoded to a single Replicate model
(minimax/video-01). Add a model picker so MiniMax is a preset, not the
sole path, and route SDK-capable providers (Sora, Veo) through the
existing praisonaiagents.VideoAgent so the engine never reimplements
provider HTTP.

- media.py: VIDEO_MODELS catalog + list_video_models() env-filtered;
  generate_video(model=...) routes non-Replicate via VideoAgent
- server.py: GET /media/video/models; pass model through /media/video
- ui/index.html: model dropdown in Video tab, disabled when key missing
- test_media.py: multi-provider catalog + env-config coverage
- INSTALL.md: Video generation section

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 09:30:33 +00:00
praisonai-triage-agent[bot]andMervin Praison ca4c3abc17 fix: wrap async generate_crew_and_kickoff in scoped_bridge for tenant isolation (fixes #5163)
The async entry point agenerate_crew_and_kickoff omitted the scoped_bridge()
wrap that the sync generate_crew_and_kickoff uses. Adapter internals, tools,
the delivery router and blueprint dispatch all call back into the sync bridge
(run_sync), so under the async surface those resolved to the process-default
_BG bridge, letting a stuck coroutine in one tenant park the shared loop for
all others. Wrapping both the workflow-YAML and adapter paths in scoped_bridge()
restores per-tenant loop+thread isolation symmetrically with the sync path.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 09:30:06 +00:00
praisonai-triage-agent[bot]andMervin Praison 7998afb228 feat(video): support OpenRouter video generation via VideoAgent (fixes #5173)
Route OpenRouter video SKUs (Veo, Kling, Hailuo, Seedance, ...) through the
existing LiteLLM-backed VideoAgent instead of a bespoke provider subsystem.
Adds OpenRouter HTTP-Referer/X-Title attribution headers for openrouter/
models (overridable via OPENROUTER_REFERER/OPENROUTER_APP_TITLE), documents
OpenRouter as a supported provider, and adds an example.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 09:29:50 +00:00
praisonai-triage-agent[bot]andMervin Praison 51109cce2a fix: normalize video default model to openai/sora-2 across surfaces (fixes #5172)
Align the wrapper capability, CLI, and MCP video entry points with the
canonical VideoAgent.DEFAULT_MODEL ("openai/sora-2"). The previous defaults
(runway/gen3a_turbo, runway-gen3, sora) were inconsistent and, in the CLI/MCP
cases, not valid provider-prefixed LiteLLM routes.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-23 09:28:42 +00:00
05f53d7014 feat(desktop): Channels, Gateway, Knowledge RAG, onboarding, media UI (#5137)
* feat(desktop): add Channels and Gateway panels for Telegram/Slack bots

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

* docs(desktop): Channels and Gateway usage notes

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

* fix(desktop): prevent bots supervisor deadlock on channel delete

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

* fix(desktop): load bot tokens for gateway and harden Channels UI error handling

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

* fix(desktop): CORS Allow-Methods so Channels POST/DELETE work in webview

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

* feat(desktop): channels, gateway, knowledge RAG, onboarding, media tabs

- Channels (Telegram/Slack/Discord) + gateway with mutual exclusion

- Knowledge RAG: index, test retrieval, chat Knowledge toggle

- 3-step onboarding wizard; engine auto-start + restart engine command

- Images/Video tabs (OpenAI/Replicate); channel logs; CORS/origin fixes

- Docs, E2E scripts, unit tests

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

* fix(desktop): stop boot spinning on failed engine, fix onboarding modal & registry drift

Frontend CI (7/190 failing) regressions from the onboarding/boot rewrite:
- boot() polled the engine 3x/2.5s on a terminal 'failed' state, burying the
  failure banner behind a stale "starting engine…" — stop the loop on 'failed'.
- boot() re-read the prefetched pre-provision status on re-boot, so a
  successful first-run setup never cleared — consume engineBootPromise once.
- the onboarding wizard rendered into #turns (the chat transcript), breaking
  New-chat/platform/a11y tests — render it in its own modal backdrop instead.
- re-synced the two settings-registry.js copies for the added "Restart engine".

npm run test:ci -> 190 passed / 0 failed / 0 skipped.

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

---------

Co-authored-by: Dhivya-Bharathy <[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-09-23 10:24:04 +01:00
praisonai-triage-agent[bot]andMervin Praison d8e19c47e7 fix: fail closed on tool-loader opt-in bypass and security config parse errors (#5206)
* fix: fail closed on tool-loader opt-in bypass and security config parse errors (fixes #5205)

Restores the "safe by default" pillar for the three paths that silently
widened permission on the failure path:

- tools.py `action_add` package-name branch now enforces the same
  PRAISONAI_ALLOW_LOCAL_TOOLS opt-in the local-file and github: branches
  do, so `tools add <pkg>` can no longer import (and run) arbitrary
  package __init__ code without the gate.
- tool_override.load_from_module now honours the same opt-in as the
  file-based load_from_file, closing the ungated dotted-module path.
- TemplateSecurity._load_config now fails CLOSED: an unreadable/malformed
  ~/.praison/security.yaml raises RuntimeError instead of silently
  reverting to permissive defaults. Empty files no longer crash.

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

* fix: reject falsey non-mapping security.yaml and surface denied tool sources

Address reviewer findings on PR #5206:
- security.py: falsey non-mapping YAML (false, [], scalars) no longer coerces
  to {} and re-enables permissive defaults; fails closed with RuntimeError.
  Empty file (None) still yields documented defaults.
- tool_override.py: PermissionError from a denied dotted-module tools_source
  is now logged at WARNING (with remediation) instead of vanishing under the
  broad except, so an agent isn't silently missing a declared tool.

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

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-09-23 10:23:21 +01:00
praisonai-triage-agent[bot]andMervinPraison cdb9454556 fix: close three wrapper-layer architecture gaps (fixes #5191) (#5198)
1. _normalize_yaml_config / _prepare_for_run: treat an empty `roles: {}`
   identically to a missing one so populated `agents:` + list-format
   `tasks:` are still promoted instead of silently running zero agents.

2. PraisonAIDB._ainit_stores / aclose: stop caching an asyncio.Lock on the
   instance (it binds to one loop and raises when awaited from another, e.g.
   per-session scoped_bridge loops sharing one adapter). Init/close are
   already serialised by the sync _init_lock inside off-loop asyncio.to_thread.

3. FrameworkAdapterRegistry._validate_adapter: a `**kwargs`-only adapter that
   names none of the required kwargs still validates but now emits a warning
   that tool_timeout/callbacks/cli_config safety guarantees may be dropped,
   mirroring warn_unsupported_fields for YAML fields.

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-09-23 10:23:06 +01:00
MervinPraison 2d1c0e2821 docs: auto-update documentation parity trackers [skip ci] 2026-09-22 23:25:31 +00:00
praisonai-triage-agent[bot] ee237f3252 Merge pull request #5202 from MervinPraison/claude/issue-5201-20260922-1712
fix: remove superseded dead code agents/delegator.py
2026-09-22 23:24:50 +00:00
praisonai-triage-agent[bot]andMervin Praison c6f351d3b1 fix: remove orphaned delegator test causing collection failure
test_subagent_delegator.py imported the deleted agents/delegator.py
module (SubagentDelegator, DelegationConfig, etc.), causing
ModuleNotFoundError at collection time in test-core / test-core-collect.
The file only tested the removed subsystem; profile assertions are
already covered by test_profiles.py. Delegation stays available via
handoff / parallel_handoffs.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-22 17:19:16 +00:00
praisonai-triage-agent[bot]andMervinPraison c49b05fb04 fix: remove superseded dead code agents/delegator.py (fixes #5201)
SubagentDelegator was an unwired, superseded delegation subsystem whose
functionality is now owned by agent/handoff.py. It had zero production,
public-API, example, or dynamic references — reachable only by its own
test. Delegation capability is preserved via handoff/parallel_handoffs.

- Delete praisonaiagents/agents/delegator.py (532 lines)
- Delete tests/unit/agents/test_delegator_protocol.py (tests only the removed module)
- agents/profiles.py left untouched (independent consumers)

Co-authored-by: MervinPraison <[email protected]>
2026-09-22 17:14:02 +00:00
MervinPraison 364bfed32d docs: auto-update documentation parity trackers [skip ci] 2026-09-22 10:53:29 +00:00
praisonai-triage-agent[bot] 8f4b5af18f Merge pull request #5199 from MervinPraison/claude/issue-5193-20260922-0947
fix: add request idempotency to gateway operator control-frame path
2026-09-22 10:52:45 +00:00
MervinPraison f96c2b80f0 docs: auto-update feature parity trackers [skip ci] 2026-09-22 10:43:45 +00:00
praisonai-triage-agent[bot] c5d342bd25 Merge pull request #5197 from MervinPraison/claude/issue-5190-20260922-0947
fix: dedupe compaction strategy mapping and lock knowledge registries
2026-09-22 10:43:03 +00:00
MervinPraison dc5241314a docs: auto-update documentation parity trackers [skip ci] 2026-09-22 10:37:57 +00:00
praisonai-triage-agent[bot] 361c7032aa Merge pull request #5196 from MervinPraison/claude/issue-5192-20260922-0947
fix: add multi-observer Gateway session contract
2026-09-22 10:37:18 +00:00
praisonai-triage-agent[bot]andMervin Praison fa4e9f5416 fix: release idempotency reservation when operator turn dispatch raises
Addresses greptile P1 "Failed Turns Keep Reservations": if
_process_agent_message raises before record() commits, the reserved
request_id was stranded in-flight, so a legitimate retry was reported as
a phantom duplicate (SQLite held it ~15m, in-memory indefinitely). Wrap
the dispatch so any raised exception releases the reservation before
re-raising, keeping the exactly-once path retry-safe.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-22 10:04:49 +00:00
praisonai-triage-agent[bot]andMervin Praison ab463fab68 fix: lock all registry ops + add tests for compaction mapping & concurrency
Address reviewer feedback on PR #5197:
- Qodo #2 (valid): knowledge registries only locked singleton
  construction, leaving register/get/list/clear unsynchronized. Lock all
  operations with the class _lock, invoking the get() factory after
  releasing the lock (matches tools/registry.py precedent).
- Qodo #1 (valid, AGENTS.md §9.1 TDD): add parametrized tests for every
  CompactionStrategy.from_policy_value mapping + unknown fallback, and
  deterministic multithreaded tests asserting singleton identity and safe
  concurrent register/get/list/clear for both registries.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-22 09:58:39 +00:00
praisonai-triage-agent[bot]andMervin Praison 518221f826 test: add regression coverage for multi-observer Gateway session contract
Pins the public SessionVisibility/SessionSharingRole/SessionObserverProtocol
surface (Issue #5192): gateway re-export identity, stable wire values, default
VIEWER role, runtime-checkable recognition, and attach/detach/observers behaviour.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-22 09:55:57 +00:00
praisonai-triage-agent[bot]andMervinPraison fe0ce0a6d2 fix: add request idempotency to gateway operator control-frame path (fixes #5193)
The client->Gateway WebSocket message frame carried no request-idempotency
key and the server did no dedup, forcing at-most-once (lose an in-flight
request on a drop) or duplicate-the-turn (a resend double-runs).

- server.py: a "message" frame may carry an optional client-supplied
  request_id; the server dedups by it (reusing the durable inbound
  idempotency store), acking a duplicate instead of re-running the turn.
  A turn that never ran (agent unavailable / stop) releases the reservation.
- client.py: send() attaches a stable request_id and, with queue=True,
  queues while disconnected and flushes on reconnect with the SAME id,
  replacing the old raise-on-disconnect at-most-once behaviour.
- protocols.py: MessageParams gains an optional request_id field so all
  clients agree on the wire shape.

Missing request_id keeps the legacy fire-and-forget behaviour (back-compat).

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

Co-authored-by: MervinPraison <[email protected]>
2026-09-22 09:55:02 +00:00
praisonai-triage-agent[bot]andMervinPraison 6eee0396d6 fix: dedupe compaction strategy mapping and lock knowledge registries (fixes #5190)
- Consolidate the policy->compactor CompactionStrategy mapping into a single
  CompactionStrategy.from_policy_value classmethod, removing the strategy_map
  dict that was hand-duplicated in _apply_compaction and _apply_compaction_async.
- Add double-checked locking to RetrieverRegistry and RerankerRegistry singletons
  to match the thread-safe pattern used elsewhere (tools/registry.py).

Co-authored-by: MervinPraison <[email protected]>
2026-09-22 09:51:44 +00:00
praisonai-triage-agent[bot]andMervinPraison 1a32d03637 fix: add multi-observer Gateway session contract (fixes #5192)
Adds core protocol vocabulary so a session can be observed by more than one
authorised client at once: SessionVisibility (private/shared/read_only),
SessionSharingRole (owner/member/viewer), and SessionObserverProtocol
(attach/detach/observers). Default stays 1:1 (private/owner); wrapper fan-out
implementation is out of scope for this core contract.

Co-authored-by: MervinPraison <[email protected]>
2026-09-22 09:50:13 +00:00
praisonai-triage-agent[bot] db166f4273 Release v4.7.10 2026-09-22 00:17:57 +00:00
praisonai-triage-agent[bot] 6853f91008 Merge pull request #5143 from MervinPraison/claude/issue-5140-20260918-0855
fix: wire chat --tools/--toolset to restrict interactive session
2026-09-18 09:43:26 +00:00
praisonai-triage-agent[bot] 4091842f51 Merge pull request #5139 from MervinPraison/claude/issue-5138-20260918-0828
fix: wire default adapters at entry points, dedupe managed backends, lazy compute probe
2026-09-18 09:36:26 +00:00
praisonai-triage-agent[bot]andMervin Praison 76cfcd5e2b fix: load --tools file paths in chat + honour restrictions when profiling
Address reviewer findings on PR #5143 (issue #5140):
- _resolve_restricted_tools now loads .py/file-path --tools entries via
  ToolResolver.load_functions_from_module (like `run`), instead of treating
  paths as unknown tool names that resolved to nothing.
- _run_profiled_chat receives and resolves --tools/--toolset, so a restriction
  supplied with --profile is no longer silently dropped on the early-return path.
- Fix cross-test approval-backend leak: test_task_human_review.py now restores
  the process-global backend via an autouse fixture, unblocking
  test_approval_scoping.py (CI test-core failure).
- Add regression tests for tools.py file loading and profiled restriction.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-18 09:07:56 +00:00
praisonai-triage-agent[bot]andMervinPraison 40a8e96b8a fix: wire chat --tools/--toolset to restrict interactive session (fixes #5140)
Thread --tools/--toolset from `chat` into the interactive AsyncTUI via the
same ToolResolver path run/YAML/Python use, so `chat --tools X` genuinely
restricts the interactive session's callable set instead of being accepted
and silently ignored. Removes tools/toolset from _UNWIRED_CHAT_OPTIONS and
keeps the remaining genuinely-unimplemented flags honestly reported.

Co-authored-by: MervinPraison <[email protected]>
2026-09-18 08:59:15 +00:00
praisonai-triage-agent[bot]andMervin Praison 1a1a5e8d53 fix: preserve custom knowledge adapters, agent-before-session order, collision-safe lazy compute discovery
Addresses three P1 regressions flagged in review of PR #5139:

1. register_default_{readers,retrievers,rerankers} now register only names
   not already present, so a custom adapter registered under a built-in name
   (e.g. a multi-tenant host's own `fusion`) is preserved, never overwritten.
2. LocalManagedAgent._ensure_session now creates the inner agent before
   persisting the session, restoring agent-before-session ordering so the
   first persisted snapshot carries agent_id/environment_id (no orphaned
   session under the shared ManagedBackendBase.stream pump).
3. backend_registry lazy compute discovery skips names an entry-point plugin
   already claimed (uses base PluginRegistry.has to avoid re-entering the
   non-reentrant compute lock), so a `praisonai.managed_backends` plugin
   named after a compute provider is not clobbered by the generic loader.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-18 08:47:22 +00:00
praisonai-triage-agent[bot]andMervinPraison b21f7735f7 fix: wire default adapters at entry points, dedupe managed backends, lazy compute probe (fixes #5138)
- Wire register_default_adapters() in _entrypoint.run/arun so built-in
  retrievers (fusion/recursive/auto_merge/hybrid) and rerankers (llm/...)
  are reachable via CLI/YAML, not just Python. Idempotent + thread-safe;
  multi-tenant callers can still opt out via a custom adapter_registry.
- Extract ManagedBackendBase with a single shared stream() queue pump;
  AnthropicManagedAgent/LocalManagedAgent now implement only _iter_events
  (+ _after_stream for local persistence), removing duplicated
  producer/queue/sentinel scaffolding.
- Make backend_registry compute-backend discovery lazy (per-name on first
  resolve/list/has) instead of a heavy entry-point walk + core import at
  module load, and log discovery failures at DEBUG instead of swallowing.

Co-authored-by: MervinPraison <[email protected]>
2026-09-18 08:36:46 +00:00
praisonai-triage-agent[bot] 3806a5592a Release v4.7.9 2026-09-18 01:39:23 +00:00
praisonai-triage-agent[bot] bf95347f7f Merge pull request #5132 from MervinPraison/claude/issue-5131-20260917-0949
fix: wire IncidentTracker into scheduler failure delivery
2026-09-18 01:27:42 +00:00
praisonai-triage-agent[bot] 6e60373844 Merge pull request #5134 from MervinPraison/claude/issue-5133-20260917-1313
fix: reset registry availability memo in test fixture
2026-09-17 13:59:38 +00:00
praisonai-triage-agent[bot]andpm25coder b165c345ea fix: reset registry availability memo in test fixture (fixes #5133)
The autouse _reset_framework_availability_cache fixture only invalidated
the probe memo in praisonai_code._framework_availability, leaving the
FrameworkAdapterRegistry._avail_cache memo (fed from the same probe via
adapter.is_available()) carrying a stubbed answer into later tests.

Extend the fixture symmetrically to also call
get_default_registry().invalidate_availability() behind the same
import-guard pattern, so a stubbed answer never outlives its patch on
either memo.

Co-authored-by: pm25coder <[email protected]>
2026-09-17 13:15:24 +00:00
praisonai-triage-agent[bot]andMervin Praison df934bf751 fix: incident wiring survives delivery failures + shared recovery path
Address reviewer P1s on PR #5132:
- Persist incident alerted/resolved transition only AFTER successful
  delivery; a failed send no longer consumes the alert/recovery and
  permanently silences later identical failures (deep-copy observation
  keeps it side-effect-free until _commit_incident).
- Move recovery observation into the shared _execute_one choke point so
  the CLI poller and host bridge (direct _execute_one callers), not just
  tick(), close incidents and emit the recovery note.
- Move RunPolicy.alert_after_failures to the end of the dataclass so
  audit_dir/scanner keep their positional constructor slots.
- Raise praisonai-bot dependency floor to praisonaiagents>=1.7.6 where
  IncidentTracker is exported.
- Add 4 tests: failed-alert retry, failed-recovery retry, direct-path
  recovery, and RunPolicy positional backward-compat.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-17 10:07:50 +00:00
praisonai-triage-agent[bot]andMervin Praison 613492c275 fix: wire IncidentTracker into scheduler failure delivery (fixes #5131)
The core IncidentTracker alert-fatigue brain shipped in praisonaiagents but
was never invoked at runtime, so the wrapper's failure path re-sent the same
summary on every failing tick. Feed each run outcome through the tracker:
alert once per distinct error signature, stay silent on repeats, re-alert on a
changed error, and emit a single recovery note on the next success. Adds a
minimal RunPolicy.alert_after_failures knob to tolerate transient blips, reuses
the existing JobStateStoreProtocol scratchpad for latching, and falls back to
the prior send-on-every-failure behaviour when no state store is available.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-17 09:55:37 +00:00
MervinPraison 4e5b83fa4a docs: auto-update feature parity trackers [skip ci] 2026-09-17 09:36:41 +00:00
praisonai-triage-agent[bot]andMervin Praison b48cef4c16 fix: session-scoped stop drains queued backlog (#5129) (#5130)
* fix: session-scoped stop drains queued backlog (fixes #5129)

Add a pure StopScope/StopResult primitive to core gateway protocols and
wire a scoped _abort_session into the bot gateway server so a session
stop owns both the running turn and the queued-but-unstarted ingress.

- core: StopScope (TURN default / SESSION) + StopResult in protocols.py,
  exported from gateway/__init__.py
- bot: _abort_session drains _inbox under scope=session; "/stop all" and
  "/cancel" chat commands and a WS abort {"scope":"session"} frame carry
  the scope. "/stop"/"stop" and default abort stay turn-scoped.
- pending_cancelled is reported as a visible, intentional non-outcome.

Additive and backward-compatible: default stays turn-scoped.

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

* fix: close session-stop dequeue race, guard core import, add tests (#5129)

Reviewer follow-ups on the session-scoped stop:

- Race (P1, Qodo/Greptile): the queue worker dequeues a message before
  registering its turn, so a SESSION stop landing in that gap missed it.
  Add a per-session _stop_epoch bumped by _abort_session; the worker
  snapshots it after dequeue and drops the message before dispatch if it
  advanced. An in-flight-unstarted message is counted toward
  pending_cancelled via _inflight_unstarted so nothing is silently lost.
- Import safety (Qodo): guard StopScope/StopResult behind a try/except
  with a dependency-free local mirror, matching the existing _ingress
  pattern, so an older permitted praisonaiagents floor cannot break the
  gateway import.
- CI (test-core): the durable memory-independent team resume path read
  self.tasks unconditionally; a bare AgentTeam (no tasks) raised
  AttributeError. Make _snapshot_task_state / _task_set_fingerprint /
  _restore_serialised_task_state tolerate a missing tasks attribute
  without weakening the tests.
- Tests: add test_scoped_stop.py covering coercion, serialisation,
  turn-scope preservation, session-scope drain+count, no-active-turn,
  unknown session, and the dequeue race.

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-09-17 10:35:19 +01:00
cce3b40001 ci: run the whole praisonai-agents unit suite — 297 failures to 0 (#5010)
* ci: run 3002 praisonai-agents tests instead of 15

#4939 found that CI executed 15 of this package's ~11,900 tests and added a
collection gate, deliberately NOT enabling the suite: it would have gone red on
arrival, and a job that is red the day it lands teaches people to ignore it --
which is how this package ended up running 15 tests in the first place.

That reasoning still holds, but the number was never measured, because every
attempt to run the full suite in that session died. Moving
test_botos_integration.py out of tests/unit (it blocks in socket.accept()) was
what unblocked it. The suite now completes in about four minutes:

    9064 passed, 297 failed, 28 skipped

So this enables what passes. 35 directories run clean -- 3002 tests in ~60s --
and they go into CI as an EXPLICIT list rather than `tests/unit`.

The list is a ratchet: fix a failing area, add it here, and it can never
silently regress. The header records where the 297 live, so the next person
starts from the biggest cluster rather than a survey:

    tools(54) provider_matrix(38) gateway_routing(22) tool_guardrails(18)
    gateway_server(17) managed_backend(16) session(12) runtime(12)
    knowledge(12) agent(9) ... and a long tail

Coverage goes from 15 tests to 3002 without landing red. ~60s of Actions time
per PR touching src/praisonai-agents, on top of the existing ~40s collect gate.

The workflow is renamed agents-suite.yml, since it is no longer only a gate.

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

* ci: record what the 297 failures actually are

Every failing file was re-run in isolation, so the next person does not repeat
the search:

    94 failures (18 files) PASS ALONE  -> test pollution
   203 failures (47 files) fail alone  -> genuine

tests/unit/tools shows it starkly: 54 failures in a full run, 5 when that
directory runs by itself.

Also recorded: the pollution is CUMULATIVE (neither half of the suite nor the
top-level files reproduce it), its symptom for one victim is arguments not
reaching the tool, and resetting the approval and tool registries changed
nothing -- so neither is the leak. That last one was tried and REMOVED rather
than shipped: a fixture that fixes nothing while looking like a fix is worse
than none.

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

* fix: make agents-suite green set installable and machine-independent

The green-set job selected directories whose tests require optional extras
(litellm for workflows/embedding/eval/context, praisonai-sandbox for sandbox)
but installed only base deps, so 15 tests raised ImportError. Install [llm]
and praisonai-sandbox so the selected set can actually run.

Also fixes a hardcoded /Users/praison absolute cwd in the trace import-time
test, and marks two real-agentic tests `live` so a fake OPENAI_API_KEY in CI
cannot let them reach a real network call.

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

* ci: 5797 praisonai-agents tests, and fix an install gap worth 47 failures

Builds on the earlier commit here (15 -> 3002). Two changes take it further.

1. CI now installs praisonai-sandbox.

   It is a MONOREPO SIBLING, not a third-party optional. Without it 47 tests
   fail with "No module named praisonai_sandbox.compute" -- an install gap
   being reported as 47 code failures. Installing it took the full unit suite
   from 297 failures to 250, and test_provider_matrix.py alone from 44 failures
   to 6 with 103 passing (up from 68).

   Worth stating: I first "fixed" this with pytest.importorskip and then with a
   per-place skip. Both worked in the sense that the red went away, and both
   LOST coverage -- the module-level skip dropped 68 passing tests, the
   per-place one dropped 21. Installing the sibling is the root cause, and it
   loses nothing.

2. The green set is now a file, and much larger.

   tests/green-set.txt lists 189 paths that pass CLEAN TOGETHER: 5797 tests in
   ~92s, up from 3002. Four files that pass ALONE are deliberately excluded --
   test_compute_home, test_placement, test_provider_matrix, test_run_on_docker
   fail only alongside the rest, which is pollution rather than broken code.

   A file rather than an inline list so it can be edited without touching CI,
   and so the reasoning travels with it. It stays a ratchet: fix an area, add
   its path, and it can never silently regress.

The workflow header records what the 250 remaining failures actually are (94
pollution across 18 files, 156 genuine across 44) and what is already known
about the pollution, so nobody repeats the search.

CI coverage: 15 -> 5797 tests, ~92s per PR touching src/praisonai-agents.
Verified by running the exact command CI runs, including the portable `xargs <`
form -- `xargs -a` is GNU-only and fails on BSD.

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

* ci: 5823 tests, and 83 of the 297 failures were never code

Continuing the environment work. The full-suite failure count moved
297 -> 250 -> 214 purely by cleaning the ENVIRONMENT, not the code:

  -47  installing the monorepo praisonai-sandbox
  -36  installing the monorepo praisonai-bot, and running with a clean HOME

So 83 of the original 297 were never code. Two examples, both of which I had
mis-classified as GENUINE because I ran the isolation checks on the same
contaminated machine:

  - test_provider_matrix.py: 44 failures, all "No module named
    praisonai_sandbox.compute". A monorepo sibling CI never installed.
  - test_gateway_routing.py: 22 failures, all KeyError: 'code' from a malformed
    ~/.praisonai/gateway/pairing.json on THIS machine, read by a STALE installed
    praisonai-bot. The monorepo version already handles both the legacy `code`
    and hashed `code_hash` forms. With a clean HOME: 22 passed.

That correction matters more than the number. A failure inventory taken on a
developer's machine will over-report, and I had published 156 genuine when it
is nearer 120. The workflow header now records the real figures.

CI installs both siblings, so a fresh runner has neither problem.

green-set.txt grows to 191 paths / 5823 tests (~95s), verified with the exact
command CI runs, in a clean HOME. The same four files stay excluded: they pass
alone and fail alongside the rest, which is pollution rather than broken code.

CI coverage: 15 -> 5823 tests.

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

* ci: record the genuine failures, measured in a clean environment

tests/known-failures.txt lists the 40 files that fail IN ISOLATION with a clean
HOME and both monorepo siblings installed -- 120 failures, ranked, so the next
person starts from the biggest and can re-measure any line with one command.

Re-classifying in a clean environment moved the split materially:

    dirty machine:  94 pollution / 203 genuine   (what I reported first)
    clean:          94 pollution / 120 genuine

The 83 difference was environment, and I had published the wrong figure because
I ran the isolation checks on the same contaminated machine. Isolation proves a
test does not depend on OTHER TESTS; it proves nothing about the environment
they all share.

Two spot-checks of what "genuine" now means, so the list is not mistaken for a
defect backlog:

  - test_tool_guardrails.py: 18 failures in a full run, 50/50 PASS alone. That
    is pollution and it is already excluded from the genuine list.
  - test_managed_backend.py: 16 failures, and genuinely stale -- it asserts a
    LocalManagedAgent._get_client() that exists in neither the installed nor
    the monorepo wrapper. Same class as the two dead test modules fixed in
    #4961: the code was refactored and the test was not. Repairing it means
    reconstructing intent, which is a decision for someone who knows what that
    API became, not a guess from me.

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

* ci: exclude a test that is green on macOS and red on Linux

tests/unit/memory/test_prefetch.py passes locally and fails on the runner
(test_async_memory_search_enforces_..., search_long_term never called).

The green set was derived on a developer machine, so CI is the first place a
platform difference can show. That is the ratchet working: the job went red,
the cause was specific, and the file is excluded rather than the list widened
or the failure ignored. It needs its own look, and is recorded in
tests/known-failures.txt so it is not forgotten.

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

* fix(runtime): the "unknown runtime" error handler crashed instead of explaining

Two genuine failures from the known-failures list, and one is a real bug.

1. runtime/resolver.py raised ImportError where it meant to be helpful.

   The ValueError handler for an unknown runtime id did:

       from .registry import list_available_runtimes
       available = [entry.runtime_id for entry in list_available_runtimes()]

   The registry has never defined list_available_runtimes -- it defines
   list_runtimes(), which already returns ids as strings. So a user passing an
   unknown runtime got an ImportError instead of "Unknown runtime ID: X.
   Available runtimes: [...]". The handler crashed exactly where the error was
   supposed to be explained, and only on the branch nobody exercises.

   tests/unit/runtime/test_resolver.py: 6 failures -> 4. The remaining 4 assert
   a "legacy" resolution_source that now resolves as "default"; that is a
   behavioural question for whoever changed it, not something to guess at.

2. session/test_bot_session_persistence.py: 11 failures -> 0.

   It reached for src/praisonai/praisonai/bots by walking five dirname() calls
   up from __file__ and putting the result on sys.path. The bots module has
   since moved to the praisonai-bot package, so that path no longer contains
   _session.py and every test failed with "No module named '_session'".

   Replaced with a real import of praisonai_bot.bots._session, guarded by
   importorskip: it follows the module wherever it lives, and skips clearly
   when praisonai-bot is absent rather than failing as if the code were broken.

Also retargets three @patch decorators in that resolver test. They patched
praisonaiagents.runtime.resolver.resolve_runtime, but the resolver imports it
INSIDE the function, so the name never existed on that module -- the patch
target has to be the registry.

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

* test(approval): fix three stale mocks and one test that asserted a forbidden thing

tests/unit/test_approval_protocol.py: 3 failures -> 0.

1. Three mocks used the OLD _prompt_user contract.

   backends.py:216 documents a (approved, scope, scope_pattern, feedback)
   tuple; the tests returned a bare bool, so the backend raised "cannot unpack
   non-iterable bool object" and reported approved=False. Updated to the
   documented shape.

2. test_yaml_approved_skips_backend asserted the wrong LAYER, and then a thing
   the design forbids.

   It called reg.approve_sync() directly and expected YAML pre-approval to be
   honoured there. It never has been: the standing-grant check lives in the
   CALLER -- agent/tool_execution.py ORs is_env_auto_approve, is_yaml_approved,
   is_auto_approved and is_already_approved before deciding to prompt -- and in
   tools/tool_proxy.py. Asserting it a layer too low made approve_sync fall
   through to the console backend, which tried to read stdin under pytest. The
   failure looked like broken pre-approval; the product behaviour is correct.

   It also used execute_command, which is CRITICAL. is_yaml_approved refuses
   critical tools outright, deliberately, so config cannot pre-approve the most
   dangerous calls. The test was asserting something the design exists to
   prevent.

   Rewritten to test the registry API the caller actually uses, with a
   high-risk tool -- and it now PINS the safety carve-out: a critical tool
   stays un-approvable even when config names it. That property had no test.

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

* fix(llm): a malformed response was retried against an error that cannot resolve

tests/unit/test_error_classification.py: 5 failures -> 0. Four were missing
patterns; two were real behaviour bugs.

1. "malformed response" and "invalid response format" were classified as
   empty_response. They are FORMAT errors, and the distinction is not cosmetic:
   resolve_failover_decision treats format_error as NON-RETRYABLE and
   empty_response as retryable. So a malformed body was retried against an
   error that cannot fix itself -- spending calls and delaying the real message.
   Moved to the format_error branch.

2. An auth error with NO failover manager fell through to the generic retry.
   There is nothing to rotate to and the attempt-0 credential refresh has
   already had its chance, so retrying a rejected credential only spends calls
   and delays the one message that tells the caller their key is wrong. It now
   surfaces, which is what the test has always documented.

Missing patterns added, each a real classification gap:
   billing        <- "subscription expired"   (had "subscription required")
   overloaded     <- "try again later", "server busy"
   empty_response <- "no content", "blank output", "null response"
   format_error   <- "parsing error", "decode error"

An unclassified error becomes "unknown", which takes the generic path -- so
these were not cosmetic either: a billing failure was being retried like a
transient one.

No regressions: tests/unit/llm went from 3 failures to 2 (the third was
pollution that these runs no longer trigger).

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

* test(handoff): stop Mock's auto-attributes defeating the tool-boundary tests

tests/unit/test_handoff_tool_policy.py: 5 failures -> 3. Both fixes are the
test's shape, not the product -- and checking that was the point, because the
symptom looked like a security boundary failing.

1. Mock tools had no usable name.

   _compute_effective_tools reads
   getattr(tool, 'name', getattr(tool, '__name__', ...)). A Mock AUTO-CREATES
   .name, so every mock tool's "name" was a unique Mock object and the
   intersection was always empty -- 0 tools where 1 was expected. Replaced with
   plain functions, which have no .name and fall through to __name__ exactly as
   a real callable tool does.

2. source and target were the SAME object.

   The end-to-end test built both from a patched MockAgent class. Calling a
   Mock class twice returns the same return_value, so target.tools overwrote
   source.tools and intersecting a set with itself returned BOTH tools. That
   read as "execute_code was not blocked" -- a security boundary failure. It is
   not: execute_programmatic does compute effective tools and pass them to
   agent.chat (handoff.py:802, :610). The test had collapsed two agents into
   one.

The remaining 3 in this file are the same class -- an end-to-end test whose
Mock agents keep tripping the real code paths (target.chat never invoked,
"cannot unpack non-iterable Mock"). Fixing them properly means rebuilding it on
real Agent fixtures, which is a larger change than the failure warrants and
where the test's intent is no longer obvious. Left recorded rather than guessed
at.

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

* test(runtime): fix four resolve tests defeated by Mock's auto-attributes

tests/unit/runtime/test_resolve.py: 4 failures -> 0 (21 pass).

Wrong patch target. test_default_resolver_fallback patched
praisonaiagents.runtime.resolve.LLM, but resolve() imports LLM lazily from
..llm.llm inside the function body, so it is not an attribute of that module --
AttributeError, not a fallback. The sibling test_default_resolver_with_llm
already patches praisonaiagents.llm.llm.LLM; this now matches it.

Mock auto-attributes, again. A bare Mock() answers hasattr() for ANY name, so:

  - provider: LLMRuntimeWrapper.provider returns self.llm.provider when the llm
    has one, else infers from model_ref. The mock always had one, so the
    inference chain the tests exist to cover ("# inferred from model name") was
    never reached -- assert Mock == 'openai'.
  - achat: aexecute uses achat when present, else runs chat in an executor. The
    test that says "# No achat method - should fall back to sync" auto-created
    achat and awaited a plain Mock -- "object Mock can't be used in 'await'".

Both now use Mock(spec=[...]) so the mock has only the methods a real LLM would
have on that path. No product change: the tests were asserting against a shape
no real LLM has.

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

* fix(knowledge): index() reported success=True when every file failed

Real product bug, found behind 5 failing tests in test_indexing.py.

IndexResult.success is a dataclass field defaulting to True, and NOTHING in the
package ever assigned it -- there was no `result.success = ...` anywhere. So
Knowledge.index() returned success=True unconditionally. A directory whose every
file failed to embed (revoked key, wrong model, exhausted quota) came back as:

    IndexResult(success=True, files_indexed=0, errors=[...3 entries...])

index() catches each per-file exception into result.errors, so nothing raised
and the flag stayed True. No caller read the field either, which is why no test
ever caught it: the bug was invisible unless you believed the flag over the
counts, which is exactly what an API consumer does.

success is now derived from the errors index() already collects. Partial
failure counts as failure; callers wanting the split still have files_indexed
and errors.

Three regression tests in test_indexing_reports_failures.py (all-fail,
partial-fail, and an all-succeed control so `success = not errors` can't pass
vacuously). Verified by reverting the one-line fix: 2 fail without it, all pass
with it.

Separately, the 5 tests that surfaced this were calling OpenAI for real.

TestKnowledgeIndex asserts file counts, ignore patterns, glob filters and corpus
stats -- none of which involve embedding -- but used the real Knowledge.add().
Without live credentials the class failed on a 401; with them it spent money and
network time on a unit test. And because index() swallows the error into
result.errors, the 401 surfaced as a bare "assert 0 >= 1" that read like broken
directory traversal. Now stubbed at the class level: 25 pass, 2.90s -> 0.33s.

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

* test(managed): 16 failures were ambient credentials, not broken code

tests/unit/test_managed_backend.py: 16 failures -> 0 (47 -> 50 tests).

ManagedAgent is no longer a class -- it is a FACTORY that picks a backend by
reading the environment:

    if os.getenv("ANTHROPIC_API_KEY") or os.getenv("CLAUDE_API_KEY"):
        provider = "anthropic"      # -> AnthropicManagedAgent
    else:
        provider = "local"          # -> LocalManagedAgent

TestManagedAgent obtained its subject from that factory and then asserted
Anthropic-only behaviour: _get_client, usage tracking, session reuse, interrupt.
So all 16 passed on any machine with ANTHROPIC_API_KEY exported and failed
everywhere else with "'LocalManagedAgent' object has no attribute '_get_client'"
-- which reads like a missing method, not a missing env var. Measured both ways:
16 failed without the key, 46 passed with it, same commit.

The 24 in-test imports now name AnthropicManagedAgent directly (aliased to
ManagedAgent, so the 25 construction sites are untouched). The suite now passes
identically with and without a key exported -- verified in both environments.

Added TestManagedAgentFactoryRouting to cover the dispatch itself, which nothing
tested: key present -> Anthropic, key absent -> Local, explicit provider ignores
the environment.

Also test_config_defaults: ManagedConfig.networking became a typed
NetworkingConfig instead of a dict. NetworkingType is a str Enum so the wire
value is still "unrestricted"; only the container changed. Assertion updated to
the current type, keeping the original intent.

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

* fix(bot): YAML rejected the comma-separated allowed_users the CLI accepts

tests/unit/test_whatsapp_message_filtering.py: 5 failures -> 0, and 122.60s ->
2.21s, because two of them were starting real servers.

Product bug: same setting, two entry points, one broken.

bots_cli splits the CLI flag on commas --

    allowed_numbers = [n.strip() for n in respond_to_raw.split(",") ...]

-- so `--respond-to 123,456` works. But allowlist/blocklist/allowed_users were
widened to List[str] "for consistency", and pydantic rejects a str for List[str],
so the identical value in bot.yaml raised a ValidationError. Comma-separated
strings are already this schema's convention: admin_users and
user_allowed_commands sit directly below those fields and are documented as
exactly that. A mode="before" validator now splits them; lists pass through
untouched, whitespace and empty items are dropped.

Three YAML tests were asserting nothing.

They patched builtins.open and yaml.safe_load and fed a flat dict
({"platform":..., "respond_to":...}). start_from_config no longer parses YAML
itself -- it checks the path exists, then hands the FILE to
load_and_validate_gateway_yaml, which validates a channels-based schema. So the
patches never applied and every test died on "Config file not found: bot.yaml".
They also wrapped every assertion in `if mock_start.called:`, so they would have
passed silently checking nothing once the call stopped happening. Rewritten
against real temp files in the current schema, with unconditional assertions.

Two CLI tests were starting a real WhatsApp server.

They patched praisonai.bots.whatsapp.WhatsAppBot, but start_whatsapp does
`from praisonai_bot.bots import WhatsAppBot` at call time -- so the patch missed,
a real bot was constructed, and start_whatsapp's closing asyncio.run served
forever until the 60s pytest timeout. That is where 120 of the 122 seconds went.
Correct target, plus _run_bot patched so the serve loop never runs.

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

* fix(tools): async tool failures counted twice, opening breakers at 3 of 5

Real product bug. Every async tool failure was recorded against the circuit
breaker TWICE, so a breaker configured failure_threshold=5 opened on the 3rd
failure. Measured: instrumenting CircuitBreaker._on_failure showed 2 calls per
single tool invocation, and the breaker rejecting the 4th call.

_invoke_guarded already records every outcome:

  - error dict  -> raises _ToolFailure (applying the approval/permission/policy/
                   guardrail exclusions) so breaker.acall counts it
  - success     -> acall's _on_success
  - raised exc  -> acall's `except Exception: self._on_failure()`

Two further breaker_record calls -- one after the invoke, one in the except
block -- counted the same failure again. The except-block comment says it was
added because "the post-invoke record block above is skipped and raised failures
never trip the breaker"; that was true of _invoke, but not of the breaker
wrapper that now surrounds it. Both are removed. Both failure shapes were
affected, not just raises.

The timeout path is the one outcome the breaker genuinely does not see:
asyncio.wait_for cancels the coroutine inside acall, and CancelledError is a
BaseException, so acall's `except Exception` never runs. It now records
explicitly, where the timeout is handled.

Why no test caught it: the only test pinning the threshold covered raises alone,
and its sibling drives 5 failures then asserts the 6th is open -- which holds
whether the breaker opens at 3 or at 5. Added tests for both failure shapes and
a control that denials never trip the breaker. Verified by reverting: 2 fail
without the fix, 17 pass with it. Full tools + agent suites: no regressions.

Also in this commit, tool retry tests:

- Agent(tool_retry_policy=...) moved into tool_config=ToolConfig(retry_policy=...).
  6 call sites migrated, and Agent's unknown-kwarg _HINTS now names the
  replacement for tool_retry_policy and tool_timeout, as it already does for
  verbose and stream. Previously the user got a bare "unexpected keyword
  argument" and a 41-parameter signature to search.
- TestRetryPolicyAgentic drives a REAL LLM through agent.start() (AGENTS.md
  §9.4). Without a live key all 4 fail on "Expected string response, got
  NoneType", which reads like a retry bug. Marked @pytest.mark.live, the repo's
  existing gate (skipped unless PRAISONAI_LIVE_TESTS=1).

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

* fix(runtime): a runtime instance passed as cli_backend was silently discarded

tests/unit/runtime/test_resolver.py: 4 failures -> 0, +2 tests.

Product bug. resolve_runtime_config gates ALL legacy cli_backend handling behind
`if self.default_runtime_id is None`, and the in-line comment concedes "in
practice, default is always available" -- so the branch was dead. Passing an
already-constructed runtime INSTANCE therefore warned about deprecation and then
dropped the object, running the agent on the default registry runtime instead of
the one the caller handed over. resolve_runtime_instance still carries the branch
that unwraps such an instance; it had simply become unreachable.

An instance is now honoured regardless of the default. The default-wins order is
about choosing between runtime *ids*, and an instance is not an id: it cannot be
expressed as the model-scoped configuration the warning tells you to migrate to,
so the default cannot stand in for it. A legacy backend NAME keeps the
documented priority -- default outranks it -- and that test now asserts
"default" rather than "legacy", which is what the code actually does.

The two validation tests built an invalid AgentRuntimeConfig and expected
validate_runtime_config to raise. The type checks moved into __post_init__, so
the TypeError fired on the construction line OUTSIDE the pytest.raises block and
the tests errored. Both checks still exist in both places, so each is now
covered by its own test: construction rejects it up front, and
validate_runtime_config still rejects a config mutated after construction.

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

* fix(bus): event history was empty unless something happened to be subscribed

tests/unit/bus/: 3 failures -> 0 (49 pass).

EventBus recorded history AFTER a no-subscriber fast path, in all three publish
paths:

  publish()        returned a bare Event without calling publish_event at all
                   when there were no subscribers and no sinks
  publish_event()  returned before the history append when no subscribers
  publish_async()  same

So get_history() returned nothing at all unless a subscriber or sink happened to
be attached. The record of what the bus did depended on who was watching it --
which is precisely backwards for the auditing and debugging history exists for,
and get_history() is documented as "Get recent event history" with no such
caveat. Appending to a list capped at _max_history is not the expensive work the
fast path was protecting; subscriber matching and dispatch are, and those are
still skipped when there is nobody to dispatch to.

JUDGEMENT CALL, flagged deliberately: the repo contradicted itself here, and I
picked a side. Three tests (test_event_history, _with_filter, _limit) publish
with no subscribers and expect history back -- those were the ones failing. Two
others (test_publish_fast_path_no_subscribers, test_no_sink_preserves_fast_path)
asserted get_history() == [] and so blessed the bug. I took the documented
contract over the implementation detail: the three describe what the feature
promises, the two describe how it happened to be built. Both blessing tests are
updated to assert that dispatch is skipped while the event is still recorded,
each carrying the reasoning. If the performance of the append is the real
concern, the decision to revisit is whether history should be opt-in, not
whether it should silently depend on subscribers.

No product code consumes EventBus.get_history(), so nothing internal depended on
either behaviour. Regression-checked across bus, session, agent and telemetry:
925 passed, and the 4 remaining failures were already on known-failures.txt.

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

* test: live tests gated on a key being SET, not on it being real

8 failures -> 0, across 5 files. All one bug, in five spellings.

These are "real agentic" tests that drive a live LLM. Each guarded itself by
checking whether OPENAI_API_KEY was present:

  test_streaming_with_tools.py  not os.environ.get("OPENAI_API_KEY")
  test_gap_closure.py           not os.environ.get("OPENAI_API_KEY")
  test_toolsets.py              ... or OPENAI_API_KEY == "test-key"        (x2)
  test_circuit_breaker.py       ... or OPENAI_API_KEY == "not-needed"
  test_max_steps_budget.py      ... and not PRAISONAI_LIVE_TESTS

But a placeholder key is always exported. The agents-suite workflow sets
OPENAI_API_KEY: 'sk-not-a-real-key' on purpose -- some modules bail at import
without one -- and local harnesses do the same. So the skip never fired and every
one of these ran against a fake credential and failed. The two guards that try to
exclude a placeholder by value each hardcode a single string ("test-key",
"not-needed") and miss whatever the next harness uses.

The failures were actively misleading. test_streaming_with_tools.py asserts
"a plain streamed turn produced nothing; the harness is broken" -- which is
exactly what a missing credential produces, and reads like the streaming defect
the file was written to catch.

All now use the repo's canonical gate, @pytest.mark.live: an explicit opt-in via
PRAISONAI_LIVE_TESTS=1 rather than an inference from a variable that is set
either way. That is the mechanism the workflow already documents ("the live
marker keeps these offline"); these five files just predated it.

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

* test: fix patch targets and an approval prompt reading stdin under pytest

6 failures -> 0 across two files.

test_ast_grep_tool.py (3): ast_grep_rewrite is
@require_approval(risk_level="high"). With no approval callback configured the
gate falls back to an interactive stdin prompt, which under pytest raises
"reading from stdin while output is captured" and surfaces as "Execution of
ast_grep_rewrite denied: Approval error: ...". That reads like the tool
crashing -- which is precisely what test_tool_does_not_crash_when_not_installed
set out to disprove. These tests cover ast-grep's fallback behaviour, not the
approval gate, so an autouse fixture sets PRAISONAI_AUTO_APPROVE for the module.

test_context_compaction_policy.py (3): the tests patched
praisonaiagents.context.adapters.{get_model_limit, ContextBudgeter,
estimate_messages_tokens}. None of those names is in adapters -- it imports only
from .protocols. compute_context_budget pulls them in as function-level imports
from .budgeter and .tokens, so the patches now name those source modules, where
a call-time import actually resolves.

The third was arithmetic. test_model_specific_overrides mocked
budgeter.usable = 7200 and then reasoned as though the denominator were the
model limit of 8000: its comment claims "87.5% utilization", but
compute_context_budget divides by usable, giving 7000/7200 = 97%. That is over
the 0.92 model override, so the route was compact_then_truncate and the test
failed against its own stated intent. usable is now 8000, which makes the real
utilization the 87.5% the test describes. test_policy_routing_logic in the same
file already pins the denominator (utilization == 2500 / 3600).

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

* test(knowledge): whole suite now runs offline — 131 pass, 0 fail

7 failures -> 0 across three files. tests/unit/knowledge/ is fully green with no
credential at all.

test_embedding_failure.py (3) and test_adapters.py (2): both build a
ChromaKnowledgeAdapter via __new__ to skip the chromadb import, mirroring the
attributes __init__ would set. _embedder was added to __init__ later (the fix
that stopped an explicitly-configured local embedder from silently going to
OpenAI) and neither helper was updated, so _resolve_embedder raised
AttributeError inside the product on every add and search -- the tests never
reached the failure path they exist to cover.

test_directory_ingestion.py (2): these hit the real OpenAI embeddings endpoint.
The module guard excluded one placeholder key by value ("not-needed") while CI
exports 'sk-not-a-real-key', so it never skipped; the request 401'd; and because
add() swallows the embedding error into its result rather than raising, the
failure surfaced as "assert 0 > 0". The per-test `except ... pytest.skip("api_key")`
guards never fired for the same reason -- nothing escapes add() for them to catch.

Rather than mark them live and lose the coverage, an autouse fixture replaces the
embedding call with deterministic bag-of-words vectors, so ingestion, storage and
search all run for real, offline. Not a constant vector: the tests assert that a
search for ZEBRA-71 returns the ZEBRA document FIRST, so the stub has to preserve
ranking. crc32 keeps it stable across processes where hash() is randomised.

One trap worth recording: `import praisonaiagents.embedding as m` yields a lazy
proxy that resolves to the FUNCTION, not the module, so setattr on it fails.
importlib.import_module gives the module object both call sites resolve against.

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

* fix(plugins): enable()/disable() with no arguments always raised

Real product bug. PluginManager.list_plugins() is typed List[PluginInfo] and
returns dataclasses, but the enable-all and disable-all branches called
plugin_info.get("name", "") on them:

    for plugin_info in manager.list_plugins():
        manager.enable(plugin_info.get("name", ""))

So plugins.enable() and plugins.disable() -- the no-argument form that acts on
every discovered plugin -- raised AttributeError: 'PluginInfo' object has no
attribute 'get'. Passing explicit names worked, which is why it survived; the
whole-set path was simply broken.

Notably the module-level list_plugins() wrapper right below handles BOTH shapes
(`if hasattr(info, 'name')` ... `elif isinstance(info, dict)`), so the dict
assumption had already been noticed once and fixed in one place but not the
other. Both call sites now use plugin_info.name, matching the declared type.

test_self_improve.py (2): the parser was tightened to reject values outside the
preset list, with a did-you-mean suggestion; two tests still asserted that an
unrecognised value silently disabled review. Rejecting is the stronger
guarantee, and the one the tests were written to protect -- "a typo like
'backround' must not silently enable inline review" is satisfied more completely
by an error than by a silent default. Updated to assert the ValueError and the
suggestion. Recognised falsy values (false/off/no/0/False/OFF) still disable and
are still covered; "" is unrecognised and moved to the rejection test.

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

* test(permissions): align with the safe-by-default deny set; fix two real defects

5 failures -> 0 across two files.

test_permissions.py (3): three tests asserted _perm_deny == frozenset() for an
agent with no approval config. That default was deliberately hardened -- an
agent with no approval mechanism now applies _resolve_default_deny_set(), which
blocks execute_command, delete_file, kill_process, execute_code and friends
rather than letting them run unguarded. approval=False is the same story: it
disables the BACKEND, and with nobody left to ask, denying destructive tools is
the safe posture, not a regression. Tests updated to assert the hardened
default, with PRAISONAI_TOOL_SAFETY=off pinned in a new test so the documented
opt-out cannot rot silently. The zero-overhead test now sets that env var, since
the falsy-empty-set path it describes is only reachable with denials off.

test_allowed_tools_filter.py (2): two genuine defects, one in each layer.

The test patched logging.getLogger() -- the ROOT logger -- while the module logs
through logging.getLogger(__name__). A child logger's .warning is its own
method, and propagation carries records to root's HANDLERS, not to root.warning,
so the assertion failed while the warning sat in the captured log output two
lines above it. Now patches the module logger.

And diagnostics reported "whitelist": list(self._whitelist) from a SET, so the
order differed between runs -- a diagnostic report that cannot be diffed across
two runs is much less useful for the debugging it exists for. Now sorted, which
is deterministic without changing how the whitelist is stored or matched.

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

* test: five stale contracts — protocol growth, eager auth, a copy, a budget

5 failures -> 0 across five files.

test_protocols.py: AgentRuntimeProtocol is @runtime_checkable, so isinstance()
requires EVERY member. The protocol grew seven members (runtime_name,
runtime_version, capabilities, execute_agent, stream_agent, validate_config,
health_check) after MockRuntime was written, so the mock had quietly stopped
implementing the protocol it exists to demonstrate, and the test failed on a
bare "assert False". MockRuntime now implements the full surface.

test_cli_backend_protocol.py: same cause, subtler mechanism. The test assigns
config/execute/stream onto a Mock but left capabilities to Mock.__getattr__.
isinstance() against a runtime_checkable Protocol uses inspect.getattr_static,
which does NOT call __getattr__ -- so an auto-created Mock attribute is invisible
to the check even though hasattr() returns True. Verified the protocol itself is
sound: a concrete class implementing all four members satisfies it. capabilities
is now assigned like the others.

test_panel.py: Agent.__init__ resolves subscription credentials eagerly for
auth=, by design ("missing credentials must fail at construction, not at request
time"). The test passes auth="claude-code" because it asserts auth reaches the
panel, so it raised AuthError on any machine without Claude Code logged in --
every CI runner. Now sets ANTHROPIC_TOKEN, the fallback the resolver names in
its own error message.

test_runtime_capabilities.py: asserted `resolve_runtime(config) is config`.
The resolver now canonicalises preferred_runtime and returns
dataclasses.replace(...), specifically so "a config reused/compared after agent
construction is unchanged". The test now pins that non-mutation guarantee, plus
a second case for the identity shortcut that still holds when there is nothing
to normalise.

test_api_exports.py: __all__ hit 31 against a < 29 bound. Traced the two
additions rather than just raising the number: RetryBackoffConfig (#2082) and
RunOutcome (#3406), both canonical public types. Bound raised to 32 with that
recorded, and a note that the ratchet is only worth keeping if each bump is
deliberate.

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

* fix(security): SSRF bypass — octal-dotted loopback hosts passed URL validation

http://0177.0.0.1/ was accepted by BOTH URL validators. 0177 is octal for 127,
so under glibc's inet_aton that host is 127.0.0.1 — loopback.

This is a parser differential, not a missing blocklist entry, which is why it
survived. Both validators classify a host by asking the resolver, and resolvers
disagree:

    glibc inet_aton   "0177.0.0.1" -> 127.0.0.1   (loopback)
    getaddrinfo/macOS "0177.0.0.1" -> 177.0.0.1   (public)

Measured, not assumed: socket.getaddrinfo("0177.0.0.1") returns 177.0.0.1 here.
So the validator sees a harmless public address, clears the URL, and the HTTP
client on a glibc host then connects to loopback. Asking the resolver cannot fix
this, because the resolver is one of the two disagreeing parsers.

Every other encoding in GHSA-5c6w-wwfq-7qqm was already handled --
127.1, 0x7f000001, 2130706433, localhost. -- so only the dotted form with a
non-decimal label fell through, in:

  - SpiderTools._validate_url: reached the getaddrinfo fallback and was allowed
  - url_safety.is_safe_http_url: classifies purely by getaddrinfo, so allowed

The second is the more serious of the two: it is the shared helper other tools
call. It had no test for any of this.

Ambiguous dotted numeric hosts (a leading-zero decimal label, or a 0x hex label)
are now rejected outright by one shared helper, is_ambiguous_numeric_host, used
by both validators. Rejecting beats normalising: there is no legitimate use for
these in a URL, and guessing which parser the transport will agree with is the
mistake that created the hole. Canonical dotted-quads and ordinary hostnames are
untouched.

Also caught two forms the advisory does not list and neither validator blocked:
0x7f.0.0.1 and 127.0.01.1.

New tests cover both helpers, including a control asserting that ordinary hosts
(example.com, 1.1.1.1, 93.184.216.34) are NOT flagged — without it, a check that
rejected everything would pass just as well. Full tools suite: 674 passed, no
regressions.

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

* test: four stale bindings and signatures

4 failures -> 0 across four files.

test_trust_system.py (2):
  - Patched praisonaiagents.tools.trust.get_registry, which does not exist:
    _is_tool_external imports it inside the function as
    `from .registry import get_registry`, so the call resolves against the
    source module. Retargeted there.
  - Asserted membership through the name imported at the top of the test file.
    add_external_tool REBINDS the module global (the set is a frozenset, so it
    builds a new one), leaving `from ... import EXTERNAL_TOOL_NAMES` pointing at
    the old object. The product behaviour was correct all along --
    is_external_tool returned True in the same test -- only the stale reference
    was wrong. Now read through the module. The test also mutated
    process-global state with no undo, leaking a fake tool name into everything
    that ran after it; the original set is now restored in a finally.

test_todo_update.py (1): the test's stand-in agent defined
_check_tool_policy_and_guardrails(self, function_name, arguments) while the real
method gained a `tools` argument. The async path calls it with four positionals,
so the stub raised TypeError, and the failure surfaced as
{"error": "Error in execute_tool_async: ... takes 3 positional arguments but 4
were given"} rather than as the missing progress channel the test is about.

test_hierarchy.py (1): asserted `session2 is session1` -- that a second read
returns the cached object. get_extended_session deliberately stopped doing that:
#1785 ("stale extended cache after cross-instance writes") found it returning
TRUNCATED message lists after a cross-process write, and reading fresh every
time was the fix. The cache is still maintained and _is_cache_valid still passes
in the same test; it is simply no longer what this accessor returns. Now asserts
equality, with the reason recorded so nobody restores the identity check and the
bug with it.

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

* fix(runtime): an empty turn result carried no error unless the key was missing

3 failures -> 0. One product bug, two stale assertions.

PraisonAIRuntime.run_turn checked for an empty result but only set an error when
OPENAI_API_KEY was ABSENT:

    if not result:
        if not os.environ.get('OPENAI_API_KEY'):
            return RuntimeResult(content="", error="...required")
    return RuntimeResult(content=str(result) if result else "", metadata=metadata)

So the one case it handled was the one already obvious to the user, while an
INVALID key, a rate limit, a network failure or a refusal all returned
content="" with error=None -- indistinguishable from "the model had nothing to
say". Presence of a credential is not validity, which is the same trap that put
five test files on live endpoints in this branch. An empty result now always
carries an error, keeping the specific missing-key message where it applies.

test_thread_safety.py: asserted isinstance(agent._history_lock,
type(threading.Lock())). The lock was upgraded to AsyncSafeState, which selects
a threading or asyncio primitive by context and still works as a plain context
manager. The test pinned an implementation detail; it now asserts the lock is
usable and releases, which is what thread safety actually requires.

test_tool_resolution_repair.py: expected execute_tool to raise
ToolExecutionError for an unknown tool name. #3309 ("self-repair and actionable
feedback on unknown tool calls") deliberately made that case RETURN
{"error": ..., "available_tools": [...]} so the model can pick a real tool and
retry, rather than finalizing the run failed over a recoverable mistake -- the
loop boundary only converts results marked retryable or raised-exception. Test
now asserts the dict, including that the available names reach the model.
Its sibling still expects a raise for a bind failure, which stays correct: that
is the tool itself throwing, not a name the model can correct.

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

* fix(agent): memory="history" silently dropped on the default OpenAI path

Real product bug. Persisted session history was injected in only ONE of the two
branches of _build_messages:

    if self._openai_client is not None:
        messages, original_prompt = self._openai_client.build_messages(
            ..., chat_history=self.chat_history, ...)   # session history absent
    else:
        ...
        if self._history_enabled and self._session_store is not None:
            ...                                          # session history here

_openai_client is set for OpenAI models -- the default -- so an agent configured
with memory="history" loaded nothing from its session store and answered every
turn with no memory of the previous ones. Only agents on the manual/custom-LLM
branch actually got history. There is no error; the feature just quietly does
nothing on the common path, which is why it survived: the test covering it set
_using_custom_llm = True intending to take the other branch, but _openai_client
is non-None regardless of that flag, so the test was silently exercising the
broken path and failing there.

The load is now a single _load_session_history() helper used by both branches
(prepended to the in-memory chat history for the client path), so the two cannot
drift apart again. Behaviour is otherwise unchanged: same working-history
preference (Issue #2741), same fallback to raw chat history, same
swallow-and-log on failure.

Regression-checked across agent, session, memory, context and llm suites:
1491 passed, 0 failed.

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

* test(handoff): finish the tool-boundary tests — 18/18, boundary verified

The remaining 3 in test_handoff_tool_policy.py, deferred earlier as needing real
fixtures. They were three more variants of the same Mock trap, and none of them
was a product defect: the tool security boundary IS enforced, and the end-to-end
test now proves it (search allowed, execute_code excluded).

Every one comes from `getattr(mock, name, default)` never taking the default,
because a Mock has every attribute:

  - _prepare_context does getattr(source_agent, 'chat_history', []) and iterates
    it -> "'Mock' object is not iterable", caught and logged inside the handoff,
    so target.chat was simply never called and the assertion read as a broken
    boundary. Fixed by giving the mock agents a real empty history.
  - _get_handoff_seed_lock builds a real DualLock only when
    getattr(agent, '_handoff_seed_lock', None) is None. A Mock returns a Mock,
    so the seeding context manager got an object that is not a context manager
    -> "'Mock' object does not support the context manager protocol". Fixed by
    handing the targets a real DualLock.

test_agent_chat_tools_none_inherits_agent_tools was worse than wrong -- it
tested nothing. It bound the REAL _format_tools_for_completion to a Mock and
then asserted the MOCK's _format_tools_for_completion had been called, which
could only pass if the real method delegated to itself. It does not (that would
recurse): it assigns `tools = self.tools` and formats them. Rewritten against a
real Agent, asserting the OUTPUT, which is the property that actually matters:
None inherits the agent's tools, and an explicit [] denies them all. Both
directions are asserted, so neither can silently collapse into the other.

Running theme across this file: four separate failures that each looked like a
security regression and were all Mock auto-attributes. Worth remembering the
next time a boundary test goes red.

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

* test: diagnose the cross-test pollution — 29 failures -> 5

The full unit suite had 29 failures that every affected file passed in
isolation. Two causes, both process-global state, found by bisecting the
collection order.

1. One line, nineteen failures.

tests/unit/tools/test_delegation_tools.py line 8:

    os.environ.setdefault("PRAISONAI_AUTO_APPROVE", "true")

At module level, so it runs at COLLECTION time and leaves auto-approval on for
the rest of the pytest process. Every later test that checks a tool actually
REACHES the approval backend then saw an empty backend, because auto-approve
short-circuits it first:

    test_approval_protocol      assert 'execute_command' in []        (x3)
    test_default_tool_safety    "must reach the approval backend"     (x2)
    test_approval_liveness      "stale resolution is denied"          (x2)
    test_loop_detector_wiring   doom-loop approval gate               (x3)
    ...and the delegation tests themselves, once another test's env
       patching removed the value they were silently relying on       (x6)

Replaced with an autouse monkeypatch fixture, so those tests keep their
auto-approval and it is unset afterwards. This is exactly the hazard already
recorded at the top of test_indexing_reports_failures.py: "a module-level env
mutation persists for the whole test process".

2. Deprecation warnings are emitted once per PROCESS.

warn_deprecated_param keeps a module-level `_warned_params` set so users see
each deprecation once rather than on every Agent construction. Right for users,
fatal for tests: whichever test constructs an Agent with a given deprecated
parameter first gets the warning, and every later test asserting the same
warning sees nothing -- even inside warnings.catch_warnings() with
simplefilter("always"), because the suppression is not the warnings filter.
Verified directly: two identical constructions, warning on the first, silence on
the second, restored by clearing the set. Five failures across three files, on
collection order alone. Cleared by an autouse fixture in conftest.

Neither was a product defect, and neither was visible from any single file. Full
unit suite: 9368 passed, 5 failed (down from 29).

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

* ci: run the whole unit suite — 9373 pass, 0 fail

The green-set is gone. This job now runs tests/unit in full.

Last four pollution failures, each found by bisecting collection order and each
a test's own global state, not a product defect:

  test_cost::test_lazy_litellm_import
    test_embedding.py evicted every sys.modules key matching `'litellm' in k`.
    That also matches praisonaiagents.llm._litellm_loader, so the next import of
    it re-executed the module and produced a SECOND module object with fresh
    globals -- while _cost.py still called get_litellm bound to the first. The
    test reset and asserted one object's _litellm_import_attempted while the
    calls updated the other's. Now matches the litellm PACKAGE only.

  test_presentation_poll (x2)
    asyncio.get_event_loop().run_until_complete(...). Since 3.12 get_event_loop
    does not create a loop when none is set, so this raised "There is no current
    event loop" as soon as any earlier test's asyncio.run() left the main thread
    without one. Now asyncio.run().

  test_code_tools_bridge::test_approval_gate_required_for_every_call
    cleared the approval context in its finally but never at the start, so an
    approval left in the contextvar by an earlier test made the first call
    record a remembered approval covering the second -- the callback fired once
    instead of twice, which is exactly the "first approval silently unlocks
    later calls" behaviour the test forbids.

  test_mixed_tools_list_resolution::test_named_tool_is_dispatchable...
    tests/unit/skills/test_approval_scoping.py calls
    registry.add_requirement("read_file", "high") on the process-global registry
    and never removes it. read_file stayed approval-gated for the rest of the
    suite, so an unrelated tools test hit the interactive console prompt and
    failed with "reading from stdin while output is captured". Now saved and
    restored around each test in that file.

Verified with the exact command CI runs: 9358 passed, 0 failed (15 fewer than
9373 because test_prefetch.py stays excluded -- it passes on macOS and fails on
Linux, a real platform difference that still needs its own look).

The workflow header no longer claims the suite cannot be run, and carries the
three pollution traps so the next person does not repeat the bisect.

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

* ci: verify on Python 3.11 — the version CI actually pins

Everything up to here was verified on Python 3.12. The workflow pins 3.11. On a
CI-shaped 3.11 environment the suite was NOT green: 28 failures. It is now 0.

The excluded test was misdiagnosed.

tests/unit/memory/test_prefetch.py was excluded as "passes on macOS, fails on
Linux". Wrong variable: it is Python 3.12 vs 3.11. asearch_memory branches on
isinstance(backend, AsyncMemoryProtocol), a runtime_checkable Protocol, and that
check changed between versions:

    3.11  hasattr           -> MagicMock satisfies it -> async branch taken
    3.12  getattr_static    -> Mock's auto-attrs invisible -> sync branch taken

So on 3.11 asearch_long_term ran and the assertion failed with "search_long_term
not called"; on 3.12 it passed. Demonstrated directly on both interpreters, then
proved by reverting: fails on 3.11 without the fix, passes with it. The mock now
names its methods (spec=), so both versions take the same branch. The test is
back in the suite and nothing is excluded any more.

Two real gaps in the install list, invisible on a developer machine.

  [knowledge] extra   22 knowledge tests failed on "chonkie package not found"
  src/praisonai       47 tests failed on "No module named 'praisonai'" --
                      test_managed_backend.py imports
                      praisonai.integrations.managed_agents

Both were already installed in my local env, which is exactly why running in a
clean CI-shaped venv was worth doing rather than assuming.

Two tests required optional third-party packages CI does not install:
TestJIRAConnection needs `jira`, and test_get_client_raises_without_key needs the
anthropic SDK -- without it the ImportError about the missing SDK fires before
the missing-key check the test is about, which is the case its own sibling
covers. Both now skip when the package is absent instead of failing.

Verified on both interpreters, in CI-shaped environments:
    3.11  9357 passed, 0 failed
    3.12  9373 passed, 0 failed

One trap for whoever runs this next: a corrupt .praisonai/chroma.sqlite3 left in
the worktree by earlier runs makes chromadb's rust bindings raise
pyo3_runtime.PanicException and fails two config tests. It is gitignored, and
deleting it fixes them. That is the store isolation issue #4376 refers to.

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

* test: make the suite order-independent — verified across random seeds

pytest-randomly was never installed, so every `-p no:randomly` in this repo
(mine included) was a no-op and the suite had only ever run in collection order.
Installing it and running real random orderings found five more tests that
depend on the order they run in. All were global state a test set and did not
put back.

  test_cost::test_lazy_litellm_import
    Reached the loader's state by importing the module BY NAME. Other test
    modules evict every sys.modules key matching 'praison' or 'litellm' and
    re-import, which can leave two live module objects with separate globals --
    so the test reset one dict while the calls updated the other. Now goes
    through the function's own __globals__, which by definition is the dict
    get_litellm writes to and cannot drift. (An earlier fix via __module__ was
    not enough: sys.modules[name] can still be the other object.)

  test_security_hardening_round2::test_launch_auth_optional_by_default
    resolve_launch_host() writes PRAISONAI_LAUNCH_AUTH_TOKEN into os.environ
    directly when it mints a token for a non-loopback bind, and caches it in
    _GENERATED_TOKEN. A test about the DEFAULT has to establish the default, not
    inherit it; an autouse fixture now clears both.

  test_availability_gating::test_transient_probe_failure_serves_last_good
    Another test shrinks the global registry's _availability_grace to 0.0 to
    exercise the sustained-failure path and never restores it. registry.clear()
    resets the caches but not the tuning, so the grace window stayed 0 and
    last-good was never served. Saved and restored per test.

  test_code_tools_bridge (x2: approval_gate_denies, serve_tool_call_honours_gate)
    Approvals are remembered in a contextvar, correctly. An approval left by an
    earlier test made a DENIAL test see an already-approved tool and not raise.
    The whole file now starts each test with a clean approval context, which
    also subsumes the single inline clear added earlier.

  test_unified_plugin_system::test_skills_discovery_uses_paths_module
    Asserted every discovered dir is named "skills", but
    get_default_skill_dirs() walks every cwd ancestor and appends the
    remote-skills cache, whose leaf is not "skills". It was asserting on
    whatever happened to exist on the machine. Now hermetic via PRAISONAI_HOME
    and a temp cwd.

Verified on Python 3.11 in a CI-shaped env, full suite:
    collection order   9357 passed, 0 failed
    seed 12345         9357 passed, 0 failed
    seed 424242        9357 passed, 0 failed

KNOWN, NOT FIXED: seed 7 segfaults (exit 139) in
tests/unit/skills/test_self_improve.py. Reproducible for that seed, does not
reproduce from a file-order replay or a short prefix, and does not occur on
origin/main under the same seed -- though main has 150 failures there, so that
comparison is not clean. It needs the whole randomised suite to manifest, which
makes it a native-crash bisect rather than a test fix. CI is unaffected: it runs
collection order and does not install pytest-randomly. Recorded rather than
quietly dropped.

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

* fix(tests): the seed-7 crash was a dangling chroma mmap; +4 more order deps

The interpreter crash is fixed, and fixing it uncovered four more
order-dependencies it had been hiding by aborting the run at 71%.

The crash: exit 139 (SIGSEGV) on one run, 138 (SIGBUS) on the next, always
inside an unrelated test. Alternating SEGV/BUS is the signature of touching a
memory-mapped file whose backing store is gone, and the crash disappeared
entirely when tests/unit/knowledge was excluded.

ChromaDB caches a System per persist path in
SharedSystemClient._identifier_to_system, and that cache outlives the directory.
A test builds a store under a temp dir, the dir is removed, the cached client
and its mmap'd sqlite stay alive, and a later test resolving to the same path
touches a mapping with no file behind it -- which the kernel answers with a
signal, not an exception. conftest now clears that cache after each test.

Uncovered by the fix, since the run now reaches the end:

  test_paths.py (17)  get_data_dir() returns ~/.praisonai if it exists, else the
    legacy ~/.praison if THAT exists. These tests assert the default while
    inheriting the ambient home, so once any earlier test created a legacy
    ~/.praison they all asserted '.praisonai' and got '.praison'. Each test now
    gets an empty home and a cleared path cache.

  TestModuleWiring (x2, in two files)  compared DEFAULT_RULES_DIR / _DEFAULT_DIR
    -- constants computed at IMPORT time from the home in effect then -- against
    a get_rules_dir() call made later. That only holds if no earlier test moved
    the home. The modules are now reloaded so the constant is recomputed under
    the current home, which is what "resolves via paths.py" actually asserts.

  test_ast_grep_tool  is_ast_grep_available() memoises in a module global, so
    the first caller fixed the answer process-wide and a later test patching
    shutil.which got the cached one. Reset around each test.

Verified on Python 3.11 in a CI-shaped env, full suite, every seed clean:
    collection order, seeds 1, 7, 12345, 99, 424242  -> 9357 passed, 0 failed
Python 3.12, collection order -> 9373 passed, 0 failed.

Repo-hygiene trap, unchanged and worth knowing: tests write chroma stores into
the worktree's gitignored .praisonai/, which accumulates across runs (142 dirs,
26MB here) until one goes corrupt and chromadb raises
pyo3_runtime.PanicException, failing ~14 memory/learn tests. `rm -rf
src/praisonai-agents/.praisonai` fixes it. CI never sees this because a runner
starts clean; it cost me two false diagnoses locally.

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

* fix(tests): the 9 CI failures — all pre-existing, newly exposed by the gate

CI went red on this branch with 9 failures across 3 files. None of the three had
EVER run in CI: none was in the old green-set. They are pre-existing failures
that running the full suite exposed, which is the gate doing its job on its
first outing.

test_allowed_tools_filter.py (3)
  AllowedToolsFilter is STRICT when the CI env var is truthy -- an unknown tool
  RAISES instead of being warned about and stripped. Three tests exercise the
  dev-mode warn path but never pinned CI, so on a runner (where CI=true is
  exported) they took the strict path and failed with "All specified tools must
  be available in CI mode". The convention was already there:
  test_filter_tools_with_unknown_tools_dev_mode pins CI="false". These now do
  too. My local runs never set CI=true, which is why I did not see this.

test_run_on_docker.py (3)
  The tests probed docker themselves -- `docker version --format
  {{.Server.Version}}`, 25s timeout -- while the runtime asks the docker
  provider's `is_available`, which runs `docker version` with a 5s timeout. On a
  cold runner those disagree: the guard says yes, the test runs, and the runtime
  then refuses with "run_on='docker' cannot start: docker is not available on
  this machine". A guard that can disagree with the thing it guards is not a
  guard. They now gate on the provider's own answer, so they skip exactly when
  the runtime would refuse.

test_team_session_resume.py (3)
  _bare_team() builds an AgentTeam with object.__new__ and mirrors "just the
  fields the methods touch". __init__ also sets `self.tasks = {}`, which the
  save/restore path reaches on Linux but not on macOS -- so it passed here and
  failed there with "'AgentTeam' object has no attribute 'tasks'". Mirrored, the
  same way the ChromaKnowledgeAdapter fixtures had to mirror _embedder.

Worth recording: the 5s timeout on `docker version` is tight for a cold machine,
and its failure mode is a flat "docker is not available" when docker is merely
slow. That is in praisonai-sandbox, so it belongs in its own change; noted, not
touched here.

Verified with CI's own conditions -- CI=true, CI-shaped 3.11 venv, clean HOME:
9357 passed, 0 failed.

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

* fix(tests): gate the docker tests on the provider the RUNTIME resolves

CI went 9 -> 3: the allowed-tools and team-session fixes landed, the docker ones
did not, because my first attempt probed the wrong class.

ComputeManagedAgent._ensure resolves its provider through
`resolve_compute(place)`, which returns DockerCompute. I gated the tests on
`DockerSandbox().is_available` from praisonai_sandbox -- a different class, with
a different answer. On the runner DockerSandbox said available, the tests ran,
and DockerCompute then refused. Same disagreement as the original 25s-vs-5s
probe, just moved one class along.

The guard now performs the runtime's exact resolution and reads is_available
the same way _ensure does (a property on one provider, a method on another), so
the two cannot answer differently by construction. Verified locally:
resolve_compute('docker') -> DockerCompute, is_available -> False, and the three
tests skip rather than fail.

The lesson I should have taken the first time: "gate on the same check the
product uses" only helps if it is the same check. Reading the resolution path
beats assuming which provider a name maps to.

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

* ci: drop "(green set)" from the job name

The green set is gone -- the job runs the whole unit suite -- but the label
still said otherwise on every PR check list, which is exactly the kind of stale
signal this branch spent its time removing.

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

* fix(parity): drop four stale signature waivers now closed on the TS side

#4986 (Task human review) and #5004 (per-tool guardrails) have merged into
main, so praisonai-ts now exposes humanInput/humanReviewPrompt on Task and
inputGuardrails/outputGuardrails on tool(). The four waivers that recorded
those as Python-only gaps no longer cover any gap, so the signature gate
fails them as stale. Delete them, exactly as the gate instructs.

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

---------

Co-authored-by: Claude Opus 5 <[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: Mervin Praison <[email protected]>
2026-09-17 10:34:02 +01:00
praisonai-triage-agent[bot]andMervin Praison a4ec761966 fix: canonical terminal-outcome contract with deterministic precedence (#5006)
* fix: add canonical terminal-outcome contract with deterministic precedence (fixes #4828)

Extend praisonaiagents/run_outcome.py with RunTerminal, a frozen closed
discriminated union for how a run ends, plus a pure merge_run_terminal()
that refines only toward stronger attribution (aborted > timeout > failed
> ok), is_sticky() for deliberate cancels / hard timeouts / supersedes,
and collapse() projecting onto the existing RunStatus vocabulary. Prevents
a late generic provider error from silently overwriting a user cancellation
or a hard timeout.

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

* fix: make merge_run_terminal order-independent for equal-kind sticky sources

Equal-rank observations previously always kept the first writer, so a sticky
run_budget/external/superseded terminal observed after a non-sticky same-kind
signal was dropped and stayed downgradeable — identical signals in reverse
order produced a different final outcome. merge now promotes an equally-ranked
sticky observation over a non-sticky current, guaranteeing order-independent
convergence. Adds regression tests covering both orderings and full fold.

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-09-17 10:33:22 +01:00
praisonai-triage-agent[bot]andMervin Praison 942fa839f9 fix: ship isolated tool-capable code_mode with default subprocess bridge (#5127)
* fix: ship isolated tool-capable code_mode with default subprocess bridge (fixes #5126)

Wires the merged CodeToolBridge contract (#3474) into a production-reachable
path. Adds a shipped concrete transport (LocalProcessBridge) and a third
code_mode value "isolated" that runs model-generated code in a subprocess
while its allow-listed tool calls are serviced in the parent under the same
allow-list + approval gate, returning only stdout to the model.

- praisonaiagents/tools/tool_proxy.py: LocalProcessBridge (subprocess isolation
  + newline-delimited JSON tool-call protocol over stdin/stdout; reuses
  serve_tool_call and POSIX resource limits; no new dependencies).
- praisonaiagents/tools/python_tools.py: build_code_execution_tools now accepts
  code_mode="isolated", building a default bridge and passing it to
  execute_code_with_tools(..., bridge=...).
- praisonaiagents/config/feature_configs.py: validate code_mode in
  {safe, unsafe, isolated}; backward compatible defaults unchanged.
- praisonaiagents/agent/agent.py: attach the isolated tool path.
- tests: LocalProcessBridge protocol/pipeline/isolation/approval/allow-list.

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

* fix: harden isolated code bridge (sandbox escape, tool contract, async, zombie) + fix pre-existing team-resume CI

Security & correctness fixes on the isolated code_mode bridge, plus the
unrelated pre-existing team session-resume CI failure.

- tool_proxy.py: reuse the in-process executor's hardened _validate_code_ast
  in the parent BEFORE spawning the child, closing the
  type(x).__subclasses__()/__globals__ introspection sandbox-escape class
  (only imports were previously blocked).
- tool_proxy.py: _service_bridge_call now re-raises ONLY authoritative policy
  denials (PermissionError/NameError); ordinary tool failures return a
  structured success=False result and no longer swallow SystemExit/
  KeyboardInterrupt — matching the in-process executor's result contract.
- tool_proxy.py: async def tools are awaited to their value via
  _call_and_resolve (both bridged and in-process paths) instead of leaking an
  unawaited coroutine string.
- tool_proxy.py: reap timed-out children with process.wait() (no zombies);
  wrap subprocess.Popen so a launch failure returns the documented failure
  dict instead of raising a separate exception path.
- registry.py: add ToolRegistry(discovery_enabled=...) (default True). Agent
  scoped code-execution registries set it False so an allow-listed name absent
  from the agent's own tools cannot resolve to an installed global plugin.
- agent.py: build the isolated scoped registry with discovery_enabled=False.
- agents.py: make team task-state snapshot/fingerprint/restore defensive with
  getattr(self, "tasks", {}) — fixes pre-existing test_team_session_resume
  AttributeError (from #4986), unrelated to this PR's feature.
- tests: introspection escape blocked, ordinary tool error -> success=False,
  async tool awaited, discovery-disabled registry, Popen startup failure.

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

* fix: remove 4 stale signature-parity waivers failing the gate

The signatures parity gate ran on this PR because it touches
praisonaiagents/**/*.py, and failed on four pre-existing stale waivers
whose gaps no longer exist (Task.human_input, Task.human_review_prompt,
tool().input_guardrails, tool().output_guardrails). The gate's own error
prescribes deleting them from waivers.yaml. Not related to the isolated
code_mode change; removes the stale entries so the gate passes.

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-09-17 10:32:40 +01:00
11cd5024c7 fix: close three concurrency gaps in agent state (#5121)
* fix: close three concurrency gaps in agent state (fixes #5120)

1. PraisonAIAgents/_guard_execution: track run ownership via a ContextVar
   instead of threading.get_ident(), so two coroutines that astart() the
   same team under asyncio.gather no longer share an OS-thread id, falsely
   take the re-entrant branch, and bypass the lock. Mirrors the existing
   _tools_scope_depths ContextVar fix in the same class.

2. chat_mixin.py tool-dispatch (async custom-client + sync streaming):
   malformed tool-call JSON is no longer silently dispatched as {} and
   reported success=True. Reuse a shared _TOOL_ARGUMENTS_PARSE_FAILED
   sentinel + tool_parse_error_message (promoted to agent.tool_execution
   and shared with llm.py) to skip execution and return a retryable
   tool-error to the model.

3. chat_mixin.py OpenAI-native chat/achat: replace the unatomic
   check-then-act duplicate guard with _add_to_chat_history_if_not_duplicate
   so concurrent turns no longer mistake another turn's user message for
   their own duplicate and drop the user turn from the record.

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

* fix: close reviewer-identified gaps in concurrency guard + tool-parse

- guard: pin re-entrancy owner to (id(self), thread, asyncio task) via
  _execution_identity() so a child task inheriting the copied ContextVar
  can no longer falsely re-enter and bypass the held lock; sequential
  batch->item nesting still matches on the same thread/task (Qodo #1,
  Greptile P1).
- async custom-client tool path: catch TypeError alongside JSONDecodeError
  and fire _notify_tool_call(success=False) on parse failure, mirroring the
  sync streaming path (Qodo #6, Greptile P2).
- sync streaming tool path: catch TypeError alongside JSONDecodeError too.
- llm.py: make _tool_arguments_parse_failed / _tool_parse_error_message
  thin delegates to the shared agent.tool_execution helpers, removing the
  duplicate bodies (single source of truth) (Qodo #4).

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

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-09-17 10:32:18 +01:00
fstark96andMr.Stark 388d3113e7 fix: add missing trailing newline (POSIX) (#5119)
Co-authored-by: Mr.Stark <[email protected]>
2026-09-17 10:31:38 +01:00
5ee8275b6f fix(config): load AutonomyConfig lazily in feature_configs (#5058)
* fix(config): load AutonomyConfig lazily in feature_configs

The eager `from ..agent.autonomy import AutonomyConfig` at the top of
config/feature_configs.py pulled agent.autonomy and, through it,
escalation.types onto every `from praisonaiagents import Agent` path.

Import it under TYPE_CHECKING for annotations, import it inside
resolve_autonomy() where it is actually used, and serve the historical
`from praisonaiagents.config.feature_configs import AutonomyConfig` via
the existing PEP 562 module __getattr__ so nothing breaks.

Adds lazy-import tests for the default Agent path and the re-export.

Closes #5056

* test(config): pin autonomy lazy-import parity with tool_search pattern

Adds a regression test verifying resolve_autonomy keeps AutonomyConfig out
of feature_configs module globals until first access (matching the established
ToolSearchConfig PEP 562 pattern) so `from praisonaiagents import Agent` stays
lazy per #5056, while explicit access still resolves and caches the canonical class.

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-09-17 10:31:14 +01:00
MervinPraison 04d53dce83 docs: auto-update feature parity trackers [skip ci] 2026-09-17 09:30:47 +00:00
c278edff0f fix(plugins): PR #5023 follow-ups — allow-list, waivers, session tests (#5128)
* fix(plugins): honour selective enable allow-list on late registration

Follow-up to #5023: new plugins registered after plugins.enable([...])
default to disabled unless allow-listed; remove stale signature waivers;
fix AgentTeam session resume test helper missing tasks dict.

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

* fix(plugins): isolate late-registration allow-list per manager

Move the selective enable allow-list from package-global state into an
instance-local snapshot on PluginManager. The facade sets it only on the
singleton used by plugins.enable([...]) and clears it on plugins.disable(),
so standalone managers keep their default (enabled) registration behaviour
and caller list mutations after enable() no longer affect registration.

Also restore the singleton allow-list in the test fixture and add a
regression test proving standalone managers ignore the facade allow-list.

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

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-09-17 10:29:49 +01:00
Amir Fathi ad08e78e96 fix: stop concurrent agents sharing one LLM from misattributing tokens (#5114)
* fix: stop concurrent agents sharing one LLM from misattributing tokens

current_agent_name was a plain instance attribute on LLM, so when two
Agents share one LLM object and arun_all_tasks gathers their tasks
concurrently, one task's set_current_agent() could overwrite it while
the other was still awaiting its own completion. Convert it to a
per-instance contextvars.ContextVar, same shape as the existing
_tools_scope_depths fix: asyncio.gather copies the context into each
task it creates, so a set() in one task never reaches a sibling task
on the same LLM instance.

Refs #5052 (finding 2a only; 2b and findings #1/#3 are untouched).

* fix: give LLM its own deepcopy so a shared ContextVar does not break Agent clones

Storing the current-agent ContextVar directly on LLM broke copy.deepcopy(Agent)
for any agent built with an already-constructed LLM instance, because
ContextVar has no __deepcopy__/__reduce__ and Agent.__deepcopy__ recursively
copies _llm_instance. Add LLM.__deepcopy__, following the same memo-registering
pattern already used by Agent.__deepcopy__, so a clone gets a fresh ContextVar
instead of failing with TypeError. Also replace the wall-clock
asyncio.sleep(0.05) in the concurrent-attribution regression test with
asyncio.Event coordination so the interleaving is deterministic.
2026-09-17 10:28:59 +01:00
Wu Shuwen b4fc60e5ea fix: carry the archived marker into session bookends (#5113)
`_bookends` walks the shared archived-plus-active projection, so an opening
bookend can be a compacted turn, but its entries carried no `archived` key --
unlike `search`'s context and `window`. That left `SessionHit.bookends` as the
one projection where the compaction boundary introduced by #5031 is invisible.
2026-09-17 10:28:13 +01:00
6823f7f86d fix: auto-extend MCP init timeout for npx/uvx cold-starts (#5110)
* fix: auto-extend MCP init timeout for npx/uvx cold-starts (fixes #5099)

Cold-start package launchers (npx/uvx/bunx/...) download the MCP server
package on first run, which on a fresh cache regularly exceeds the 60s
default init timeout and fails before the handshake begins (esp. Windows).

When the caller does not pass an explicit timeout, raise the effective
init timeout to 180s for these launchers. Explicit timeouts always win,
and non-launcher stdio commands keep the 60s default. The timeout error
now hints at pre-warming the launcher command.

Co-authored-by: Dhivya-Bharathy <[email protected]>

* fix: quote pre-warm hint via shlex.join + generic timeout remediation

Address PR review feedback on #5110:
- Use shlex.join so the pre-warm command hint preserves argv boundaries for
  spaced paths / shell metacharacters (Greptile, Qodo #4).
- Add actionable remediation to non-launcher stdio timeouts (Qodo #1).
- Add tests asserting launcher and non-launcher timeout message content (Qodo #2).

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

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: Dhivya-Bharathy <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-17 10:27:33 +01:00
30d01b3ff5 fix: inject retrieved RAG context into llm_prompt in sync chat (#5109)
* fix: inject retrieved RAG context into llm_prompt in sync chat (fixes #5098)

In Agent._chat_impl the retrieved knowledge was appended to `prompt` but
the LLM call used a separate `llm_prompt`, so the model never received the
retrieved text and answered from parametric memory only. Sync llm_prompt
with the formatted context (text prompts only, preserving any response
template). Async path already passed the merged prompt.

Adds a regression test asserting the retrieved marker reaches the prompt.

Co-authored-by: Dhivya-Bharathy <[email protected]>

* fix: inject retrieved RAG context into multimodal (list) llm_prompt

Handle the attachment case where llm_prompt is a multimodal list: append
the retrieved context to the last text part (copying dicts before mutation
to avoid aliasing caller attachments), adding a text part if none exists.
Mirrors the existing response-template append pattern so knowledge reaches
the model on attachment-bearing sync turns too. Adds a regression test.

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: Dhivya-Bharathy <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-17 10:27:01 +01:00
365e2f4203 fix: fail fast on duplicate Telegram poller (409 Conflict) (#5106)
* fix: fail fast on duplicate Telegram poller with 409 pre-flight (fixes #5094)

Telegram allows only one getUpdates poller per token. A second process
(CLI bot, gateway telegram channel, UI Channels worker) silently breaks
replies with a 409 conflict loop. Add a lightweight pre-flight getUpdates
probe that detects an already-active poller before binding, and fail fast
with a clear, actionable message instead of entering the conflict cascade.

Co-authored-by: Dhivya-Bharathy <[email protected]>

* fix: fail fast on real Telegram 409 instead of harmful getUpdates probe (#5094)

The pre-flight getUpdates probe is itself a competing consumer: with a
short poll it terminates an incumbent long-poll (incumbent gets the 409,
probe gets 200), so it both disrupts a healthy bot and unreliably detects
the conflict. Remove the probe and instead classify the real 409 raised by
updater.start_polling(), then re-raise a clear, actionable RuntimeError so
supervisors mark startup as failed rather than silently stopping.

- Remove detect_telegram_poll_conflict() from _resilience.py
- telegram.py: capture is_conflict_error() in the polling loop and
  fail fast after cleanup with a single-instance RuntimeError
- tests: replace probe tests with 409 classification + start() fail-fast
  structural guarantees + helper-removal assertion

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

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: Dhivya-Bharathy <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-17 10:26:13 +01:00
MervinPraison c3059ae252 docs: auto-update documentation parity trackers [skip ci] 2026-09-17 09:25:39 +00:00
praisonai-triage-agent[bot] 5056159bd2 Merge pull request #5125 from MervinPraison/revert/anyapi-5122
Revert AnyAPI built-in tool (#5122)
2026-09-17 09:24:58 +00:00
praisonai-triage-agent[bot] fc1034823d Merge pull request #5124 from MervinPraison/claude/issue-5123-20260917-0820
fix: close wrapper gaps in adapter resolver, run-history RMW, HTTP client reuse
2026-09-17 09:21:42 +00:00
55f9e5741a fix: wrap chonkie embeddings import with actionable error (#5105)
* fix: wrap chonkie embeddings import with actionable error (fixes #5102)

The semantic/sdpm/late chunkers imported chonkie.embeddings.AutoEmbeddings
without wrapping, surfacing a raw ModuleNotFoundError. Now raises the same
actionable message as the main chunker path: install praisonaiagents[knowledge].

Co-authored-by: Dhivya-Bharathy <[email protected]>

* fix: preserve original chonkie import error + add embeddings guard tests

Only translate the AutoEmbeddings import failure into the install hint when
the top-level chonkie package is genuinely missing; otherwise re-raise the
original ImportError so a broken transitive dependency is not masked.

Add unit tests covering the default semantic, sdpm, late and string-model
paths (actionable hint) and the installed-but-broken case (original error).

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: Dhivya-Bharathy <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-17 09:51:08 +01:00
2d601c43e9 fix: pin Langflow transitive deps for praisonai[flow] on Python 3.13 (#5103)
* fix: pin Langflow transitive deps for praisonai[flow] on Python 3.13 (fixes #5101)

a2a-sdk<1.0 lacks a2a.server.active_task and older fastapi-pagination
breaks against newer FastAPI, causing `praisonai flow` to crash on import.

Co-authored-by: Dhivya-Bharathy <[email protected]>

* docs: note flow/claw a2a-sdk mutual exclusivity in [flow] extra

Langflow needs a2a-sdk>=1.0.0 while praisonaiagents[a2ui] (pulled in by
[claw] via praisonaiagents[all]) pins a2a-sdk<1.0.0. Document that [flow]
and [claw] are mutually exclusive rather than risk a core a2ui bump that
would regress the existing praisonaiagents/ui/a2a integration.

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

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: Dhivya-Bharathy <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-17 09:50:49 +01:00
xizhuomengcontin 32b895169c fix: pass the resolved base URL to headers_for, not the Qwen constant (#5091) 2026-09-17 09:50:32 +01:00
Wu Shuwen 3f04ae5f4b fix(agents): wire enabled plugin tools into agents (#5023)
* fix(agents): harden plugin tool lifecycle

* fix(agents): reject non-executable plugin schemas

* fix(agents): preserve plugin ownership across clones

* fix(agents): retain all shared plugin owners

* fix(agents): satisfy plugin test lint
2026-09-17 09:48:30 +01:00
MervinPraison 47e82d2134 docs: auto-update documentation parity trackers [skip ci] 2026-09-17 08:44:39 +00:00
4813db8c36 feat(task): require a person to sign off a task's output (#4986)
* feat(task): require a person to sign off a task's output

Two things already existed and neither did this: the approval system gates a
TOOL CALL, and guardrails validate automatically. Nothing let an orchestrator
say "a person must approve this before the next task consumes it".

    Task(description=..., human_input=True, human_review_prompt="Is this legally safe?")

Reuses the approval BACKENDS rather than prompting directly, so it works
wherever approvals already do -- console, callback, bot, UI -- instead of
hard-wiring input() and breaking every non-TTY caller. A rejection fills the
same Task.validation_feedback slot the guardrail retry already uses, so the
reviewer's reason reaches the re-run through machinery that exists.

Review runs AFTER the automatic guardrail passes, not before. Waking a person
to reject output a guardrail would have caught wastes the one resource this
feature spends.

Two refusals worth stating:

- If praisonaiagents.approval is unavailable, a task with human_input=True
  RAISES rather than skipping the review. A task that was supposed to be signed
  off and simply was not is the failure this feature exists to prevent.
- Repeated rejection stops at max_retries with a message naming the reviewer's
  reason, rather than looping.

9 tests, three of them controls: a task without human_input is never reviewed
(and the backend is never called), a rejection within budget does not raise,
and the flag defaults off.

Parity: adds two expiring waivers (2026-12-08). They say plainly they are NOT
an equivalence but a real gap -- TypeScript has an approval manager, so the port
is tractable and was left out only because it is a separate change in a separate
package. The expiry means the gate goes red if it is forgotten.

No regressions: tests/unit/{agents,task} is 259 passed vs 250 on origin/main,
exactly the 9 new tests, zero failures either side. Collect gate and all three
parity gates exit 0.

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

* fix(task): gate async task output on human review and fix feedback shape

Two valid defects from PR review:

- arun_task applied the guardrail but never called the human-review hook, so
  an async task with human_input=True released unreviewed output. Add
  _aapply_human_review (blocking backend offloaded to a thread, mirroring
  _aapply_task_guardrail) and wire it into arun_task after the guardrail passes.

- A rejection stored the reviewer's reason as a bare string, but
  Process._build_task_context indexes feedback['validation_response']; the
  workflow engine would raise the moment it consumed it. Write the same dict
  shape the guardrail retry writes so the reason reaches the re-run.

Adds end-to-end async gate tests (plus a control) and a test asserting the
rejection feedback survives Process._build_task_context.

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

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

---------

Co-authored-by: Claude Opus 5 <[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-09-17 09:43:59 +01:00
MervinPraison 848dec8669 docs: auto-update documentation parity trackers [skip ci] 2026-09-17 08:40:04 +00:00
cc0216db9b fix: fourteen shipped defects, and the unit tree from 277 failures to zero (#4958)
Rebased onto latest main. Consolidates the branch's 42 commits into one
because the original history could not be replayed onto main (the early
commit shared no blob ancestry, yielding spurious whole-tree conflicts).
The net change is identical to the branch's intended diff (70 files).

Conflict resolved: praisonaiagents/tools/spider_tools.py -- main (#4966)
landed the same SSRF octal-loopback fix; kept main's superset, preserving
this PR's defect-1 intent.

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-09-17 09:39:24 +01:00
MervinPraisonandCursor 04e21328ee docs: remove AnyAPI exports from api.md after revert
Co-authored-by: Cursor <[email protected]>
2026-09-17 09:38:39 +01:00
MervinPraison cf445b4202 Revert "Add AnyAPI built-in tool (#4817)"
This reverts commit eaa62e5525.
2026-09-17 09:38:09 +01:00
praisonai-triage-agent[bot]andMervin Praison 9e77def922 fix: backend-safe run-history locking, per-loop async client, webhook race, langfuse tests
- history.py: skip fcntl sidecar flock for backend storage (avoids
  FileNotFoundError under uninitialised path); move backend delete inside
  index lock; cache RunHistory per resolved path so Windows (flock no-op)
  still serialises via shared in-process RLock.
- passthrough.py: key cached httpx.AsyncClient to its owning event loop and
  rebuild on loop change; add close_clients()/aclose_clients() shutdown hooks.
- executor.py: guard lazy webhook client creation with an asyncio.Lock and
  close under the same lock in stop() to prevent a leaked/overwritten client.
- langfuse_client.py: add close()/context-manager to release the Session pool.
- test_langfuse_commands.py: patch requests.Session.get (client now uses a
  session) so tests mock correctly instead of hitting localhost:3000.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-17 08:35:56 +00:00
praisonai-triage-agent[bot]andMervinPraison bcdd50909a fix: close wrapper gaps in adapter resolver, run-history RMW, HTTP client reuse (fixes #5123)
- registry: remove shadowed duplicate resolve_or_default so the intended
  fallback-with-actionable-error definition wins (was dead code)
- recipe/history: guard index read-modify-write with a reentrant in-process
  lock plus cross-process fcntl advisory lock, make _save_index atomic via
  tmp file + os.replace, and lock the get_history() singleton assignment
- recipe/operations: JobHandle keeps a lazily-built shared httpx.Client so
  wait()/poll loops reuse one keep-alive connection (context-manager + close)
- jobs/executor: reuse a single AsyncClient for webhooks, closed on stop()
- capabilities/passthrough: module-level lazy shared sync/async clients
- cli/langfuse_client: reuse a requests.Session with persistent auth

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

Co-authored-by: MervinPraison <[email protected]>
2026-09-17 08:25:53 +00:00
MervinPraison 3e298c8e10 docs: auto-update api.md [skip ci] 2026-09-17 08:15:45 +00:00
MervinPraison b7662c61a3 docs: auto-update documentation parity trackers [skip ci] 2026-09-17 08:14:22 +00:00
MervinPraison 09457a6635 docs: auto-update feature parity trackers [skip ci] 2026-09-17 08:12:38 +00:00
1de60722d7 feat(ts): model-harness helpers and guardrail retry (#4964)
* feat(ts): port the model-harness testing helpers, closing 5 parity gaps

praisonai-ts had no way to unit-test an agent offline. Python gained
ScriptedModel and a global no-network guard; the TypeScript side had neither,
which the parity tracker recorded as 5 missing exports.

Adds src/model-harness/{scripted,guard}.ts:

- ScriptedModel extends the real BaseLLM and replaces only the
  provider-facing generate/generateStream, so prompt assembly, options
  handling and the response shape all run production code. The double cannot
  drift from the real client, because it IS the real client with its network
  boundary swapped out -- the same reasoning as the Python port.
- It records every request the agent sent, supports callable entries that
  answer based on the request, and throws ScriptExhausted when the script runs
  out rather than inventing a reply or hanging.
- allowModelRequests(false) blocks real provider calls and ModelRequestBlocked
  names the offending call site.

The guard tracks open scopes with a COUNTER, not a saved-and-restored boolean.
The Python version originally used a snapshot, and an inner scope exiting
first could re-enable requests while an outer scope was still open -- leaking
exactly the call the guard exists to prevent. A regression test covers the
overlapping case here so the TypeScript port never acquires that bug.

Parity: TS gap count 6 -> 1 (only GuardrailRetry remains, which needs a real
guardrail retry loop in TS rather than an exported name).

11 new tests, each positive paired with a control. Full suite 2454 passed,
build and webview gate exit 0, all three parity gates exit 0.

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

* feat(ts): guardrail retry — TS parity gap count reaches 0

TypeScript had no guardrail retry at all: applyOutputGuardrails ran in
finishTurn, downstream of the model call, and simply threw. Exporting a
GuardrailRetry symbol would have satisfied the name tracker while doing
nothing, so this builds the behaviour instead.

A blocking guardrail now raises GuardrailRetry carrying its reason. The turn
loop appends that reason to the same conversation and re-runs the turn, so the
model patches its previous answer rather than the caller restarting the task.

maxGuardrailRetries defaults to 0, which preserves the previous behaviour
exactly -- including the error text. GuardrailRetry.toBlockedMessage()
reproduces the original
`Agent <name>: guardrail "<g>" blocked the response: <why>` byte for byte, so
existing callers and tests are untouched. An existing parity test caught this
when the first draft dropped "blocked the response" from the message.

6 tests, mutation-checked: disabling the loop fails 3 of them. Two are
controls -- a passing guardrail runs the turn exactly once, and the default
config still throws on the first rejection.

Not shipped: an `Agent({ provider })` option for injecting a built provider.
It was written, and then found never to be consulted -- the suite mocks the
OpenAI client globally and the agent reached it by another path, so the option
would have looked supported while doing nothing. Removed rather than shipped
half-wired. Making a model double drive a full TS agent run needs that seam
built properly, and is worth its own change.

Parity: praisonai-ts name gap count 6 -> 0.
Full suite 2460 passed, build and webview gate exit 0, all three parity gates
exit 0.

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

* feat(ts): per-tool guardrails — clears both expiring waivers

Python gained @tool(input_guardrails=, output_guardrails=); TypeScript had
neither, so #4929 shipped two waivers with expires: 2026-12-07 to unblock it.
Those waivers said plainly they were NOT an equivalence but a real gap. This
closes the gap, so both are deleted -- a waiver whose gap has closed is stale
and fails the check, which is what proves the port is real.

Adds src/tools/tool-guardrails.ts and wires it into FunctionTool.executeRaw:

- Input guardrails run FIRST, before the approval gate, so bad arguments are
  refused without waking a human and never reach the tool.
- Output guardrails run on the result before it reaches the model.
- Either may block or REPLACE, so a guardrail can redact instead of refuse.
- A blocked call returns { error, guardrail_denied } rather than throwing:
  the model reads it and reacts, which is what makes the guardrail useful
  mid-run instead of fatal. Same shape the Python side returns.

7 tests, three of them controls: an allowed call runs normally, a guardrail on
one tool does not fire for another, and an unguarded tool declares undefined
so the fast path stays free.

Parity: `signatures --check` now passes with both waivers REMOVED.
Full suite 2467 passed, build and webview gate exit 0, all three parity gates
exit 0.

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

* docs(parity): record why 10 of the 12 behaviour entries must NOT be "closed"

Verified against praisonaiagents, not assumed:

- AgentTeam knowledge/guardrails/web/reflection/caching/learn: Python's
  AgentTeam constructor logs that it does not apply them at the team level
  either and tells callers to pass them to individual Agents. TEAM_OPTION_NOTES
  in agent/team.ts already said so; the ledger did not.
- Task web/reflection/autonomy/planning: Python's Task assigns each to self and
  nothing in agents.py or process.py reads them back -- `task.<option>` returns
  zero hits in both. Stored and ignored.

So TypeScript not acting on those ten IS parity. Implementing them would make
this SDK diverge from the reference it is measured against, which is the
opposite of what the number appears to ask for. Only AgentTeam.autonomy and
AgentTeam.toolsRunOn are blocked on TypeScript-side work, and both wait on
larger subsystems.

The count is deliberately left at 12 rather than trimmed. Quietly shrinking a
parity number is the exact failure this tooling exists to catch; the fix is to
record what the number means, so the next person starts from the two entries
that are real instead of doing ten changes that would each be a regression.

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-09-17 09:11:29 +01:00
MervinPraison c26c7e16eb docs: auto-update api.md [skip ci] 2026-09-17 08:10:52 +00:00
eaa62e5525 Add AnyAPI built-in tool (#4817)
* Add AnyAPI built-in tool

AnyAPI puts hundreds of scraping and data APIs behind one key and one
normalized JSON schema, priced per request in USD.

Three tools, not one per platform. The catalog is a few hundred APIs and
it grows, so a tool per platform would be a wrapper that goes stale. The
interface that does not is discover then run:

  anyapiSearchApis  ranked catalog search; slug, description, USD price
  anyapiGetApi      one API's normalized input/output JSON Schema + price
  anyapiRunApi      run an API by slug; returns output and the USD cost

Follows the valyu/firecrawl pattern: ToolMetadata, a lazy import() of the
optional package that throws MissingEnvVarError without ANYAPI_API_KEY and
MissingDependencyError without @getanyapi/sdk, tool factories, and
create*Tool wrappers registered in tools.ts.

One deviation from those two, deliberate: when the package loads but does
not export the client, this throws MissingDependencyError instead of
returning an empty result. A run reports the USD it charged, and a
fabricated zero cost would misreport money.

* fix(ts): export anyapi factories from package root

Adds anyapiSearchApis/anyapiGetApi/anyapiRunApi to the praisonai package
root export list so consumers can import them like the sibling built-ins
(valyu*, firecrawl*), closing the parity gap flagged in review.

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

* fix(ts): make the advertised anyapi id resolvable and name the price a ceiling

Two review findings on #4817.

Register the base `anyapi` id alongside the three suffixed ones, the way
tavily already does. `tools list` prints the metadata id `anyapi`, and
`tools.anyapi()` exists on the facade, but the registry only held
anyapi-search-apis/get-api/run-api, so `tools.create('anyapi')` and
`praisonai-ts tools test anyapi` both failed on an id the CLI had just
listed. The base id now resolves to the search tool, the entry point of
the discover-then-run loop.

Rename AnyapiApiSummary.priceUsd and AnyapiGetApiResult.priceUsd to
maxCostUsd. Both map pricing.from.maxUsd, which the SDK documents as the
price of a flat offer but only the ceiling of a linear one, so "price"
overstated it for metered APIs and sat oddly beside failoverMaxUsd, which
was already named as a ceiling. The two tool descriptions the model reads
say the same thing now. AnyapiRunApiResult.costUsd is unchanged: that one
really is what the call was charged.

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

* chore(ts): regenerate the parity tracker for the anyapi exports

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

---------

Co-authored-by: Kevin Wang <[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: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-09-17 09:09:11 +01:00
MervinPraison b9296da308 docs: auto-update feature parity trackers [skip ci] 2026-09-17 08:06:50 +00:00
f20eb95533 feat(ts): team-wide toolsRunOn shares one sandbox across members (#5008)
* feat(ts): compute providers become toolsRunOn places

`toolsRunOn` was already wired end to end -- resolvePlacement picks a place and
Agent.invokeTool routes tool calls through it -- but the registry that
registerToolPlace fills was EMPTY. The mechanism existed and nothing populated
it, so every `toolsRunOn` value silently fell back to running on the host.

    new Agent({ instructions: '...', toolsRunOn: 'docker', tools: [...] })

ComputeToolPlace adapts a ComputeProvider (#5003) to the ToolPlaceLike
contract, and registering happens on import of praisonai/compute -- by the act
of having providers, rather than a caller remembering a setup step.

Four decisions:

- A tool without a declared `command` runs LOCALLY and says so. A JavaScript
  closure cannot cross a process boundary, and pretending otherwise would imply
  isolation that is not there.
- Every substituted argument is single-quoted. Verified: `{who: "a; echo pwned"}`
  produces the literal text, not a second command.
- An unavailable provider RAISES rather than falling back to the host. Silently
  running on the host what a caller asked to sandbox is the opposite of what
  asking for a sandbox means -- a security property, not an inconvenience.
- A timeout raises rather than returning an empty string, which would read as a
  successful tool call that found nothing.

Deliberately NOT claimed: AgentTeam.toolsRunOn stays in the behaviour ledger.
This honours the AGENT-level option; team-level placement needs one shared
instance across members and is not wired. I removed that ledger entry first,
an existing parity test failed, and it was right to -- the entry is restored
rather than the test adjusted.

8 new tests (24 in tests/unit/compute total), three of them controls. Full
suite 2485 passed, build and webview gate exit 0, all three parity gates
exit 0.

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

* feat(ts): a team-wide toolsRunOn places every member in ONE sandbox

The ledger note said this "needs one shared sandbox for the whole team;
TypeScript has no compute providers yet, so every tool would still run on the
host". That was true until #5003 and #5007. A single ToolPlace instance handed
to every member is exactly one shared sandbox, so the entry is now removed --
legitimately, with a test proving members share the SAME instance rather than
one each (separate instances would multiply cost and lose state tools leave for
each other).

An agent that declared its OWN toolsRunOn keeps it: an explicit choice on the
member is more specific than the team's default, and silently overriding it
would be the surprising direction.

behaviour parity: 12 -> 11 options.

Two things went wrong on the way, both worth recording:

1. I first removed the ledger entry after wiring only the AGENT-level option.
   An existing parity test failed and was RIGHT to -- team placement was not
   wired. The entry went back until it actually was.

2. Making toolsRunOn validated (rather than accepted-and-ignored) meant 'docker'
   had to be a known name without the caller importing praisonai/compute. My
   first attempt used `require()` in placement.ts, which put compute on
   praisonai/mobile's static graph and broke the chrome58 bundle check --
   `require` counts as a STATIC import, a trap this package has hit before.
   The built-ins are now thin placeholders that load the real provider inside
   runTool through a COMPUTED specifier, so nothing lands on any bundle graph.

One existing test is updated rather than worked around: it used 'docker' as its
example of an UNREGISTERED place, which docker no longer is. It now names one
that genuinely is not registered, keeping the behaviour it guards.

6 new tests, three of them controls: no toolsRunOn leaves members unplaced,
toolsRunOn=false is not a placement, and an unknown place still raises.

Full suite 2491 passed, build and webview gate exit 0, all three parity gates
exit 0.

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

* fix(ts): close five gaps in compute tool placement

- ESM: use a literal dynamic import so esm-shim appends .js (computed
  specifier dodged the rewrite and threw ERR_MODULE_NOT_FOUND); still
  dynamic, so compute/ stays off the mobile/chrome58 static graph
- team: adopt the shared place in agentFor so task-owned and agentConfig
  agents land in the SAME sandbox, not just constructor members
- compute: registerComputeProvider now also registers the toolsRunOn
  place, so a custom provider is selectable via toolsRunOn
- docker: shutdown throws when `docker rm -f` fails instead of reporting
  a clean stop for a container that may still be running
- tool-place: warn once per tool when a non-local place falls back to
  host execution, making the isolation gap visible

Tests: 2495 passed, build + webview-gate exit 0.

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

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

---------

Co-authored-by: Claude Opus 5 <[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: Cursor <[email protected]>
2026-09-17 09:05:55 +01:00
MervinPraison d58ce07485 docs: auto-update documentation parity trackers [skip ci] 2026-09-17 08:01:04 +00:00
Mervin PraisonandClaude Opus 5 0d7a57fd9f feat(ts): per-tool input/output guardrails (#5012)
Ports praisonaiagents/guardrails/tool_guardrails.py to praisonai-ts and removes
the two expiring parity waivers it was written to cover.

praisonai-ts had guardrails at two scopes and nothing in between: whole-run
output validation, and an agent-wide tool surface firing for every tool the
agent can reach. Neither lets a tool author say "*this one* tool must never be
called with a recipient outside the company domain":

    const sendEmail = tool({
      name: 'send_email',
      inputGuardrails: [(args) =>
        String(args.to).endsWith('@corp.com')
          ? [true, args]
          : [false, 'Recipient is outside the company domain.']],
      execute: async ({ to, body }) => { ... },
    });

Contract, matching Python exactly:

  - A block RETURNS the reason to the model as the tool result; it does not
    throw. A blocked call is a normal policy outcome, and throwing would abort
    the run and surface a stack trace for it. The model can then pick different
    arguments, choose another tool, or explain to the user.
  - A guardrail may REWRITE: [true, value] substitutes, [true, null] means
    "unchanged" rather than "replace with null".
  - Unreadable verdicts and throwing guardrails FAIL CLOSED. A verdict that
    cannot be interpreted is not an approval.
  - Input rewrites must still be an object of keyword arguments; anything else
    blocks, because handing a non-object to the tool would throw inside user
    code instead of producing a message the model can act on.
  - The output guardrail sees the RAW result, before any agent-wide handling.
  - Chains run in order, each guardrail's allowed value feeding the next, short
    -circuiting on the first failure. failOpen is opt-in.

Coerced at definition time so a malformed declaration surfaces where it was
written, and so the per-call path is a single property read -- a tool with no
guardrails does no per-call work at all.

One TypeScript-specific trap worth recording: the direction is passed THROUGH
the base constructor rather than declared as a subclass field. Subclass field
initializers run after super(), so `readonly direction = INPUT` would still be
undefined inside the base constructor that needs it.

30 tests covering both directions, all verdict shapes, rewriting, redaction,
chaining and short-circuit, both fail-closed paths, the failOpen escape hatch,
memoised coercion, and controls proving guardrails on one tool never fire for
another and that an unguarded tool is untouched.

Parity gates: names regenerated (guardrails 16 -> 33 exports), signatures OK
(input_guardrails/output_guardrails now camelCase-matched instead of MISSING
(waived)), behaviour ratchet OK. Full praisonai-ts suite: 2705 passed, 0 failed.

The behaviour report and package-lock are deliberately NOT in this commit: the
report's only change is a pre-existing 12 -> 10 drop from AgentTeam.autonomy and
toolsRunOn, which this change did not close and should not claim, and main
regenerates that file on push anyway.

Co-authored-by: Claude Opus 5 <[email protected]>
2026-09-17 09:00:23 +01:00
MervinPraison 801db38668 docs: auto-update documentation parity trackers [skip ci] 2026-09-17 07:57:38 +00:00
MervinPraisonandCursor 4b5808af39 fix(ci): use hub pick-oauth-token so failover works on old PR branches
Local ./.github/actions/pick-oauth-token vanishes after checkout switches to
PR branches that predate dual OAuth (e.g. #4958). Use the template action
and run token pick before PR branch checkout.

Co-authored-by: Cursor <[email protected]>
2026-09-17 08:57:02 +01:00
Mervin Praison bf50683b07 Merge pull request #4959 from MervinPraison/fix/agent-backend-protocol-detection
fix: Agent(llm=<backend>) adopts every documented protocol, not just get_response
2026-09-17 08:56:56 +01:00
praisonai-triage-agent[bot] 47b9e49bc3 Merge pull request #5118 from MervinPraison/claude/issue-5117-20260916-1635
fix: reset process-global framework availability cache per test
2026-09-17 04:29:31 +00:00
praisonai-triage-agent[bot] 77f2414a75 Merge pull request #5112 from MervinPraison/claude/issue-5092-20260915-1308
fix: surface Telegram deny-policy DM drops and correct security banner
2026-09-17 04:24:42 +00:00
praisonai-triage-agent[bot]andMervin Praison 861adaab39 fix: narrow cache-reset fixture guard to ImportError (PR #5118 review)
Catching bare Exception let an installed-but-broken praisonai_code import
be treated as absent, silently disabling test isolation and hiding the
environment defect. Narrow to ImportError so genuine import failures surface.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-16 16:41:58 +00:00
praisonai-triage-agent[bot]andpm25coder edb4d3fe31 fix: reset process-global framework availability cache per test (fixes #5117)
Add an autouse fixture in src/praisonai/tests/conftest.py that calls
praisonai_code._framework_availability.invalidate() before and after each
test, so a memoised answer computed while a test stubs find_spec /
distribution never leaks into later tests. Also document invalidate() as
the test escape hatch in the is_available() docstring.

Co-authored-by: pm25coder <[email protected]>
2026-09-16 16:37:40 +00:00
praisonai-triage-agent[bot]andMervin Praison 577c820cd4 fix: validate unknown_user_policy, sharpen deny banner/log accuracy (PR #5112 review)
- _config_schema.py: add field_validator rejecting invalid unknown_user_policy
  at load time (mirrors validate_approval_mode) so typos no longer pass schema
  validation and silently disable the channel via BotConfig.
- _config_schema.py: reword deny banner to acknowledge already-paired users
  (responds to "NOBODY new") instead of overstating "NOBODY".
- _unknown_user.py: deny-drop warning now logs platform= and conversation=
  separately so operators can locate the affected chat.
- tests: add pair-policy banner test, invalid-policy rejection test, and assert
  platform/conversation labels in the deny-drop warning test.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-16 16:23:30 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 4bc139ce95 fix: surface Telegram deny-policy drops and correct security banner (fixes #5092)
When unknown_user_policy defaults to deny, a DM from a user not in
allowed_users was dropped with no log at all, and the startup banner
still warned the bot "responds to EVERYONE" — the opposite of the
effective policy. Both made a misconfigured bot look broken.

- _unknown_user.py: log a WARNING (user_id + policy) instead of a
  silent drop so an operator can see why a DM was ignored.
- _config_schema.py: add the unknown_user_policy field and make the
  empty-allowlist banner match the resolved policy (deny -> NOBODY,
  allow -> EVERYONE, pair -> pairing required).

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-09-16 16:23:30 +00:00
Mervin Praison 3eb8288584 Merge pull request #5111 from MervinPraison/claude/issue-5093-20260915-1308
fix: wire unknown_user_policy from YAML in praisonai bot start
2026-09-16 17:21:11 +01:00
MervinPraisonandCursor b887ea9c94 fix(ci): unblock offline unit tests and review-chain when GH_TOKEN is empty
Skip litellm remote cost-map fetches in ollama URL tests, use portable paths
in context_events import benchmark, and fall back to github.token in the
review-chain workflow when the GH_TOKEN secret is unset.

Co-authored-by: Cursor <[email protected]>
2026-09-16 17:19:00 +01:00
praisonai-triage-agent[bot]andMervin Praison dfb16de5c6 fix: coerce numeric owner_user_id and validate top-level policy (PR #5111 review)
Address reviewer findings on the single-bot admission-policy wiring:

- Coerce numeric owner_user_id via a before-validator on both the channel
  and top-level single-bot fields, so an unquoted YAML `owner_user_id:
  987654321` (natural for Telegram/Discord) no longer fails Pydantic
  validation. Mirrors the gateway path's existing str() coercion.
- Validate the top-level unknown_user_policy via a shared normalization
  helper so a typo (`alow`) fails closed even when a `channels:` block is
  present (previously only validated when migrated onto a synthesised
  channel).
- Gate the startup `unknown_user_policy` line behind `policy_applied` so it
  is printed only on platforms that actually wire it into BotConfig
  (telegram/discord/slack), and surface the effective `deny` default when
  omitted instead of printing nothing on the common path.
- Add regression tests: numeric owner id (channel + top-level), top-level
  typo rejection with channels present, and startup-info gating.

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

Co-Authored-By: Mervin Praison <[email protected]>
2026-09-16 15:27:24 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy e89367278c fix: wire unknown_user_policy from YAML in praisonai bot start (fixes #5093)
The `praisonai bot start` CLI path built BotConfig without forwarding
`unknown_user_policy`/`owner_user_id`, so a bot.yaml declaring
`unknown_user_policy: allow` was silently ignored and the secure `deny`
default won — dropping every DM. The gateway path already wires this
(Issue #2855); the single-bot CLI path was missed.

- Declare `unknown_user_policy`/`owner_user_id` on ChannelConfigSchema and
  as top-level single-bot fields, migrating the top-level values onto the
  synthesised channel.
- Validate `unknown_user_policy` (fail-closed on typos).
- Forward the fields through BotCapabilities into all three CLI BotConfig
  sites (telegram/discord/slack) via a shared `_policy_kwargs` helper.
- Print the resolved policy at startup so operators can confirm it applied.
- Add regression tests for CLI wiring and YAML validation.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-09-16 15:27:24 +00:00
praisonai-triage-agent[bot] 1281138134 Merge pull request #5108 from MervinPraison/claude/issue-5097-20260915-1309
fix: carry top-level app_token into single-bot Slack channel
2026-09-16 15:25:34 +00:00
praisonai-triage-agent[bot] 118d1012b3 Merge pull request #5107 from MervinPraison/claude/issue-5095-20260915-1309
test: lock run <missing>.yaml routing to agent runner (fixes #5095)
2026-09-16 15:25:02 +00:00
praisonai-triage-agent[bot] df5efc29a4 Merge pull request #5104 from MervinPraison/claude/issue-5100-20260915-1309
fix: replace non-existent @modelcontextprotocol/server-time npm package
2026-09-16 15:24:58 +00:00
praisonai-triage-agent[bot]andMervin Praison ae935dbe6e test: make availability-cache cleanup exception-safe; clarify routing test
- Wrap crewai availability invalidation in try/finally so a failing
  assertion cannot leak a stale ``crewai: False`` into the process-wide
  registry cache (Greptile: order-dependent follow-on failures).
- Reword missing-YAML routing test docstring/comment to be dispatch-only,
  since the recorder stubs run.app and the target is not read (CodeRabbit).

Co-authored-by: Mervin Praison <[email protected]>
2026-09-16 14:42:08 +00:00
praisonai-triage-agent[bot]andMervin Praison 967949d061 fix(test): invalidate availability cache in framework validator tests
is_available() memoises probe results per process (intended hot-path
behavior). The two TestRegistryImportErrorIsContained tests patch
registry.create to raise ImportError, but a cached probe from an earlier
test short-circuited before create() ran, so the ImportError path was
never exercised (DID NOT RAISE / assert True is False). Drop the cached
crewai entry around each test so the patch is honored deterministically.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-16 14:41:43 +00:00
praisonai-triage-agent[bot]andMervin Praison 99c310ceff docs: make MCP.get_tools docstring example cross-platform
Address Greptile review feedback: replace hard-coded POSIX /tmp path with
portable '.' and note Windows npx.cmd invocation in the MCP.get_tools example.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-16 14:41:39 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy b73c9f519b fix: replace non-existent @modelcontextprotocol/server-time npm package (fixes #5100)
The npm package @modelcontextprotocol/server-time does not exist (404).
Replace npm/npx references with working packages:
- YAML example: use published Python server via `uvx mcp-server-time`
- mcp.py docstring and CLI test: use @modelcontextprotocol/server-filesystem

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-09-16 14:41:39 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 7b5a593001 test: lock run <missing>.yaml routing to agent runner (fixes #5095)
`praisonai run agents.yaml` already routes a .yaml/.yml first token to the
modern Typer agent runner instead of the async-jobs CLI (issue #4374 fix in
praison_ai.py:1382-1415). This adds a regression test covering the
non-existent/misspelled .yaml case from issue #5095 so the documented
onboarding path can't silently regress back to the confusing jobs-API error.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-09-16 14:41:01 +00:00
praisonai-triage-agent[bot]andMervin Praison e81f321e03 fix: scope single-bot app_token migration to Slack + add ref-form tests
Only forward the top-level app_token into the migrated channel when the
declared platform is slack (Greptile p2). app_token is Slack-specific, so
forwarding it to a non-Slack custom adapter with a strict constructor could
prevent the channel from starting. Adds regression tests for the ${ENV}
reference form and the Slack-scoping guard.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-16 14:40:53 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy 6eadd88531 fix: carry top-level app_token into single-bot Slack channel (fixes #5097)
The single-bot bot.yaml migration built the Slack channel from `token`
alone, so a plaintext top-level `app_token:` was silently dropped and
never reached the Slack Socket Mode adapter. Accept `app_token` as a
top-level field and thread it into the migrated channel. The core
`BotConfig(app_token=...)` TypeError was already fixed and tested; this
closes the remaining wiring gap.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-09-16 14:40:53 +00:00
praisonai-triage-agent[bot] 4e4668204a Merge pull request #5116 from MervinPraison/claude/issue-5115-20260916-1315
fix: stabilize TestRegistryImportErrorIsContained against warm availability cache
2026-09-16 14:38:06 +00:00
praisonai-triage-agent[bot]andMervin Praison 5e4677fb76 fix(tests): force litellm local model-cost map to keep unit imports offline
litellm fetches its remote model-cost map from GitHub at import time; the
network guard blocks that outbound call, so any unit test that freshly imports
litellm (LLM construction, capabilities) failed with NetworkBlockedError.
Set LITELLM_LOCAL_MODEL_COST_MAP in pytest_configure so imports use the bundled
local map, unless the environment explicitly opts in to live network.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-16 13:44:26 +00:00
praisonai-triage-agent[bot]andMervin Praison 700a6b77fd fix: restore availability cache after ImportError containment tests
Invalidate crewai availability in a finally block so the two
TestRegistryImportErrorIsContained tests no longer leave a stale
False cached on the process-wide default registry, preventing the
order dependency Greptile flagged from moving to later tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-16 13:23:22 +00:00
praisonai-triage-agent[bot]andpm25coder aa3ca1bf5a fix: invalidate framework availability cache in registry ImportError tests (fixes #5115)
TestRegistryImportErrorIsContained assumed the patched create() was the only
path, but is_available() memoises probes in _avail_cache. When an earlier test
in the same xdist worker warmed the cache (e.g. via list_available_frameworks),
the patched create() was never reached, causing intermittent failures.

Call registry.invalidate_availability("crewai") before patching, as the
is_available docstring already prescribes for tests.

Co-authored-by: pm25coder <[email protected]>
2026-09-16 13:17:44 +00:00
MervinPraisonandCursor 79343b01c0 feat(ci): dual OAuth pre-flight probe and failover (v1.1.8)
Pick CLAUDE_CODE_OAUTH_TOKEN / _B before Claude runs; honour
CLAUDE_OAUTH_ACTIVE and fail over on 401/429.

Co-authored-by: Cursor <[email protected]>
2026-09-15 07:49:31 +01:00
praisonai-triage-agent[bot] 6e7dafe500 Merge pull request #5039 from MervinPraison/claude/issue-5016-20260910-1655
fix: route framework default through registry (Gap 1 of #5016)
2026-09-14 20:11:54 +00:00
praisonai-triage-agent[bot]andMervin Praison 49ac8854e2 fix: fall back to default registry in _build_yaml_workflow when none injected
_build_yaml_workflow referenced self._adapter_registry unconditionally,
raising AttributeError when an AgentsGenerator is constructed without the
injected registry (e.g. via __new__ in tests, or any path bypassing
__init__). Resolve the registry once via the existing safe accessor
(getattr(...) or _get_default_adapter_registry()) so build-time framework
resolution and validation still honour an injected/tenant-scoped registry
but transparently fall back to the process default otherwise.

Fixes CI: test_workflow_yaml_cli_backend_rejected.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-14 19:22:06 +00:00
praisonai-triage-agent[bot]andMervin Praison deca91764d fix: honor explicit framework precedence + registry consistency (PR #5039 review)
- Workflow observability labels (sync + async) now resolve
  self.framework or config.get('framework') so caller-supplied
  frameworks aren't mislabeled by config-only resolution.
- _build_yaml_workflow resolves and validates the framework through
  the injected registry, keeping build-time validation consistent with
  the observability label for tenant-scoped registries.
- JobSubmitRequest.framework defaults to None so an omitted API field
  reaches the registry default selection instead of forcing praisonai.
- Executor resolves the default framework via asyncio.to_thread to keep
  registry entry-point discovery off the event loop.
- Add unit tests for resolve_or_default and registry-backed
  framework_from_config.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-14 19:19:43 +00:00
praisonai-triage-agent[bot]andMervin Praison c479bf1594 fix: route framework default through registry as single source of truth (fixes #5016)
Gap 1: adds FrameworkAdapterRegistry.resolve_or_default() and removes the
scattered `or "praisonai"` literals in agents_generator, jobs, scheduler, and
workflow_framework. Workflow observability sessions now tag the config-resolved
framework instead of a hardcoded name. Backward-compatible.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-14 19:19:15 +00:00
praisonai-triage-agent[bot] 9541cf7fc2 Merge pull request #5090 from MervinPraison/fix/framework-registry-defaults-5022
fix(framework): honor registry defaults in agents generator (internal, from #5022)
2026-09-14 19:17:08 +00:00
MervinPraison 578f9de97e docs: auto-update documentation parity trackers [skip ci] 2026-09-14 19:00:06 +00:00
praisonai-triage-agent[bot] be84084605 Merge pull request #5005 from MervinPraison/claude/issue-4984-20260908-1945
fix: relocate live _run_async_in_sync_context out of deprecated mixin
2026-09-14 18:59:20 +00:00
praisonai-triage-agent[bot]andMervin Praison ccc195fda8 test: cover relocated run_async_in_sync_context behaviour (#4984)
Add minimal unit tests for the sync/async bridge relocated to
async_safety: no-loop -> asyncio.run, running-loop -> dedicated thread,
exception propagation, deprecated mixin shim delegation, and the
tool_execution coroutine-resolution guard.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-14 18:21:10 +00:00
dajiaohuang d33d6cec3e fix(framework): honor registry defaults in agents generator 2026-09-14 19:18:48 +01:00
Mervin Praison 929a07d90d Merge pull request #5083 from MervinPraison/claude/issue-5076-20260914-0945
fix: close per-request agent clones in HTTP invoke API
2026-09-14 19:18:27 +01:00
MervinPraison b3bf0a1476 docs: auto-update documentation parity trackers [skip ci] 2026-09-14 18:04:17 +00:00
praisonai-triage-agent[bot] 9ed86ea542 Merge pull request #5089 from MervinPraison/claude/issue-5088-20260914-1713
fix: lazy-load gateway/__init__.py to defer protocols.py parse
2026-09-14 18:03:31 +00:00
praisonai-triage-agent[bot]andMervin Praison bd005ede95 fix: bind lazy gateway exports into globals + add __dir__ (fixes #5088 CI)
Lazy __getattr__ served symbols only from a private cache, so resolved
names never became real module attributes. This broke eager-import
parity: dir() omitted the API and monkeypatch.delattr(gw, ...) raised
AttributeError, failing test_check_code_skew_uses_cached_predicate_not_fresh_import.

Now __getattr__ writes each resolved symbol into globals() (real,
deletable, patchable, discoverable), and a __dir__ exposes __all__ for
IDE/autocomplete without importing protocols.py. Cold-import boundary is
preserved: protocols.py is still parsed only on first protocol touch.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-14 17:25:36 +00:00
praisonai-triage-agent[bot]andMervinPraison 1c11106b2c fix: lazy-load gateway/__init__.py to defer protocols.py parse (fixes #5088)
Replace the eager `from .protocols/.liveness/.degraded_state/.hooks/.config
import (...)` blocks with a symbol->submodule map driven by the SDK's
existing `create_lazy_getattr`, chained from the module's existing
`__getattr__` (which already lazy-loads WebSocketGateway/GatewaySession).

protocols.py (7,201 lines / 283 KB) is now parsed only when a protocol/policy
symbol is first touched; config-only access loads only gateway/config.py.
All public import paths, __all__, and behaviour are unchanged; mirrors the
mcp/skills packages per AGENTS.md §4.2.

Co-authored-by: MervinPraison <[email protected]>
2026-09-14 17:17:28 +00:00
praisonai-triage-agent[bot]andMervin Praison 49ec2d76eb fix: release LLM client in aclose() and derive clone-ownership race-free (#5076)
Reviewer fixes for PR #5083:
- Agent.aclose() now tears down a materialised LLM client (async-preferred),
  mirroring close(); previously aclose() marked _closed without releasing the
  client, so per-request invoke clones (which prefer aclose) leaked pools.
  Avoids lazy client creation by only touching an existing _llm_instance.
- _safe_close_agent derives clone ownership from _supports_session_isolation()
  on the resolved agent instead of comparing against a second, independently
  timed registry lookup — race-free under concurrent registry replacement.
  Drops the now-unused template argument from both invoke paths.
- Tests: add FastAPI route-level clone cleanup coverage (success/failure/shared
  template) and Agent.aclose() LLM-client teardown regression tests.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-09-14 09:59:09 +00:00
praisonai-triage-agent[bot]andDhivya-Bharathy e35f672d6c fix: close per-request agent clones in HTTP invoke API (fixes #5076)
The n8n/HTTP invoke API resolves a fresh Agent clone per request via
resolve_session_agent() but never called close()/aclose(), leaking LLM
client pools, tool-executor threads and MCP subprocesses into the
long-lived server process.

Wrap both the FastAPI route and invoke_agent_standalone in try/finally
that calls a new _safe_close_agent() helper. The helper only closes a
genuine clone (agent is not the shared registry template), prefers async
aclose(), falls back to close() via a worker thread, and swallows/logs
cleanup errors so teardown never masks a successful response.

Co-authored-by: Dhivya-Bharathy <[email protected]>
2026-09-14 09:50:23 +00:00
praisonai-triage-agent[bot]andMervin Praison e15e7f75fc fix: relocate live _run_async_in_sync_context helper out of deprecated mixin (fixes #4984)
The sync tool-calling path (tool_execution.py) depends on
_run_async_in_sync_context, which lived only inside UnifiedExecutionMixin —
a module documented as fully unused and scheduled for removal (#2644).
Following that removal plan would delete a load-bearing helper and
reintroduce a silent-data-loss bug for async tool bodies.

Relocate the helper to the stable, already-imported async_safety module as
run_async_in_sync_context; tool_execution.py now imports it from there. The
mixin keeps a thin shim so its deprecated public methods still work, and the
inaccurate "no production usage" docstring/MRO comment are corrected. No
behaviour change.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-08 19:49:14 +00:00
praisonai-triage-agent[bot]andMervin Praison a7ca8b5348 fix: pin backend adoption to the surface the tool loop actually drives
The prior widening adopted chat/achat and chat_completion/achat_completion
backends, but the custom-LLM tool loop only ever calls
llm_instance.get_response/get_response_async. Those backends were adopted at
init and then raised AttributeError on the first turn. Narrow detection to
get_response/get_response_async so adoption can be honoured, and update tests
to assert the honest contract.

Co-authored-by: Mervin Praison <[email protected]>
2026-09-08 15:52:28 +00:00
MervinPraisonandClaude Opus 5 df6c5ace0c fix: adopt every documented backend protocol, not just get_response
A follow-up to the Agent(llm=<object>) fix in #4929. Review caught that the
fix was incomplete, and it was right -- verified by running it:

    chat_completion-only backend -> self.llm=<object>     custom=False   BUG
    get_response backend         -> self.llm="custom/..." custom=True    ok

llm/protocols.py documents two backend surfaces besides get_response:
LLMProviderProtocol via chat/achat, and UnifiedLLMProtocol via
chat_completion/achat_completion. A backend implementing either still fell
through to the plain OpenAI branch, where the object became the model
identifier and the turn went to the wrong backend entirely.

This lands separately because #4929 had already merged by the time the fix
was written; it was pushed to that branch and stranded there.

5 tests including two controls: a plain object with no backend method is
still NOT adopted, and a string llm is unaffected. Mutation-checked --
2 fail when the detection is narrowed back to get_response alone.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 16:44:42 +01:00
587 changed files with 54451 additions and 5987 deletions

No files matched your search

@@ -0,0 +1,36 @@
name: Pick Claude OAuth token
description: Probe primary/secondary Claude OAuth tokens and select a working one (dual OAuth Option B).
inputs:
token_primary:
description: Primary Claude OAuth token (CLAUDE_CODE_OAUTH_TOKEN)
required: false
token_secondary:
description: Secondary Claude OAuth token (CLAUDE_CODE_OAUTH_TOKEN_B)
required: false
active_slot:
description: Preferred slot — primary or secondary (repo variable CLAUDE_OAUTH_ACTIVE)
required: false
default: primary
outputs:
token:
description: Selected OAuth token
value: ${{ steps.pick.outputs.token }}
slot:
description: Slot used (primary, secondary, or *-unprobed fallback)
value: ${{ steps.pick.outputs.slot }}
runs:
using: composite
steps:
- id: pick
shell: bash
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.token_primary }}
CLAUDE_CODE_OAUTH_TOKEN_B: ${{ inputs.token_secondary }}
CLAUDE_OAUTH_ACTIVE: ${{ inputs.active_slot }}
run: |
if [[ -n "${HUB_SCRIPTS:-}" && -f "${HUB_SCRIPTS}/oauth-pick-token.sh" ]]; then
SCRIPT="${HUB_SCRIPTS}/oauth-pick-token.sh"
else
SCRIPT="${GITHUB_ACTION_PATH}/../../scripts/oauth-pick-token.sh"
fi
bash "$SCRIPT"
+29 -1
View File
@@ -16,6 +16,9 @@ const PR_MAX_AUTO_ADDITIONS = 800;
const PR_MAX_AUTO_FILES = 30;
const MANUAL_ONLY_LABELS = new Set(['security', 'breaking-change', 'needs-manual-review', 'release']);
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 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/';
@@ -328,7 +331,7 @@ function finalClaudeCompletedOnSha(comments, headPushedAt) {
}
const FINAL_CLAUDE_REVIEW_BODY =
'@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: 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/\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.\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';
async function getMergeState(github, owner, repo, prNumber) {
const query = `
@@ -583,6 +586,11 @@ function isCiOnlyChange(files) {
return files.every((f) => CI_ONLY_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
}
function isExternalExampleOnlyChange(files) {
if (!files.length) return false;
return files.every((f) => f.filename.startsWith(EXTERNAL_EXAMPLE_PATH_PREFIX));
}
function isInternalPullRequestLink(link, owner, repo) {
if (link?.base?.repo?.full_name === `${owner}/${repo}`) return true;
const baseUrl = link?.base?.repo?.url || '';
@@ -973,6 +981,15 @@ async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, op
const pullFiles = await listPullFiles(github, owner, repo, prNumber);
if (
isExternalExampleOnlyChange(pullFiles) &&
!ctx.labels.includes(MAINTAINER_ACCEPT_EXAMPLE_LABEL)
) {
reasons.push(
'external example only — belongs in PraisonAI-Tools (add maintainer-accept-example to opt in)'
);
}
const agentChange = getAgentPyChangeFromFiles(pullFiles);
const agentManual = manualReviewReasonForAgentPy(agentChange);
if (agentManual) reasons.push(agentManual);
@@ -1019,6 +1036,13 @@ async function listPrNumbersForMergeGateScan(github, owner, repo, core) {
}
const mergeReady = mergeReadyIssues
.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;
}
return true;
})
.map((issue) => issue.number)
.sort((a, b) => a - b);
@@ -1146,6 +1170,10 @@ module.exports = {
claudeRunBlocksPr,
hasBlockingClaudeRunForPr,
isCiOnlyChange,
isExternalExampleOnlyChange,
EXTERNAL_EXAMPLE_PATH_PREFIX,
EXTERNAL_EXAMPLE_LABEL,
MAINTAINER_ACCEPT_EXAMPLE_LABEL,
isInternalPullRequestLink,
resolvePrNumberFromLinkedPullRequests,
resolvePrNumberFromHeadBranch,
+101
View File
@@ -0,0 +1,101 @@
#!/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
# Out: GITHUB_OUTPUT token, slot
set -euo pipefail
PRIMARY="${CLAUDE_CODE_OAUTH_TOKEN:-}"
SECONDARY="${CLAUDE_CODE_OAUTH_TOKEN_B:-}"
ACTIVE="${CLAUDE_OAUTH_ACTIVE:-primary}"
ACTIVE_LC=$(echo "$ACTIVE" | tr '[:upper:]' '[:lower:]')
case "$ACTIVE_LC" in
secondary|b|2) PREFERRED=secondary ;;
*) PREFERRED=primary ;;
esac
probe_token() {
local token=$1
[[ -z "$token" ]] && return 1
local body='{"model":"claude-3-5-haiku-latest","max_tokens":1,"messages":[{"role":"user","content":"."}]}'
local tmp http_code body_resp
tmp=$(mktemp)
http_code=$(curl -sS -o "$tmp" -w "%{http_code}" \
-X POST "https://api.anthropic.com/v1/messages" \
-H "Authorization: Bearer ${token}" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d "$body" 2>/dev/null || echo "000")
body_resp=$(cat "$tmp")
rm -f "$tmp"
if [[ "$http_code" == "200" ]] || [[ "$http_code" == "400" ]]; then
return 0
fi
if [[ "$http_code" == "401" ]] || [[ "$http_code" == "429" ]]; then
echo "::warning::OAuth probe failed (HTTP ${http_code})"
return 1
fi
if echo "$body_resp" | grep -qiE 'revoked|invalid.*token|authentication|weekly limit|rate_limit|overloaded|billing'; then
echo "::warning::OAuth probe rejected (HTTP ${http_code})"
return 1
fi
return 0
}
try_token() {
local slot=$1 token=$2
[[ -z "$token" ]] && return 1
if probe_token "$token"; then
SELECTED_SLOT=$slot
SELECTED_TOKEN=$token
return 0
fi
return 1
}
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
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
echo "::error::No Claude OAuth token configured"
exit 1
fi
echo "::warning::OAuth probe failed for all tokens; using ${SELECTED_SLOT}"
fi
echo "::notice::Claude OAuth slot: ${SELECTED_SLOT}"
if [[ -z "${GITHUB_OUTPUT:-}" ]]; then
echo "slot=${SELECTED_SLOT}"
exit 0
fi
{
echo "token<<EOF"
echo "$SELECTED_TOKEN"
echo "EOF"
echo "slot=${SELECTED_SLOT}"
} >> "$GITHUB_OUTPUT"
@@ -73,4 +73,33 @@ assert(
)
);
assert(
'external example only path',
mg.isExternalExampleOnlyChange([{ filename: 'examples/tools/external/arxiv/tool.py' }])
);
assert(
'mixed paths not external-example only',
!mg.isExternalExampleOnlyChange([
{ filename: 'examples/tools/external/foo.py' },
{ filename: 'src/praisonai-agents/foo.py' },
])
);
assert(
'upstream head repo',
ps.isUpstreamHeadRepo(
{ head: { repo: { full_name: 'MervinPraison/PraisonAI' } } },
'MervinPraison',
'PraisonAI'
)
);
assert(
'fork head repo still synced (not upstream)',
!ps.isUpstreamHeadRepo(
{ head: { repo: { full_name: 'ai-mrscraper/PraisonAI' } } },
'MervinPraison',
'PraisonAI'
)
);
process.exit(failed ? 1 : 0);
+25 -5
View File
@@ -37,8 +37,17 @@ 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/external-example',
color: 'bfd4f2',
description: 'Diff only under examples/tools/external/ — not merge-ready without maintainer-accept-example',
},
];
// PRs with pipeline/external-example cannot reach pipeline/merge-ready unless
// maintainers add maintainer-accept-example (enforced in merge-gate evaluatePipelineQuiescent,
// same fail-closed pattern as fork PRs).
function deriveStage(comments, evalResult) {
if (evalResult.ready) return 'pipeline/merge-ready';
if (!chain.chainKickPosted(comments)) return 'pipeline/reviews-pending';
@@ -63,7 +72,8 @@ function reasonToBlockerLabel(reason) {
r.includes('without test') ||
r.includes('files changed') ||
r.includes('possible secret') ||
r.includes('agent.py')
r.includes('agent.py') ||
r.includes('external example only')
) return 'pipeline/blocked:manual-review';
if (r.includes('recent @claude') || r.includes('post-push buffer')) return 'pipeline/blocked:cooldown';
if (r.includes('stale final')) return 'pipeline/blocked:stale-final';
@@ -133,11 +143,16 @@ async function syncPipelineLabels(github, owner, repo, prNumber, core) {
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 labelSet = [...all, ...auxiliary];
const current = ctx.labels.filter((l) => l.startsWith(PIPELINE_PREFIX));
const desired = new Set(all);
const desired = new Set(labelSet);
const toRemove = current.filter((l) => !desired.has(l) && l !== 'pipeline/merged');
const toAdd = all.filter((l) => !current.includes(l));
const toAdd = labelSet.filter((l) => !current.includes(l));
for (const name of toRemove) {
try {
@@ -199,6 +214,11 @@ async function dispatchMergeGateForOldestReady(github, owner, repo, readyCandida
return 0;
}
function isUpstreamHeadRepo(pr, owner, repo) {
const headFull = pr.head?.repo?.full_name;
return !headFull || headFull === `${owner}/${repo}`;
}
async function syncOpenPullRequests(github, owner, repo, options, core) {
const { maxPrs = 20, dispatchMergeGate = true } = options || {};
await ensurePipelineLabels(github, owner, repo, core);
@@ -223,9 +243,8 @@ async function syncOpenPullRequests(github, owner, repo, options, core) {
for (const pr of prs) {
if (synced >= maxPrs) break;
if (pr.draft) continue;
if (pr.head?.repo?.full_name && pr.head.repo.full_name !== `${owner}/${repo}`) continue;
const result = await syncPipelineLabels(github, owner, repo, pr.number, core);
if (result.ready) {
if (result.ready && isUpstreamHeadRepo(pr, owner, repo)) {
readyCandidates.push({
prNumber: pr.number,
createdAt: result.createdAt || new Date(pr.created_at).getTime(),
@@ -248,6 +267,7 @@ module.exports = {
STAGE_LABELS,
BLOCKER_LABELS,
ALL_PIPELINE_LABELS,
isUpstreamHeadRepo,
deriveStage,
deriveBlockerLabels,
computePipelineLabels,
-59
View File
@@ -1,59 +0,0 @@
# Why this exists
#
# praisonai-agents has ~11,900 tests. Until this gate, CI executed 15 of them:
# one smoke step in test-optimized.yml naming two files. Everything else was
# collected by nobody.
#
# The cost of that showed up as two test modules that could not even be
# IMPORTED -- they referenced symbols moved or replaced months earlier, so 19
# tests silently contributed nothing while still appearing to exist. A failing
# test is loud; a test that never collects is not.
#
# This job does not run the suite. Running all 11,900 on every PR is a real
# decision about Actions compute, and it would go red on arrival because there
# are pre-existing failures nothing has been enforcing. A job that is red the
# day it lands teaches people to ignore it.
#
# Collection alone takes ~40s, costs almost nothing, and would have caught both
# dead modules the day they broke. It is the honest ratchet: it cannot pass
# while a test file is unimportable.
name: agents-collect-gate
on:
push:
branches: [ main ]
paths:
- 'src/praisonai-agents/**'
- '.github/workflows/agents-collect-gate.yml'
pull_request:
paths:
- 'src/praisonai-agents/**'
- '.github/workflows/agents-collect-gate.yml'
jobs:
collect:
name: every praisonai-agents test file imports
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install
run: |
set -euo pipefail
python -m pip install --upgrade pip
pip install -e src/praisonai-agents
pip install pytest pytest-asyncio pytest-timeout
- name: Collect the whole suite
working-directory: src/praisonai-agents
env:
# A key must be present or some modules bail at import; it is never
# used, because collection does not execute test bodies.
OPENAI_API_KEY: 'sk-collect-only-not-a-real-key'
run: |
set -euo pipefail
# No pipe here: piping pytest into tail discards its exit code, which
# is how a gate silently stops gating.
python -m pytest tests/ --collect-only -q --disable-warnings
+142
View File
@@ -0,0 +1,142 @@
# Why this exists
#
# praisonai-agents has ~11,900 tests. Until this gate, CI executed 15 of them:
# one smoke step in test-optimized.yml naming two files. Everything else was
# collected by nobody.
#
# The cost of that showed up as two test modules that could not even be
# IMPORTED -- they referenced symbols moved or replaced months earlier, so 19
# tests silently contributed nothing while still appearing to exist. A failing
# test is loud; a test that never collects is not.
#
# This job started as collection-only, because running the suite would have gone
# red on arrival: 297 pre-existing failures nothing had been enforcing, and a job
# that is red the day it lands teaches people to ignore it. Then a curated
# green-set, ratcheted upward.
#
# Both are gone. The unit suite now runs in full and passes: 9373 tests, 0
# failures. The `collect` job stays because it is the cheaper, stricter signal --
# it cannot pass while a test file is unimportable, and it says so in 40s.
#
name: agents-suite
on:
push:
branches: [ main ]
paths:
- 'src/praisonai-agents/**'
- '.github/workflows/agents-suite.yml'
pull_request:
paths:
- 'src/praisonai-agents/**'
- '.github/workflows/agents-suite.yml'
jobs:
collect:
name: every praisonai-agents test file imports
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install
run: |
set -euo pipefail
python -m pip install --upgrade pip
pip install -e src/praisonai-agents
pip install pytest pytest-asyncio pytest-timeout
- name: Collect the whole suite
working-directory: src/praisonai-agents
env:
# A key must be present or some modules bail at import; it is never
# used, because collection does not execute test bodies.
OPENAI_API_KEY: 'sk-collect-only-not-a-real-key'
run: |
set -euo pipefail
# No pipe here: piping pytest into tail discards its exit code, which
# is how a gate silently stops gating.
python -m pytest tests/ --collect-only -q --disable-warnings
unit:
name: praisonai-agents unit tests
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install
run: |
set -euo pipefail
python -m pip install --upgrade pip
# Extras, not decoration. Tests across workflows/embedding/eval/context
# patch litellm and construct LLM(...), and the knowledge suite needs
# chonkie for chunking -- without [knowledge] 22 tests fail on
# "chonkie package not found", an install gap that reads as 22 code
# failures.
pip install -e "src/praisonai-agents[llm,knowledge]"
# praisonai-sandbox is a MONOREPO sibling, not a third-party optional.
# Without it 47 tests fail with "No module named
# praisonai_sandbox.compute" -- an install gap reported as 47 code
# failures. Installing it took the suite from 297 failures to 250.
pip install -e src/praisonai-sandbox
# Same story: a stale praisonai-bot in the environment made 22 gateway
# tests fail on a malformed pairing file. The monorepo version already
# handles it; the INSTALLED one did not.
pip install -e src/praisonai-bot
# The wrapper. tests/unit/test_managed_backend.py imports
# praisonai.integrations.managed_agents; without it 47 tests fail with
# "No module named 'praisonai'". Found by running the suite on 3.11 in
# 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
- name: Run the unit suite
working-directory: src/praisonai-agents
env:
# Never used: PRAISONAI_ALLOW_NETWORK=0 and the live marker keep these
# offline. Present because some modules bail at import without a key.
OPENAI_API_KEY: 'sk-not-a-real-key'
PRAISONAI_ALLOW_NETWORK: '0'
run: |
set -euo pipefail
# The whole suite, not a curated subset. tests/green-set.txt and
# tests/known-failures.txt are gone: 9373 pass, 0 fail.
#
# The 297 failures this job was built around were, in the end:
# 83 environment -- monorepo siblings not installed, dirty HOME
# 19 ONE line -- a module-level os.environ.setdefault in
# tests/unit/tools/test_delegation_tools.py that
# turned auto-approval on for the whole process
# at COLLECTION time
# ~5 deprecation warnings that fire once per process
# ~85 tests asserting contracts the product had deliberately changed
# 5 real product bugs
# the rest: tests built on Mocks whose auto-created attributes
# defeated the very code path under test
#
# Three traps worth knowing before adding a test here, each of which
# cost a full-suite bisect:
# 1. Module-level env mutation persists for the entire process, and
# runs at collection. Use monkeypatch.
# 2. The approval registry is a process-global singleton. Anything
# you add_requirement() stays for every later test.
# 3. `'litellm' in module_name` also matches praisonai's own
# _litellm_loader; evicting it from sys.modules creates a second
# module object with separate globals.
#
# No pipe: piping pytest into anything discards its exit code, which is
# how a gate silently stops gating.
#
# Nothing is excluded. test_prefetch.py used to be, recorded as "passes
# on macOS, fails on Linux" -- wrong variable. It was Python 3.12 vs the
# 3.11 pinned above: isinstance() against a runtime_checkable Protocol
# used hasattr on 3.11 (which any MagicMock satisfies) and
# inspect.getattr_static from 3.12 (which does not see Mock's
# auto-created attributes). The mock now names its methods, so both
# versions take the same branch.
python -m pytest tests/unit/ -q -p no:randomly --timeout=120 \
--disable-warnings
+7 -7
View File
@@ -53,7 +53,7 @@ jobs:
- name: Post Claude FINAL after prior reviewers
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
github-token: ${{ secrets.GH_TOKEN != '' && secrets.GH_TOKEN || github.token }}
script: |
const path = require('path');
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
@@ -97,7 +97,7 @@ jobs:
- name: Post Claude FINAL (skip Copilot)
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
github-token: ${{ secrets.GH_TOKEN != '' && secrets.GH_TOKEN || github.token }}
script: |
const path = require('path');
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
@@ -126,7 +126,7 @@ jobs:
- name: Re-post FINAL Claude review when stale or missing
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
github-token: ${{ secrets.GH_TOKEN != '' && secrets.GH_TOKEN || github.token }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
@@ -182,7 +182,7 @@ jobs:
- name: Sync pipeline status labels
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
github-token: ${{ secrets.GH_TOKEN != '' && secrets.GH_TOKEN || github.token }}
script: |
const path = require('path');
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
@@ -203,7 +203,7 @@ jobs:
- name: Post missing/stale FINAL @claude on open PRs
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
github-token: ${{ secrets.GH_TOKEN != '' && secrets.GH_TOKEN || github.token }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
@@ -275,7 +275,7 @@ jobs:
- name: Trigger CodeRabbit and Qodo reviews
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
github-token: ${{ secrets.GH_TOKEN != '' && secrets.GH_TOKEN || github.token }}
script: |
const path = require('path');
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
@@ -285,7 +285,7 @@ jobs:
- name: Sync pipeline status labels
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
github-token: ${{ secrets.GH_TOKEN != '' && secrets.GH_TOKEN || github.token }}
script: |
const path = require('path');
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
+9 -1
View File
@@ -28,8 +28,16 @@ jobs:
with:
token: ${{ steps.app-token.outputs.token }}
- name: Pick Claude OAuth token
id: oauth
uses: MervinPraison/github-automation-template/.github/actions/[email protected]
with:
token_primary: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
token_secondary: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_B }}
active_slot: ${{ vars.CLAUDE_OAUTH_ACTIVE != '' && vars.CLAUDE_OAUTH_ACTIVE || 'primary' }}
- name: Run Claude Issue Triage
uses: ./.github/actions/claude-issue-triage-action
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_code_oauth_token: ${{ steps.oauth.outputs.token }}
github_token: ${{ steps.app-token.outputs.token }}
+9 -1
View File
@@ -267,6 +267,14 @@ jobs:
- name: Stash merge gate helpers
run: cp .github/scripts/merge-gate.js /tmp/merge-gate.js
- name: Pick Claude OAuth token
id: oauth
uses: MervinPraison/github-automation-template/.github/actions/[email protected]
with:
token_primary: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
token_secondary: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_B }}
active_slot: ${{ vars.CLAUDE_OAUTH_ACTIVE != '' && vars.CLAUDE_OAUTH_ACTIVE || 'primary' }}
- name: Fetch PR branch
env:
PR_NUMBER: ${{ matrix.pr_number }}
@@ -283,7 +291,7 @@ jobs:
CLAUDE_CODE_SUBAGENT_MODEL: inherit
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_code_oauth_token: ${{ steps.oauth.outputs.token }}
github_token: ${{ steps.app-token.outputs.token }}
model: claude-opus-4-8
trigger_phrase: '@claude-merge-gate-internal'
+9 -1
View File
@@ -249,6 +249,14 @@ jobs:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- name: Pick Claude OAuth token
id: oauth
uses: MervinPraison/github-automation-template/.github/actions/[email protected]
with:
token_primary: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
token_secondary: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN_B }}
active_slot: ${{ vars.CLAUDE_OAUTH_ACTIVE != '' && vars.CLAUDE_OAUTH_ACTIVE || 'primary' }}
- name: Fetch PR branch and Setup Remote
if: steps.check_fork.outputs.is_pr == 'true'
env:
@@ -275,7 +283,7 @@ jobs:
GH_TOKEN: ${{ steps.check_fork.outputs.can_push_fork == 'true' && secrets.GH_TOKEN || steps.app-token.outputs.token }}
with:
allowed_bots: 'praisonai-triage-agent[bot]'
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_code_oauth_token: ${{ steps.oauth.outputs.token }}
github_token: ${{ steps.app-token.outputs.token }}
model: claude-opus-4-8
trigger_phrase: "@claude"
+8 -5
View File
@@ -21,6 +21,9 @@ on:
- '.github/actions/install-monorepo-packages/**'
workflow_dispatch:
permissions:
contents: read
jobs:
cross-repo-integration:
runs-on: ubuntu-latest
@@ -41,13 +44,13 @@ jobs:
ref: main
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: python -m pip install --upgrade pip
- uses: PraisonAI/.github/actions/install-monorepo-packages
- uses: ./PraisonAI/.github/actions/install-monorepo-packages
with:
repo-root: PraisonAI/
wrapper-extras: ".[ui,dev]"
@@ -138,13 +141,13 @@ jobs:
ref: main
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: python -m pip install --upgrade pip
- uses: PraisonAI/.github/actions/install-monorepo-packages
- uses: ./PraisonAI/.github/actions/install-monorepo-packages
with:
repo-root: PraisonAI/
wrapper-extras: ".[ui,dev]"
@@ -162,4 +165,4 @@ jobs:
python -m pytest tests/integration/test_aiui_host_agentic.py -v
else
echo "OPENAI_API_KEY not available, skipping agentic tests"
fi
fi
+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
@@ -1,6 +1,6 @@
name: Pipeline Status Sync
# Sync pipeline/* stage and blocker labels on open internal PRs.
# Sync pipeline/* stage and blocker labels on open PRs (fork heads included; merge gate only for upstream branches).
on:
schedule:
+26 -2
View File
@@ -16,8 +16,6 @@ jobs:
review:
runs-on: ubuntu-latest
if: >
secrets.PRAISONAI_APP_ID != '' &&
secrets.PRAISONAI_APP_PRIVATE_KEY != '' &&
(
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
github.event_name == 'workflow_dispatch' ||
@@ -32,7 +30,28 @@ jobs:
)
steps:
- name: Check reviewer configuration
id: config
env:
HAS_APP_CREDENTIALS: ${{ secrets.PRAISONAI_APP_ID != '' && secrets.PRAISONAI_APP_PRIVATE_KEY != '' }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
echo "configured=$HAS_APP_CREDENTIALS" >> "$GITHUB_OUTPUT"
openai_ready=true
if [ -z "$OPENAI_API_KEY" ] || [ "$OPENAI_API_KEY" = "not-needed" ]; then
openai_ready=false
if [ "$HAS_APP_CREDENTIALS" = "true" ]; then
echo "::notice::Skipping PraisonAI PR review: OPENAI_API_KEY is missing or a placeholder."
fi
fi
if [ "$HAS_APP_CREDENTIALS" = "true" ] && [ "$openai_ready" = "true" ]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
else
echo "ready=false" >> "$GITHUB_OUTPUT"
fi
- name: Determine checkout ref
if: steps.config.outputs.ready == 'true'
id: dest
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
@@ -48,6 +67,7 @@ jobs:
fi
- name: Checkout Repository
if: steps.config.outputs.ready == 'true'
uses: actions/checkout@v4
with:
persist-credentials: false
@@ -55,6 +75,7 @@ jobs:
fetch-depth: 0
- name: Generate GitHub App Token
if: steps.config.outputs.ready == 'true'
id: generate_token
uses: tibdex/github-app-token@v2
with:
@@ -62,14 +83,17 @@ jobs:
private_key: ${{ secrets.PRAISONAI_APP_PRIVATE_KEY }}
- name: Set up Python
if: steps.config.outputs.ready == 'true'
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install PraisonAI
if: steps.config.outputs.ready == 'true'
run: pip install "praisonai[all]"
- name: Run PraisonAI PR Review
if: steps.config.outputs.ready == 'true'
env:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+5 -1
View File
@@ -23,6 +23,7 @@ jobs:
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 }}
GH_TOKEN_VALUE: ${{ secrets.GH_TOKEN }}
DOCS_GITHUB_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
run: |
@@ -34,8 +35,11 @@ jobs:
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"
echo -n "$CLAUDE_CODE_OAUTH_TOKEN" | gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo "$TARGET"
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
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, GH_TOKEN → $TARGET"
echo "Synced CLAUDE_APP_ID, CLAUDE_APP_PRIVATE_KEY, CLAUDE_CODE_OAUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN_B (if set), GH_TOKEN → $TARGET"
+56
View File
@@ -31,6 +31,13 @@ env:
PRAISONAI_ALLOW_NETWORK: '0'
PRAISONAI_LIVE_TESTS: '0'
PRAISONAI_TEST_TIER: main
# litellm reads this at import and, without it, downloads its model-price
# table from raw.githubusercontent.com before a single test runs -- which
# made the suite depend on that host being reachable. It has to be set in
# the environment rather than a conftest, because pytest imports plugins
# (and litellm with them) before any conftest is loaded.
LITELLM_LOCAL_MODEL_COST_MAP: 'True'
HF_HUB_OFFLINE: '1'
jobs:
test-core-collect:
@@ -109,6 +116,55 @@ jobs:
extra_ignore: ""
pythonpath: >-
${{ github.workspace }}/src/praisonai-agents:${{ github.workspace }}/src/praisonai-code
- shard: agents-core
workdir: src/praisonai-agents
# src/praisonai-agents/tests holds 727 files and 11,437 test
# functions, and until now NO workflow referenced the directory at
# all -- so the LLM layer, the provider adapters, the local-model
# package and the streaming paths were guarded by tests that never
# ran. Nearly every defect found in the recent local-model audits
# had a working test sitting unused beside it.
#
# The ten directories originally left out of this shard (knowledge,
# runtime, session, tools, bus, plugins, skills, agents, rag,
# streaming -- about 52 failures) have since been repaired, so they
# are all listed below: 1,567 tests in the seven added here alone.
# Leaving them out would have meant the repairs were never exercised.
#
# The whole tree in one invocation was hanging. The cause was NOT
# the pytest-rerunfailures run_server thread guessed at here
# earlier: it is test_botos_integration.py, where a supervised
# Bot.start() deliberately never returns and the mock adapter
# reports is_running=True forever (fixed on this branch). Naming
# directories explicitly is still the safer shape for a shard.
paths: >-
tests/unit/agent/ tests/unit/app/ tests/unit/approval/
tests/unit/auth/ tests/unit/background/ tests/unit/checkpoints/
tests/unit/cli/ tests/unit/compaction/ tests/unit/conditions/
tests/unit/config/ tests/unit/context/ tests/unit/embedding/
tests/unit/eval/ tests/unit/gateway/ tests/unit/goal/
tests/unit/hooks/ tests/unit/llm/ tests/unit/lsp/ tests/unit/mcp/
tests/unit/memory/ tests/unit/permissions/ tests/unit/policy/
tests/unit/process/ tests/unit/runs/ tests/unit/sandbox/
tests/unit/security/ tests/unit/server/ tests/unit/snapshot/
tests/unit/storage/ tests/unit/task/ tests/unit/telemetry/
tests/unit/trace/ tests/unit/utils/ tests/unit/workflows/
tests/unit/workspace/ tests/unit/knowledge/ tests/unit/session/
tests/unit/plugins/ tests/unit/runtime/ tests/unit/tools/
tests/unit/bus/ tests/unit/skills/ tests/unit/agents/
tests/unit/rag/ tests/unit/streaming/
tests/unit/test_adapter_registry.py
# Both tests previously deselected here now pass in combination.
# test_lazy_litellm_import was looking the loader module up by
# name and could get a different object than the one get_litellm()
# mutates; it reads the function's own __globals__ now.
# test_skills_discovery_uses_paths_module read the real home
# directory and asserted every discovered dir was named "skills",
# which is false once a remote skill has ever been cached; it runs
# against a temp HOME and asserts the paths module is used.
extra_ignore: ""
pythonpath: >-
${{ github.workspace }}/src/praisonai-agents
- shard: train
workdir: src/praisonai-train
# tests/unit/data/ (96 tests) was collected by no workflow at all.
+4 -2
View File
@@ -42,7 +42,7 @@ jobs:
# ============================================
smoke:
runs-on: ubuntu-latest
timeout-minutes: 12
timeout-minutes: 16
steps:
- uses: actions/checkout@v4
with:
@@ -773,7 +773,9 @@ jobs:
env:
# Bump deliberately: this is the version everything in this job was
# verified against. Latest at time of writing is v0.33.3.
OLLAMA_VERSION: v0.33.2
# No leading "v": install.sh passes ?version=$OLLAMA_VERSION and ollama.com
# redirects to GitHub tag v$version (vv0.33.2 → 404 if set to v0.33.2).
OLLAMA_VERSION: "0.33.2"
# 522 MB, capabilities: completion, tools, thinking.
OLLAMA_TEST_MODEL: qwen3:0.6b
+1
View File
@@ -122,3 +122,4 @@ jobs/*
# Local Android SDK/NDK/JDK for building praisonai-mobile on a laptop without
# Android Studio (how: .github/workflows/mobile-platforms.yml). Several GB.
.android-sdk/
.worktrees/
-1
View File
@@ -12,7 +12,6 @@
<a href="https://github.com/MervinPraison/PraisonAI"><img src="https://static.pepy.tech/badge/PraisonAI" alt="Total Downloads" /></a>
<a href="https://github.com/MervinPraison/PraisonAI"><img src="https://img.shields.io/github/v/release/MervinPraison/PraisonAI" alt="Latest Stable Version" /></a>
<a href="https://github.com/MervinPraison/PraisonAI"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License" /></a>
<a href="https://registry.modelcontextprotocol.io/servers/io.github.MervinPraison/praisonai"><img src="https://img.shields.io/badge/MCP-Registry-blue" alt="MCP Registry" /></a>
</p>
<div align="center">
+4 -3
View File
@@ -776,7 +776,7 @@ Methods:
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">wait_for_completions</a>(timeout: Optional[float] = None, agent_ids: Optional[List[str]] = None) -> List[SubAgentCompletionEvent]</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">where_does_it_run</a>() -> str</code>
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval</a>(request: ApprovalRequest) -> ApprovalDecision</code>
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval_sync</a>(request: ApprovalRequest) -> ApprovalDecision</code>
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval_sync</a>(request: ApprovalRequest, timeout: Optional[float] = None) -> ApprovalDecision</code>
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">completed</a>(output: Optional[str] = None) -> 'RunOutcome'</code>
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">from_exception</a>(exc: BaseException, output: Optional[str] = None) -> 'RunOutcome'</code>
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">succeeded</a>() -> bool</code>
@@ -1018,7 +1018,8 @@ export { // Bot types
FailoverManager, // Enums
SandboxStatus, // Gateway types
type GatewayConfig, // Other types
type ProviderStatus, AutonomyLevel, GatewayEventType, RagRetrievalPolicy } from "./gateway";
type ProviderStatus, // Session-projection reducer (Issue #5324)
SessionProjection, AutonomyLevel, GatewayEventType, RagRetrievalPolicy } from "./gateway";
export { LLMGuardrail, createLLMGuardrail } from "./guardrails/llm-guardrail";
export { DisplayTypes, HooksManager, WorkflowHooksExecutor, clearAllCallbacks, clearApprovalCallback, createHooksManager, createLoggingOperationHooks, createLoggingWorkflowHooks, createTimingWorkflowHooks, createValidationOperationHooks, createWorkflowHooks, executeCallback, executeSyncCallback, getRegisteredDisplayTypes, hasApprovalCallback, registerApprovalCallback, registerDisplayCallback, requestApproval, unregisterDisplayCallback } from "./hooks";
export { // Computer Use
@@ -1056,7 +1057,7 @@ export type { AsyncLearnProtocol, BaseStoreOptions, LearnEntryConvertible, Learn
export { Memory, createMemory } from "./memory/memory";
export type { MemoryConfig, MemoryEntry } from "./memory/memory";
export { RulesManager, createRulesManager, createSafetyRules } from "./memory/rules-manager";
export { DEFAULT_PROFILE, HarnessProfile, listHarnessProfiles, registerProfile, register_profile, resetHarnessRegistry, resolveHarness, resolve_harness } from "./model-harness";
export { DEFAULT_PROFILE, HarnessProfile, ModelRequestBlocked, ScriptExhausted, ScriptedModel, allowModelRequests, listHarnessProfiles, modelRequestsAllowed, noModelRequests, registerProfile, register_profile, resetHarnessRegistry, resolveHarness, resolve_harness } from "./model-harness";
export type { HarnessProfileConfig, HarnessRegistryEntry, HarnessResolverProtocol } from "./model-harness";
export { // Adapters
NoopObservabilityAdapter, // Constants
+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.8" \
"praisonai>=4.7.12" \
"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.8" \
"praisonai>=4.7.12" \
"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.8" \
"praisonai>=4.7.12" \
"praisonai[ui]" \
"praisonai[crewai]"
+1 -1
View File
@@ -140,4 +140,4 @@ Key fixes applied:
4. String-based LLM guardrails are also supported
The guardrail will automatically retry (up to max_retries times) if validation fails.
""")
""")
+28
View File
@@ -0,0 +1,28 @@
"""OpenRouter video generation via VideoAgent.
Requires OPENROUTER_API_KEY. Optionally set OPENROUTER_REFERER and
OPENROUTER_APP_TITLE for OpenRouter dashboard attribution.
The openrouter/ prefix is required so the request routes to OpenRouter.
Discover exact model ids at https://openrouter.ai/api/v1/videos/models.
"""
from praisonaiagents import VideoAgent
agent = VideoAgent(llm="openrouter/google/veo-3.1")
video = agent.generate(
"A serene mountain landscape at sunset, locked camera",
duration=6,
resolution="720p",
aspect_ratio="16:9",
generate_audio=False,
)
print(f"Video ID: {video.id}")
completed = agent.wait_for_completion(video.id)
if completed.status == "completed":
agent.download(video.id, "openrouter_veo.mp4")
print("Saved openrouter_veo.mp4")
else:
print(f"Generation ended with status: {completed.status}")
+3 -4
View File
@@ -27,11 +27,10 @@ mcp:
enabled: true
time:
# Time and date utilities
command: npx
# Time and date utilities (published Python MCP server via uvx)
command: uvx
args:
- "-y"
- "@modelcontextprotocol/server-time"
- "mcp-server-time"
env: {}
enabled: true
@@ -1,9 +0,0 @@
{
"short_term_limit": 100,
"long_term_limit": 1000,
"episodic_retention_days": 30,
"enable_summarization": false,
"summarization_threshold": 50,
"importance_threshold": 0.7,
"auto_promote": true
}
@@ -1,21 +0,0 @@
{
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp6_tn0h1t",
"total_files": 0,
"total_tokens": 11,
"levels": 3,
"built_at": "2026-02-02T17:47:19.241146",
"nodes": {
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp6_tn0h1t": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp6_tn0h1t",
"level": 3,
"summary": "Project with 0 folders and 0 root files: ",
"children": [],
"token_count": 11,
"created_at": "2026-02-02T17:47:19.241224",
"metadata": {
"folder_count": 0,
"root_file_count": 0
}
}
}
}
@@ -1,35 +0,0 @@
{
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp7wxr57wm",
"total_files": 1,
"total_tokens": 16,
"levels": 2,
"built_at": "2026-02-02T17:47:19.243732",
"nodes": {
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp7wxr57wm/src/app.py": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp7wxr57wm/src/app.py",
"level": 1,
"summary": "# Application code",
"children": [],
"token_count": 5,
"created_at": "2026-02-02T17:47:19.243833",
"metadata": {
"filename": "app.py",
"relative_path": "src/app.py"
}
},
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp7wxr57wm/src": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp7wxr57wm/src",
"level": 2,
"summary": "Folder contains 1 files: # Application code",
"children": [
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp7wxr57wm/src/app.py"
],
"token_count": 11,
"created_at": "2026-02-02T17:47:19.243852",
"metadata": {
"folder_name": "src",
"file_count": 1
}
}
}
}
@@ -1,21 +0,0 @@
{
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp8qwfp4zl",
"total_files": 0,
"total_tokens": 11,
"levels": 3,
"built_at": "2026-02-02T17:49:37.074095",
"nodes": {
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp8qwfp4zl": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp8qwfp4zl",
"level": 3,
"summary": "Project with 0 folders and 0 root files: ",
"children": [],
"token_count": 11,
"created_at": "2026-02-02T17:49:37.074201",
"metadata": {
"folder_count": 0,
"root_file_count": 0
}
}
}
}
@@ -1,33 +0,0 @@
{
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp9nw2kfoj",
"total_files": 2,
"total_tokens": 14,
"levels": 1,
"built_at": "2026-02-02T17:47:19.242486",
"nodes": {
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp9nw2kfoj/utils.py": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp9nw2kfoj/utils.py",
"level": 1,
"summary": "def helper(): return True",
"children": [],
"token_count": 7,
"created_at": "2026-02-02T17:47:19.242582",
"metadata": {
"filename": "utils.py",
"relative_path": "utils.py"
}
},
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp9nw2kfoj/main.py": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp9nw2kfoj/main.py",
"level": 1,
"summary": "def main(): print('Hello')",
"children": [],
"token_count": 7,
"created_at": "2026-02-02T17:47:19.242630",
"metadata": {
"filename": "main.py",
"relative_path": "main.py"
}
}
}
}
@@ -1,35 +0,0 @@
{
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp_dbottar",
"total_files": 1,
"total_tokens": 16,
"levels": 2,
"built_at": "2026-02-02T17:49:37.077857",
"nodes": {
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp_dbottar/src/app.py": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp_dbottar/src/app.py",
"level": 1,
"summary": "# Application code",
"children": [],
"token_count": 5,
"created_at": "2026-02-02T17:49:37.077985",
"metadata": {
"filename": "app.py",
"relative_path": "src/app.py"
}
},
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp_dbottar/src": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp_dbottar/src",
"level": 2,
"summary": "Folder contains 1 files: # Application code",
"children": [
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmp_dbottar/src/app.py"
],
"token_count": 11,
"created_at": "2026-02-02T17:49:37.078011",
"metadata": {
"folder_name": "src",
"file_count": 1
}
}
}
}
@@ -1,33 +0,0 @@
{
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpp5k48vv4",
"total_files": 2,
"total_tokens": 14,
"levels": 1,
"built_at": "2026-02-02T17:49:37.075925",
"nodes": {
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpp5k48vv4/utils.py": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpp5k48vv4/utils.py",
"level": 1,
"summary": "def helper(): return True",
"children": [],
"token_count": 7,
"created_at": "2026-02-02T17:49:37.076036",
"metadata": {
"filename": "utils.py",
"relative_path": "utils.py"
}
},
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpp5k48vv4/main.py": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpp5k48vv4/main.py",
"level": 1,
"summary": "def main(): print('Hello')",
"children": [],
"token_count": 7,
"created_at": "2026-02-02T17:49:37.076227",
"metadata": {
"filename": "main.py",
"relative_path": "main.py"
}
}
}
}
@@ -1,33 +0,0 @@
{
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwwb01831",
"total_files": 2,
"total_tokens": 16,
"levels": 1,
"built_at": "2026-02-02T17:49:37.079504",
"nodes": {
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwwb01831/javascript.js": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwwb01831/javascript.js",
"level": 1,
"summary": "JavaScript web development",
"children": [],
"token_count": 7,
"created_at": "2026-02-02T17:49:37.079612",
"metadata": {
"filename": "javascript.js",
"relative_path": "javascript.js"
}
},
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwwb01831/python.py": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwwb01831/python.py",
"level": 1,
"summary": "Python programming language code",
"children": [],
"token_count": 9,
"created_at": "2026-02-02T17:49:37.079658",
"metadata": {
"filename": "python.py",
"relative_path": "python.py"
}
}
}
}
@@ -1,33 +0,0 @@
{
"root_path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwzdvf3_7",
"total_files": 2,
"total_tokens": 16,
"levels": 1,
"built_at": "2026-02-02T17:47:19.245229",
"nodes": {
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwzdvf3_7/javascript.js": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwzdvf3_7/javascript.js",
"level": 1,
"summary": "JavaScript web development",
"children": [],
"token_count": 7,
"created_at": "2026-02-02T17:47:19.245344",
"metadata": {
"filename": "javascript.js",
"relative_path": "javascript.js"
}
},
"/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwzdvf3_7/python.py": {
"path": "/var/folders/0b/t9c4hwvx6lg6jk58gw8w_dp80000gn/T/tmpwzdvf3_7/python.py",
"level": 1,
"summary": "Python programming language code",
"children": [],
"token_count": 9,
"created_at": "2026-02-02T17:47:19.245395",
"metadata": {
"filename": "python.py",
"relative_path": "python.py"
}
}
}
}
+38 -38
View File
@@ -18,76 +18,76 @@ This report compares **Python SDK feature categories** against **Python document
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 511 |
| ✅ Agent | 22 | 59 | 18844 |
| ✅ Agent | 22 | 59 | 19076 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 701 |
| ✅ Approval | 1 | 8 | 3934 |
| ✅ Approval | 1 | 8 | 4028 |
| ✅ Audio | 2 | 12 | 1000 |
| ✅ Auto Generation | 5 | 12 | 3879 |
| ✅ Autonomy | 3 | 7 | 2794 |
| ✅ Bots | 7 | 36 | 14678 |
| ✅ Auto Generation | 5 | 12 | 3941 |
| ✅ Autonomy | 3 | 7 | 2805 |
| ✅ Bots | 7 | 38 | 15809 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 124 | 38873 |
| ✅ Chunking | 2 | 2 | 424 |
| ✅ CLI | 5 | 124 | 39199 |
| ✅ 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 | 14275 |
| ✅ Context Management | 16 | 36 | 14384 |
| ✅ Deep Research | 8 | 2 | 591 |
| ✅ Display | 6 | 3 | 844 |
| ✅ Embeddings | 6 | 23 | 2051 |
| ✅ Evaluation | 1 | 9 | 3537 |
| ✅ Events | 1 | 2 | 767 |
| ✅ Execution | 3 | 4 | 1693 |
| ✅ Failover | 2 | 1 | 480 |
| ✅ Evaluation | 1 | 9 | 3637 |
| ✅ Events | 1 | 2 | 782 |
| ✅ Execution | 3 | 4 | 1694 |
| ✅ Failover | 2 | 1 | 537 |
| ✅ Files | 2 | 7 | 2376 |
| ✅ Flow | 1 | 3 | 788 |
| ✅ Gateway | 7 | 87 | 33047 |
| ✅ Guardrails | 5 | 4 | 2470 |
| ✅ Flow | 1 | 3 | 811 |
| ✅ Gateway | 7 | 97 | 37652 |
| ✅ Guardrails | 5 | 4 | 2642 |
| ✅ Handoffs | 11 | 6 | 2748 |
| ✅ Hooks | 2 | 9 | 4990 |
| ✅ Image | 1 | 11 | 1255 |
| ✅ Knowledge | 4 | 19 | 5964 |
| ✅ LLM | 3 | 15 | 5944 |
| ✅ Loops | 4 | 5 | 1475 |
| ✅ MCP | 1 | 63 | 14860 |
| ✅ Memory | 6 | 21 | 8186 |
| ✅ 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 |
| ✅ OCR | 2 | 1 | 237 |
| ✅ Observability | 2 | 23 | 2842 |
| ✅ Observability | 2 | 23 | 2859 |
| ✅ Optimizer | 1 | 2 | 792 |
| ✅ Output | 3 | 5 | 1273 |
| ✅ Output | 3 | 5 | 1289 |
| ✅ Parallel Execution | 3 | 2 | 528 |
| ✅ Planning | 6 | 7 | 1825 |
| ✅ Plugins | 8 | 5 | 3316 |
| ✅ Planning | 6 | 7 | 1827 |
| ✅ Plugins | 8 | 5 | 4112 |
| ✅ Prompts | 2 | 9 | 2043 |
| ✅ Providers | 1 | 57 | 8640 |
| ✅ Providers | 1 | 58 | 8751 |
| ✅ Query | 1 | 2 | 736 |
| ✅ RAG | 5 | 15 | 3124 |
| ✅ RAG | 5 | 15 | 3152 |
| ✅ Realtime | 2 | 5 | 668 |
| ✅ Reflection | 3 | 3 | 732 |
| ✅ Retrieval | 2 | 5 | 1046 |
| ✅ Routing | 1 | 2 | 430 |
| ✅ Sandbox | 5 | 7 | 3690 |
| ✅ Security | 1 | 3 | 3598 |
| ✅ Sessions | 4 | 20 | 8049 |
| ✅ Skills | 6 | 16 | 5984 |
| ✅ Sandbox | 5 | 7 | 3720 |
| ✅ Security | 1 | 3 | 3642 |
| ✅ Sessions | 4 | 21 | 8388 |
| ✅ Skills | 6 | 16 | 6045 |
| ✅ Tasks | 2 | 8 | 3266 |
| ✅ Telemetry | 1 | 2 | 660 |
| ✅ Templates | 1 | 8 | 1667 |
| ✅ Tools | 12 | 145 | 44509 |
| ✅ Templates | 1 | 9 | 1859 |
| ✅ Tools | 12 | 147 | 45378 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Vector Store | 1 | 12 | 1219 |
| ✅ Video | 2 | 6 | 728 |
| ✅ Video | 2 | 10 | 1303 |
| ✅ Vision | 2 | 1 | 329 |
| ✅ Web | 3 | 10 | 2815 |
| ✅ Workflows | 5 | 21 | 8851 |
| ✅ Web | 3 | 10 | 2822 |
| ✅ Workflows | 5 | 21 | 8880 |
## Documentation Without Features
These docs exist but don't match any implemented feature category:
- ℹ️ Database (43 docs, 7657 lines)
- ℹ️ Database (44 docs, 8292 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
}
+17
View File
@@ -0,0 +1,17 @@
"""Root conftest: environment that must be set before third-party imports.
pytest loads plugins registered through entry points before it loads
``tests/conftest.py``, and something in that chain imports litellm. litellm
reads LITELLM_LOCAL_MODEL_COST_MAP at *its* import and, without it, fetches
model_prices_and_context_window.json from raw.githubusercontent.com -- so the
suite made an outbound request before a single test ran, and stalled or failed
when offline. A rootdir conftest is loaded early enough to win that race.
Anything that merely needs to be set before the tests themselves belongs in
tests/conftest.py instead.
"""
import os
os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")
os.environ.setdefault("HF_HUB_OFFLINE", "1")
@@ -183,7 +183,9 @@ banner. Key fragments:
timeout-minutes: 15
env:
# Bump deliberately; the cache key and what we test both follow this.
OLLAMA_VERSION: v0.33.2
# No leading "v": install.sh passes ?version=$OLLAMA_VERSION and ollama.com
# redirects to GitHub tag v$version (vv0.33.2 → 404 if set to v0.33.2).
OLLAMA_VERSION: "0.33.2"
OLLAMA_TEST_MODEL: qwen3:0.6b
steps:
- uses: actions/checkout@v4
@@ -204,9 +206,12 @@ banner. Key fragments:
shell: bash
run: |
set -euo pipefail
curl -fsSL -o /tmp/ollama.tgz \
"https://github.com/ollama/ollama/releases/download/${OLLAMA_VERSION}/ollama-linux-amd64.tgz"
sudo tar -C /usr -xzf /tmp/ollama.tgz
# The official installer, VERSION-PINNED via OLLAMA_VERSION (which it
# honours). Since v0.33 the linux bundle ships as a .tar.zst with GPU
# runtimes bundled in, so a direct release-asset download is not a
# workable alternative; the installer handles the zst/tgz split.
sudo apt-get update -qq && sudo apt-get install -y -qq zstd
curl -fsSL https://ollama.com/install.sh | sh
ollama --version
- name: Start Ollama and pull model
shell: bash
@@ -146,6 +146,10 @@ _LAZY_IMPORTS = {
'TerminationReason': ('praisonaiagents.run_outcome', 'TerminationReason'),
'termination_to_run_status': ('praisonaiagents.run_outcome', 'termination_to_run_status'),
'validate_decision_string': ('praisonaiagents.run_outcome', 'validate_decision_string'),
'RunTerminal': ('praisonaiagents.run_outcome', 'RunTerminal'),
'merge_run_terminal': ('praisonaiagents.run_outcome', 'merge_run_terminal'),
'is_sticky': ('praisonaiagents.run_outcome', 'is_sticky'),
'collapse': ('praisonaiagents.run_outcome', 'collapse'),
# Main display utilities (imports rich)
'TaskOutput': ('praisonaiagents.main', 'TaskOutput'),
+35 -3
View File
@@ -37,6 +37,22 @@ _cache_lock = threading.RLock()
# Global module cache
_module_cache: Dict[str, Any] = {}
# Import roots of praisonaiagents' declared *core* runtime dependencies (see
# pyproject.toml ``[project].dependencies``). A lazy import that fails because
# one of these is missing/broken indicates a genuinely broken install, which
# must be surfaced rather than masked as a graceful ``None``. Optional
# integrations whose own optional SDKs are absent are NOT in this set, so they
# keep the documented ``None`` fallback.
_CORE_REQUIRED_DEPENDENCIES = frozenset({
"pydantic",
"rich",
"openai",
"posthog",
"aiohttp",
"yaml", # PyYAML — core dep; import root differs from the package name
"croniter",
})
def lazy_import(
module_path: str,
@@ -220,9 +236,25 @@ def create_lazy_getattr_with_fallback(
_cache[name] = None
return None
except ImportError as exc:
# Optional module not available: preserve the graceful None
# fallback, but record why so real import failures remain
# diagnosable instead of surfacing later as NoneType errors.
# Distinguish "optional integration not installed" from
# "a required *core* dependency is broken". A missing/broken core
# dependency (e.g. ``pydantic``) must be surfaced loudly instead
# of being masked as ``None`` (which resurfaces later as a
# confusing ``NoneType`` error). But an optional integration whose
# own optional SDK is absent must keep the documented graceful
# ``None`` fallback — the failing module name there belongs to
# that optional SDK, not to us. We therefore only escalate when
# the failing module is one of praisonaiagents' declared *core*
# runtime dependencies; everything else stays a soft None.
failing = getattr(exc, "name", None)
failing_root = failing.split(".")[0] if failing else None
if failing_root in _CORE_REQUIRED_DEPENDENCIES:
raise ImportError(
f"Failed to load {name!r} because importing {module_path!r} "
f"raised: {exc}. This usually means a required core dependency "
f"({failing_root!r}) is missing or broken, not that {name!r} is "
f"an optional feature you chose not to install."
) from exc
_logger.debug(
"Lazy import of %r (%s.%s) failed: %s",
name, module_path, attr_name, exc,
@@ -28,8 +28,10 @@ def _configure_environment():
"PYDANTIC_WARNINGS_ENABLED": "False",
}
# Use setdefault so a value the host exported before launch is preserved
# (and not leaked/overwritten into subprocesses spawned afterwards).
for key, value in env_vars.items():
os.environ[key] = value
os.environ.setdefault(key, value)
# ========================================================================
@@ -48,7 +50,11 @@ def _get_all_noisy_loggers() -> List[str]:
# Markdown
"markdown_it", "rich.markdown",
# System
"asyncio", "selector_events", "pydantic",
# NOTE: "asyncio" is intentionally NOT suppressed here — it is the host
# application's own logger, not a PraisonAI dependency's. Forcing it to
# CRITICAL hides unrelated host errors (e.g. "Task exception was never
# retrieved").
"selector_events", "pydantic",
"praisonaiagents.telemetry.telemetry",
]
@@ -7,10 +7,7 @@ from praisonaiagents._logging import get_logger
import asyncio
import contextlib
import threading
import concurrent.futures
import random
import re
from typing import List, Optional, Any, Dict, Union, Literal, TYPE_CHECKING, Callable, Generator
from typing import List, Optional, Any, Dict, Union, Literal, TYPE_CHECKING, Callable
from collections import OrderedDict
import inspect
@@ -23,9 +20,12 @@ from .tool_execution import ToolExecutionMixin, BackoffPolicy
from .chat_handler import ChatHandlerMixin
from .session_manager import SessionManagerMixin
from .async_safety import AsyncSafeState, DualLock
# NOTE: UnifiedExecutionMixin is deprecated and unused by any production path
# (Issue #2644). It is kept in the MRO for backward compatibility during the
# deprecation cycle and will be removed afterwards.
# NOTE: UnifiedExecutionMixin's *public* methods are deprecated and unused by
# any production path (Issue #2644); it is kept in the MRO for backward
# compatibility during the deprecation cycle and will be removed afterwards.
# The one live helper it used to own (_run_async_in_sync_context) has been
# relocated to async_safety.run_async_in_sync_context, so dropping this mixin
# from the MRO is a true no-op.
from .unified_execution_mixin import UnifiedExecutionMixin
from .sandbox_mixin import SandboxMixin
from .message_steering import SteeringMixin
@@ -880,6 +880,11 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
"streaming moved into output=; use "
"output=OutputConfig(stream=True)."
),
# Same migration, different container: these two were flat
# kwargs before they were grouped into tool_config. Without a
# hint the user gets a bare "unexpected keyword argument" and
# has to search a 41-parameter signature for a name that is not
# in it.
"tool_retry_policy": (
"tool retry moved into tool_config=; use "
"tool_config=ToolConfig(retry_policy=RetryPolicy(...))."
@@ -1365,12 +1370,17 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
array_mode=ArrayMode.PASSTHROUGH,
default=None,
)
# Per-agent event-hook registry (isolates BEFORE_TOOL/AFTER_AGENT/...
# hooks so they never fire on unrelated agents sharing the default
# registry). A bare HookRegistry passed as ``hooks=`` still works too.
_hooks_registry = None
if _hooks_config is not None:
if isinstance(_hooks_config, list):
_hooks_list = _hooks_config
elif isinstance(_hooks_config, HooksConfig):
step_callback = _hooks_config.on_step
_hooks_list = list(_hooks_config.middleware or [])
_hooks_registry = getattr(_hooks_config, "registry", None)
# Route on_step / on_tool_call onto the SAME middleware chain
# that already powers ``hooks=[...]`` (MiddlewareManager). Both
# keys used to be stored-and-never-called, so the TypeError that
@@ -2093,7 +2103,11 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
# Lazy init for HookRunner and StreamEventEmitter (zero overhead when not used)
self.__hook_runner = None # Will be initialized on first access
self.__stream_emitter = None # Will be initialized on first access
self._hooks_registry_param = hooks # Store for lazy init
# Prefer a per-agent registry declared via HooksConfig(registry=...) so
# this agent's event hooks stay isolated; else fall back to the raw
# ``hooks`` value (a bare HookRegistry still isolates, anything else
# resolves to the shared default registry in the _hook_runner property).
self._hooks_registry_param = _hooks_registry if _hooks_registry is not None else hooks
# Handle llm= deprecation: model= is the preferred parameter name
# llm= still works but shows deprecation warning
@@ -2296,10 +2310,22 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
# was sent to OpenAI under a model name that was really a repr -- the
# opposite of what passing your own model means. Duck-typed on
# ``get_response`` so any conforming backend works, not just ``LLM``.
# Detection must match what the tool loop actually invokes: the custom
# path calls ``llm_instance.get_response(**llm_kwargs)`` (and the async
# ``get_response_async``) with PraisonAI-internal kwargs -- tools,
# tool_choice, seed, cancel_token, steering_drain, and more. The
# ``chat``/``achat`` (LLMProviderProtocol) and
# ``chat_completion``/``achat_completion`` (UnifiedLLMProtocol) surfaces
# take a different signature and are NOT wired into that loop, so a
# backend exposing only those would be adopted here and then raise
# ``AttributeError`` on the first turn. Adopt only the surface the
# executor can drive; a translation adapter for the other protocols
# would be a heavy, unused implementation rather than a fix.
_MODEL_BACKEND_METHODS = ("get_response", "get_response_async")
_is_model_instance = (
llm is not None
and not isinstance(llm, (str, dict))
and callable(getattr(llm, "get_response", None))
and any(callable(getattr(llm, m, None)) for m in _MODEL_BACKEND_METHODS)
)
if _is_model_instance:
self._llm_instance = llm
@@ -2575,6 +2601,11 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
self._on_budget_exceeded = _on_budget_exceeded
# Thread-safe cost/token tracking (Gap 1a fix)
self._cost_lock = threading.Lock()
# Serializes the read-modify-write of _auto_save_last_index. Auto-save
# now runs via asyncio.to_thread, so two concurrent async runs sharing
# one Agent could otherwise read the same last-saved index in separate
# worker threads and each persist the same turns, duplicating history.
self._auto_save_lock = threading.Lock()
self._total_cost = 0.0
self._total_tokens_in = 0
self._total_tokens_out = 0
@@ -2861,7 +2892,10 @@ Your Goal: {self.goal}
# Reuse the caller's manager as-is (no disk I/O side-effect).
_mgr = self._approval_permissions
else:
_mgr = PermissionManager(agent_name=self.name)
_mgr = PermissionManager(
agent_name=self.name,
workspace_root=os.getcwd(),
)
if isinstance(self._approval_permissions, dict):
_mgr.load_rules_from_config(self._approval_permissions)
elif isinstance(self._approval_permissions, (list, tuple)):
@@ -3004,6 +3038,7 @@ Your Goal: {self.goal}
# turns on the same Agent instance don't corrupt each other's buffer.
self._turn_tools_used = []
self._turn_tools_lock = DualLock()
self._tool_executor_lock = DualLock()
# Database persistence (lazy - no imports until used)
self._db = db
@@ -3031,6 +3066,12 @@ Your Goal: {self.goal}
# Agent-centric feature instances (lazy loaded for zero performance impact)
self._auto_memory = auto_memory
self._policy = policy
# Route enabled GUARDRAIL/POLICY/SKILL plugins into their typed
# subsystems now that guardrail (_setup_guardrail), _skills and _policy
# are all set. A plugin declared PluginType.GUARDRAIL/POLICY/SKILL thus
# participates in the GuardrailChain / PolicyEngine / SkillManager, not
# just a generic lifecycle hook. No-op when no such plugin is enabled.
self._merge_plugin_subsystems()
self._output_style = output_style
# Backward-compatible: `thinking_budget` property mirrors the legacy int
# budget when supplied via the alias (Issue #4452); the unified effort is
@@ -3211,7 +3252,7 @@ Your Goal: {self.goal}
for k, v in self.__dict__.items():
if k in ("_Agent__cache_lock",):
object.__setattr__(result, k, threading.RLock())
elif k == "_cost_lock":
elif k in ("_cost_lock", "_auto_save_lock"):
object.__setattr__(result, k, threading.Lock())
elif k == "_approvals_lock":
object.__setattr__(result, k, asyncio.Lock())
@@ -3413,8 +3454,27 @@ Your Goal: {self.goal}
from ..config.feature_configs import MemoryConfig
clone_kwargs['memory'] = MemoryConfig(auto_save=value)
# Create new Agent instance
return self.__class__(**{k: v for k, v in clone_kwargs.items() if v is not None})
# Create new Agent instance. Plugin tools are shallow-copied above so
# callable identity and their ownership metadata can be carried over;
# otherwise the constructor's name-collision guard would treat a copied
# plugin tool as a caller-declared tool and disabling the plugin could
# leave the clone with an executable stale capability.
clone = self.__class__(**{k: v for k, v in clone_kwargs.items() if v is not None})
plugin_owners = getattr(self, "_plugin_tool_owners", None)
if plugin_owners:
inherited_owners = {
id(tool): plugin_owners[id(tool)]
for tool in (clone.tools if isinstance(clone.tools, (list, tuple)) else [])
if id(tool) in plugin_owners
}
if inherited_owners:
# Keep ownership recorded while the constructor merged any
# plugins that became enabled after the source was created.
# Those entries are needed to revoke newly attached tools.
current_owners = dict(getattr(clone, "_plugin_tool_owners", {}) or {})
current_owners.update(inherited_owners)
clone._plugin_tool_owners = current_owners
return clone
@property
def _telemetry(self):
@@ -4074,34 +4134,41 @@ Summary:"""
* ``"safe"`` -> subprocess isolation, no tool access from the code.
* ``"unsafe"`` -> same process, restricted builtins, and the
``code_tools_allow`` allow-list injected as callable tool proxies.
* ``"isolated"`` -> subprocess isolation AND the ``code_tools_allow``
allow-list, with each tool call bridged back to the parent under the
approval gate (the safe-and-tool-capable path).
Both expose one tool named ``execute_code``, which the approval registry
All expose one tool named ``execute_code``, which the approval registry
classes as "critical", so it stays gated under every preset but "full".
"""
from ..tools.python_tools import build_code_execution_tools
_tool_capable = ("unsafe", "isolated")
code_tools = bool(getattr(exec_config, "code_tools", False))
allowed = list(getattr(exec_config, "code_tools_allow", None) or [])
if code_tools and code_execution_mode != "unsafe":
if code_tools and code_execution_mode not in _tool_capable:
import warnings
warnings.warn(
"ExecutionConfig(code_tools=True) needs code_mode='unsafe': in "
"'safe' mode the code runs in a separate process and cannot "
"reach the agent's tools, so code_tools_allow is ignored.",
"ExecutionConfig(code_tools=True) needs code_mode='unsafe' or "
"'isolated': in 'safe' mode the code runs in a separate process "
"and cannot reach the agent's tools, so code_tools_allow is "
"ignored.",
UserWarning,
stacklevel=3,
)
code_tools = False
# In unsafe mode the allow-list must resolve against ONLY the tools this
# agent was granted, never the process-global registry (which can hold
# plugin/entry-point tools the agent was never given). Build a private
# registry from self.tools and pass it down so code-mode inherits the
# agent's exact tool boundary.
# In tool-capable modes the allow-list must resolve against ONLY the
# tools this agent was granted, never the process-global registry (which
# can hold plugin/entry-point tools the agent was never given). Build a
# private registry from self.tools and pass it down so code-mode
# inherits the agent's exact tool boundary.
scoped_registry = None
if code_tools and code_execution_mode == "unsafe":
if code_tools and code_execution_mode in _tool_capable:
from ..tools.registry import ToolRegistry
scoped_registry = ToolRegistry()
# discovery_enabled=False: an allow-listed name absent from THIS
# agent's tools must NOT resolve to an installed global plugin.
scoped_registry = ToolRegistry(discovery_enabled=False)
for t in (self.tools or []):
if callable(t) or hasattr(t, "name"):
try:
@@ -4109,12 +4176,44 @@ Summary:"""
except Exception:
pass
# Gate every code-mode tool call through the SAME PolicyEngine,
# permission deny-rules and per-tool ``input_guardrails`` the direct
# tool-call loop runs. The approval framework the proxy already honours
# does not cover these, so without this hook a model could reach an
# allow-listed tool from code and bypass a guardrail that blocks the
# same call on the normal path.
#
# Contract (mirrors the direct path in ``_check_tool_approval_sync``):
# returns ``(denial_reason, arguments)`` where ``denial_reason`` is a
# string to block or ``None`` to allow, and ``arguments`` is the
# possibly-guardrail-rewritten kwargs dict the tool must run with. The
# proxy uses the returned args so a sanitising guardrail's rewrite is
# honoured (not discarded) exactly as on the direct path. BYPASS mode
# skips the permission-deny gate, matching the direct contract, so a
# call permitted directly is not rejected only in code mode. Only runs
# in the tool-capable modes.
policy_hook = None
if code_tools and code_execution_mode in _tool_capable:
def policy_hook(name, arguments):
if not self._is_bypass_mode():
deny = self._check_permission_manager_deny(name, arguments)
if isinstance(deny, dict):
return deny.get("error", "denied by permission policy"), arguments
verdict = self._check_tool_policy_and_guardrails(name, arguments)
if isinstance(verdict, dict):
return verdict.get("error", "denied by policy"), arguments
# ``verdict`` is ``(None, rewritten_args)`` when allowed; hand the
# possibly-rewritten args back so the proxy dispatches with them.
_, rewritten = verdict
return None, rewritten
# An unknown code_mode raises out of here rather than silently
# producing nothing, which is the failure this whole change is about.
new_tools = build_code_execution_tools(
code_mode=code_execution_mode,
allowed_tools=allowed if code_tools else [],
registry=scoped_registry,
policy_hook=policy_hook,
)
existing = {getattr(t, "__name__", None) for t in (self.tools or [])}
@@ -5292,14 +5391,19 @@ Summary:"""
f"response_len={len(response_str)}"
)
# Auto-save session after each async iteration (memory integration)
self._auto_save_session()
# Auto-save session after each async iteration (memory integration).
# Offloaded to a worker thread so the synchronous FileLock/JSON
# write never stalls the shared event loop (mirrors the goal-
# completion-judge offload below).
await asyncio.to_thread(self._auto_save_session)
# ─────────────────────────────────────────────────────────────
# GOAL COMPLETION JUDGE (opt-in): independent acceptance-criteria
# gate. No-op when no goal loop is active (behaviour unchanged).
# Offloaded to a worker thread (blocking judge LLM call / hooks)
# so it never stalls the shared event loop.
# ─────────────────────────────────────────────────────────────
_goal_gate = self._goal_gate(response_str)
_goal_gate = await self._goal_gate_async(response_str)
if _goal_gate is not None:
_outcome, _reason = _goal_gate
if _outcome == "done":
@@ -5431,8 +5535,8 @@ Summary:"""
# Yield control to allow other async tasks to run
await asyncio.sleep(0)
# Final auto-save before returning
self._auto_save_session()
# Final auto-save before returning (offloaded off the event loop).
await asyncio.to_thread(self._auto_save_session)
# Max iterations reached
return AutonomyResult(
@@ -5848,6 +5952,8 @@ Summary:"""
List of available tools
"""
if not self.plan_mode:
if getattr(self, "_plugin_tool_owners", None):
return [tool for tool in self.tools if self._is_plugin_tool_active(tool)]
return self.tools
# Filter to read-only tools only
@@ -5855,6 +5961,8 @@ Summary:"""
filtered_tools = []
for tool in self.tools:
if not self._is_plugin_tool_active(tool):
continue
tool_name = getattr(tool, '__name__', str(tool)).lower()
# Check if tool is in restricted list
@@ -7015,6 +7123,25 @@ Answer:"""
"""The most recent rejection message a guardrail sent back to the model."""
return getattr(self, "_last_guardrail_error", None)
def _guardrail_blocked_message(self, error=None):
"""Fixed, user-facing string returned when an output guardrail blocks a run.
A blocked response must never surface the rejected (potentially unsafe)
model text, but it must also never be ``None`` - callers expect a
``str`` and would otherwise crash on ``.strip()`` (issue #5596, FR-001).
The returned string is intentionally **fixed** and carries no diagnostic
detail: a guardrail's own rejection reason (especially an LLM validator's
free-text) can echo the blocked output, so appending it would leak the
very content the guardrail rejected. The reason is logged instead, where
it stays auditable without reaching the caller. The rejection is also
exposed programmatically via :attr:`last_guardrail_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}")
return "I'm sorry, but I can't share that response because it did not pass the safety guardrail."
def _process_guardrail(self, task_output):
"""Process the guardrail validation for a task output.
@@ -7030,8 +7157,16 @@ Answer:"""
return GuardrailResult(success=True, result=task_output)
try:
# Call the guardrail function
result = self._guardrail_fn(task_output)
# Call the guardrail function, awaiting it if the caller supplied an
# async def. Calling a coroutine function synchronously would only
# build a coroutine object (never running its body), which then
# crashes GuardrailResult.from_tuple and is misreported as a
# validation failure.
if asyncio.iscoroutinefunction(self._guardrail_fn):
from ..utils.async_bridge import run_coroutine_from_any_context
result = run_coroutine_from_any_context(self._guardrail_fn(task_output))
else:
result = self._guardrail_fn(task_output)
# Convert the result to a GuardrailResult
return GuardrailResult.from_tuple(result)
@@ -7329,10 +7464,25 @@ Answer:"""
return "empty"
# Create a simple hash based on tool names
tool_names = []
try:
from ..tools.hosted import is_hosted_tool
except ImportError:
is_hosted_tool = lambda _tool: False
for tool in tools:
if callable(tool) and hasattr(tool, '__name__'):
tool_names.append(tool.__name__)
elif isinstance(tool, dict) and 'function' in tool and 'name' in tool['function']:
elif isinstance(tool, dict) and is_hosted_tool(tool):
try:
hosted_key = json.dumps(tool, sort_keys=True)
except (TypeError, ValueError):
hosted_key = str(id(tool))
tool_names.append(f"hosted:{hosted_key}")
elif (
isinstance(tool, dict)
and isinstance(tool.get('function'), dict)
and tool['function'].get('name')
):
tool_names.append(tool['function']['name'])
elif isinstance(tool, str):
tool_names.append(tool)
@@ -7928,16 +8078,28 @@ Answer:"""
except Exception as e:
logger.warning(f"Task cleanup failed: {e}")
# ThreadPoolExecutor cleanup
# ThreadPoolExecutor cleanup. Detach the executor under the same lock
# that guards timeout retirement/replacement (see tool_execution.py) so
# cleanup and a concurrent timeout can never act on different executor
# generations. Shut down outside the critical section to avoid holding
# the lock across a blocking call.
try:
if hasattr(self, '_tool_executor') and self._tool_executor:
executor = None
lock = getattr(self, '_tool_executor_lock', None)
if lock is not None:
with lock.sync():
executor = getattr(self, '_tool_executor', None)
self._tool_executor = None
elif hasattr(self, '_tool_executor'):
executor = self._tool_executor
self._tool_executor = None
if executor is not None:
# Use cancel_futures only if supported (Python 3.9+)
import sys
if sys.version_info >= (3, 9):
self._tool_executor.shutdown(wait=False, cancel_futures=True)
executor.shutdown(wait=False, cancel_futures=True)
else:
self._tool_executor.shutdown(wait=False)
delattr(self, '_tool_executor')
executor.shutdown(wait=False)
except Exception as e:
logger.warning(f"ThreadPoolExecutor cleanup failed: {e}")
@@ -7971,6 +8133,41 @@ Answer:"""
elif hasattr(self.memory, 'close_connections'):
self.memory.close_connections()
# LLM client cleanup - release live client pools asynchronously.
# Mirror close()'s targets but never touch the ``llm_instance``
# property (it would lazily *create* a client just to close it);
# only tear down an already-materialised ``_llm_instance``.
try:
llm_instance = getattr(self, '_llm_instance', None)
if llm_instance is not None:
aclose = getattr(llm_instance, 'aclose', None)
if aclose is not None:
try:
if asyncio.iscoroutinefunction(aclose):
await aclose()
else:
aclose()
except Exception:
close = getattr(llm_instance, 'close', None)
if callable(close):
close()
else:
close = getattr(llm_instance, 'close', None)
if callable(close):
close()
openai_client = getattr(self, '_Agent__openai_client', None)
if openai_client is not None and hasattr(openai_client, 'close'):
openai_client.close()
llm = getattr(self, 'llm', None)
if llm and not isinstance(llm, str):
llm_client = getattr(llm, '_client', None)
if llm_client and hasattr(llm_client, 'close'):
llm_client.close()
except Exception as e:
logger.warning(f"LLM client cleanup failed: {e}")
# Close MCP clients passed via tools=[MCP(...)]
# (mirrors remove_mcp_server()'s best-effort shutdown)
if isinstance(self.tools, list):
@@ -8005,22 +8202,35 @@ Answer:"""
except asyncio.CancelledError:
pass
# ThreadPoolExecutor cleanup (async-safe)
if hasattr(self, '_tool_executor') and self._tool_executor:
# ThreadPoolExecutor cleanup (async-safe). Detach the executor under
# the lock that guards timeout retirement/replacement so cleanup and
# a concurrent timeout can never act on different executor
# generations. Capture the reference before scheduling the shutdown
# so the deferred call cannot resolve a replacement pool created by
# another call.
executor = None
lock = getattr(self, '_tool_executor_lock', None)
if lock is not None:
async with lock.async_lock():
executor = getattr(self, '_tool_executor', None)
self._tool_executor = None
elif hasattr(self, '_tool_executor'):
executor = self._tool_executor
self._tool_executor = None
if executor is not None:
import sys
loop = asyncio.get_running_loop()
# Use run_in_executor to avoid blocking the event loop
if sys.version_info >= (3, 9):
await loop.run_in_executor(
None,
lambda: self._tool_executor.shutdown(wait=False, cancel_futures=True)
None,
lambda: executor.shutdown(wait=False, cancel_futures=True)
)
else:
await loop.run_in_executor(
None,
lambda: self._tool_executor.shutdown(wait=False)
None,
lambda: executor.shutdown(wait=False)
)
delattr(self, '_tool_executor')
# Approval scope cleanup (see close()).
self._release_approval_scope()
@@ -12,6 +12,38 @@ from contextlib import contextmanager, asynccontextmanager
from weakref import WeakKeyDictionary
def run_async_in_sync_context(coro):
"""
Run an async coroutine to completion from a sync context with proper
event loop handling.
Handles the common cases:
1. No event loop exists - use asyncio.run()
2. Event loop already running - avoid deadlock by running the coroutine
to completion in a dedicated thread with its own event loop.
This is a live production helper on the sync tool-calling path (see
``tool_execution.py``): it bridges ``async def`` tool bodies into the sync
tool loop, otherwise a bare un-awaited coroutine would be handed to the
model as the tool result and the tool body would never run (silent data
loss).
Delegates to the single canonical bridge
(:func:`utils.async_bridge.run_coroutine_from_any_context`) so all sync→async
call sites share one implementation: the caller's ``contextvars`` (trace /
session / approval context) are carried into the coroutine, and the running
loop is handled on a dedicated worker thread rather than raising.
``timeout=None`` is passed deliberately: this helper backs the sync
tool-calling path, where a tool's own timeout policy (or lack of one) already
governs how long a tool may run. Imposing the bridge's default 5-minute
deadline here would silently cancel long-running tools that previously had no
limit — a backward-incompatible regression — so the bridge adds none.
"""
from ..utils.async_bridge import run_coroutine_from_any_context
return run_coroutine_from_any_context(coro, timeout=None)
class DualLock:
"""
A dual-lock abstraction that automatically selects threading.Lock or asyncio.Lock
@@ -37,9 +69,24 @@ class DualLock:
"""
def __init__(self):
"""Initialize with separate threading and asyncio locks."""
self._thread_lock = threading.RLock() # Re-entrant lock to handle nested acquisitions
self._async_locks = WeakKeyDictionary() # Per-event-loop async locks
"""Initialize with a single underlying mutex shared by both call styles.
A single ``threading.Lock`` backs both ``sync()`` and ``async_lock()``
so that holding one genuinely blocks the other — mutual exclusion is
real across sync and async callers of the same instance. A plain
``Lock`` (not ``RLock``) is used because the async path acquires on a
worker thread but releases on the event-loop thread, which ``RLock``'s
thread-ownership semantics forbid. Same-thread ``sync()`` reentrancy is
preserved explicitly via an owner/depth guard below. The per-event-loop
``asyncio.Lock`` only serialises coroutines on the same loop so they
queue for the underlying mutex cooperatively instead of every coroutine
racing straight into the blocking acquire.
"""
self._thread_lock = threading.Lock() # Shared mutex (cross-thread release ok)
self._owner_lock = threading.Lock() # Guards the reentrancy bookkeeping
self._owner_thread: Any = None # Thread id currently holding for sync reentrancy
self._owner_depth = 0
self._async_locks = WeakKeyDictionary() # Per-event-loop async gate
def __deepcopy__(self, memo):
"""Return an unlocked primitive with no event-loop state sharing."""
@@ -49,9 +96,31 @@ class DualLock:
@contextmanager
def sync(self):
"""Acquire lock in synchronous context using threading.Lock."""
with self._thread_lock:
"""Acquire lock in synchronous context using the shared mutex.
Re-entrant for the same thread: a nested ``sync()`` on the thread that
already holds the mutex does not re-acquire (and cannot self-deadlock).
"""
me = threading.get_ident()
with self._owner_lock:
reentrant = self._owner_thread == me and self._owner_depth > 0
if reentrant:
self._owner_depth += 1
if not reentrant:
self._thread_lock.acquire()
with self._owner_lock:
self._owner_thread = me
self._owner_depth = 1
try:
yield
finally:
with self._owner_lock:
self._owner_depth -= 1
released = self._owner_depth == 0
if released:
self._owner_thread = None
if released:
self._thread_lock.release()
def _get_async_lock(self):
"""Get or create an asyncio.Lock for the current event loop."""
@@ -64,16 +133,64 @@ class DualLock:
self._async_locks[loop] = asyncio.Lock()
return self._async_locks[loop]
async def _acquire_shared_mutex(self, loop):
"""Acquire the shared mutex off-loop, releasing it if we are cancelled.
``run_in_executor`` can complete (mutex acquired) after the awaiting
task is cancelled. If we let the ``CancelledError`` propagate without
checking, the worker would strand the mutex forever. We therefore wait
on the acquisition, and on cancellation ensure the mutex is released
once the worker finishes so no caller waits indefinitely.
"""
future = loop.run_in_executor(None, self._thread_lock.acquire)
try:
await asyncio.shield(future)
except asyncio.CancelledError:
# The worker may still be mid-acquire; wait for it to settle, then
# release if it managed to take the mutex so it is never orphaned.
try:
acquired = await future
except asyncio.CancelledError:
acquired = False
if acquired:
self._thread_lock.release()
raise
@asynccontextmanager
async def async_lock(self):
"""Acquire lock in asynchronous context using asyncio.Lock.
Uses a per-event-loop asyncio.Lock to ensure proper async coordination
without blocking the event loop or violating thread ownership semantics.
"""Acquire lock in asynchronous context using the shared mutex.
Backs onto the *same* ``threading.Lock`` as ``sync()`` so a concurrent
sync caller is genuinely excluded (and vice-versa). The blocking
``acquire`` is offloaded to a worker thread so the event loop is never
blocked while waiting; the matching ``release`` (allowed cross-thread on
a plain ``Lock``) happens on the loop thread. A per-event-loop
``asyncio.Lock`` serialises coroutines on this loop first so they don't
all spin up executor hops at once.
While the mutex is held, the *event-loop thread* is registered as the
sync owner so a nested ``with lock.sync()`` on that thread (used by the
async handoff seeding path) is treated as re-entrant instead of
self-deadlocking against the mutex we already hold.
"""
async_lock = self._get_async_lock()
async with async_lock:
yield
loop = asyncio.get_running_loop()
async_gate = self._get_async_lock()
async with async_gate:
await self._acquire_shared_mutex(loop)
me = threading.get_ident()
with self._owner_lock:
self._owner_thread = me
self._owner_depth = 1
try:
yield
finally:
with self._owner_lock:
self._owner_depth -= 1
released = self._owner_depth == 0
if released:
self._owner_thread = None
if released:
self._thread_lock.release()
def is_async_context(self) -> bool:
"""Check if we're currently in an async context."""
@@ -147,6 +264,11 @@ class AsyncSafeState:
@asynccontextmanager
async def async_lock(self):
"""Acquire lock in async context."""
# Fail fast BEFORE blocking on the shared mutex: an in-progress
# synchronous deepcopy holds the mutex for the whole copy, so waiting on
# it here would block until the copy finished instead of surfacing the
# documented RuntimeError (and could stall the loop indefinitely).
self._raise_if_copying()
async with self._lock.async_lock():
self._begin_async_access()
try:
@@ -154,6 +276,14 @@ class AsyncSafeState:
finally:
self._end_async_access()
def _raise_if_copying(self) -> None:
"""Reject entry while a synchronous deepcopy of this state is active."""
with self._activity_lock:
if self._copying:
raise RuntimeError(
"Cannot access AsyncSafeState during synchronous deepcopy"
)
def _begin_async_access(self) -> None:
"""Register async access unless a synchronous copy is in progress."""
with self._activity_lock:
@@ -170,20 +300,32 @@ class AsyncSafeState:
def __enter__(self):
"""Support for synchronous context manager protocol (backward compatibility)."""
self._lock._thread_lock.acquire()
self._sync_guard = self._lock.sync()
self._sync_guard.__enter__()
return self.value
def __exit__(self, exc_type, exc_val, exc_tb):
"""Support for synchronous context manager protocol (backward compatibility)."""
self._lock._thread_lock.release()
return None
return self._sync_guard.__exit__(exc_type, exc_val, exc_tb)
async def __aenter__(self):
"""Support for asynchronous context manager protocol."""
# Fail fast before blocking on the mutex an in-progress deepcopy holds.
self._raise_if_copying()
loop = asyncio.get_running_loop()
async_lock = self._lock._get_async_lock()
await async_lock.acquire()
try:
self._begin_async_access()
# Hold the SAME underlying mutex used by the sync path so async and
# sync accessors are mutually exclusive (matches DualLock.async_lock).
# Cancellation-safe acquire so the mutex is never stranded if the
# awaiting task is cancelled mid-acquisition.
await self._lock._acquire_shared_mutex(loop)
try:
self._begin_async_access()
except Exception:
self._lock._thread_lock.release()
raise
except Exception:
async_lock.release()
raise
@@ -195,7 +337,10 @@ class AsyncSafeState:
try:
self._end_async_access()
finally:
async_lock.release()
try:
self._lock._thread_lock.release()
finally:
async_lock.release()
return None
def get(self) -> Any:
@@ -751,6 +751,43 @@ Your Goal: {self.goal}"""
return None
def _coerce_structured_output(self, response_text, schema_model):
"""Parse a string response into the requested Pydantic model.
When ``output_pydantic``/``output_json`` is a Pydantic model, callers
expect a validated instance rather than a raw string. This tolerates
markdown code fences around the JSON payload.
Returns the parsed model instance on success, or the original
``response_text`` unchanged if parsing is not possible (preserves
backward compatibility for non-Pydantic schemas or malformed output).
"""
if not schema_model or not isinstance(response_text, str):
return response_text
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
# Strip a surrounding markdown code fence (```json ... ```) if present
fenced = re.search(r"```(?:json)?\s*(.*?)\s*```", candidate, re.DOTALL)
if fenced:
try:
return schema_model.model_validate_json(fenced.group(1).strip())
except Exception:
pass
logging.warning(
f"Agent {self.name}: output_pydantic={getattr(schema_model, '__name__', schema_model)} "
f"was requested but the response could not be parsed into the model; "
f"returning raw string."
)
return response_text
def _supports_native_structured_output(self):
"""Check if current model supports native structured output via response_format.
@@ -778,6 +815,35 @@ Your Goal: {self.goal}"""
)
return False
def _load_session_history(self):
"""Persisted session history for this agent, or [] when unavailable.
Extracted so BOTH branches of _build_messages can use it. The injection
used to live only in the manual-message branch, so an agent configured
with memory="history" silently dropped its persisted history whenever
_openai_client was set -- which is the default path for OpenAI models.
"""
if not self._history_enabled or self._session_store is None:
return []
try:
# Prefer compacted working history (summary + tail) on resume when a
# compaction checkpoint exists (Issue #2741); falls back to raw chat
# history for backward compatibility.
if hasattr(self._session_store, "get_working_history"):
session_history = self._session_store.get_working_history(
self._history_session_id,
max_messages=self._history_limit
)
else:
session_history = self._session_store.get_chat_history(
self._history_session_id,
max_messages=self._history_limit
)
return list(session_history) if session_history else []
except Exception as e:
logging.debug(f"Failed to load session history: {e}")
return []
def _build_messages(self, prompt, temperature=1.0, output_json=None, output_pydantic=None, tools=None, use_native_format=False, restore_durable=True, memory_prefetch_context: str = ""):
"""Build messages list for chat completion.
@@ -803,7 +869,7 @@ Your Goal: {self.goal}"""
tools=tools,
memory_prefetch_context=memory_prefetch_context,
),
chat_history=self.chat_history,
chat_history=self._load_session_history() + list(self.chat_history or []),
output_json=None if use_native_format else output_json,
output_pydantic=None if use_native_format else output_pydantic
)
@@ -817,25 +883,7 @@ Your Goal: {self.goal}"""
messages.append({"role": "system", "content": system_prompt})
# Inject session history if enabled (from persistent storage)
if self._history_enabled and self._session_store is not None:
try:
# Prefer compacted working history (summary + tail) on
# resume when a compaction checkpoint exists (Issue #2741);
# falls back to raw chat history for backward compatibility.
if hasattr(self._session_store, "get_working_history"):
session_history = self._session_store.get_working_history(
self._history_session_id,
max_messages=self._history_limit
)
else:
session_history = self._session_store.get_chat_history(
self._history_session_id,
max_messages=self._history_limit
)
if session_history:
messages.extend(session_history)
except Exception as e:
logging.debug(f"Failed to load session history: {e}")
messages.extend(self._load_session_history())
# Add in-memory chat history (current conversation)
if self.chat_history:
@@ -894,6 +942,12 @@ Your Goal: {self.goal}"""
elif isinstance(tools, list) and len(tools) == 0:
# Explicit empty list - return immediately to enforce boundary
return []
# Plugin tools are copied into the Agent's local tool list at
# construction time. Re-check ownership before advertising them so a
# later plugin disable/unregister revokes the advertised capability.
if getattr(self, "_plugin_tool_owners", None) and isinstance(tools, (list, tuple)):
tools = [tool for tool in tools if self._is_plugin_tool_active(tool)]
if not tools:
return []
@@ -909,9 +963,18 @@ Your Goal: {self.goal}"""
return cached_tools
formatted_tools = []
# Provider-hosted tool specs have no local callable; the provider
# executes them after this formatter forwards the allowlisted dict.
try:
from ..tools.hosted import is_hosted_tool
except ImportError:
is_hosted_tool = lambda _tool: False
for tool in tools:
if isinstance(tool, dict) and is_hosted_tool(tool):
formatted_tools.append(tool)
# Handle pre-formatted OpenAI tools
if isinstance(tool, dict) and tool.get('type') == 'function':
elif isinstance(tool, dict) and tool.get('type') == 'function':
# Validate nested dictionary structure before accessing
if 'function' in tool and isinstance(tool['function'], dict) and 'name' in tool['function']:
formatted_tools.append(tool)
@@ -920,7 +983,9 @@ Your Goal: {self.goal}"""
# Handle lists of tools
elif isinstance(tool, list):
for subtool in tool:
if isinstance(subtool, dict) and subtool.get('type') == 'function':
if isinstance(subtool, dict) and is_hosted_tool(subtool):
formatted_tools.append(subtool)
elif isinstance(subtool, dict) and subtool.get('type') == 'function':
# Validate nested dictionary structure before accessing
if 'function' in subtool and isinstance(subtool['function'], dict) and 'name' in subtool['function']:
formatted_tools.append(subtool)
@@ -1403,15 +1468,8 @@ Your Goal: {self.goal}"""
from ..hooks import HookEvent as _HookEvent
import logging
# Map policy strategy to compactor strategy
strategy_map = {
"truncate": LegacyStrategy.TRUNCATE,
"summarise": LegacyStrategy.SUMMARIZE,
"drop_oldest_tools": LegacyStrategy.PRUNE,
"sliding_window": LegacyStrategy.SLIDING,
}
compactor.strategy = strategy_map.get(policy.strategy.value, LegacyStrategy.PRUNE)
# Map policy strategy to compactor strategy (single source of truth)
compactor.strategy = LegacyStrategy.from_policy_value(policy.strategy.value)
# Execute hooks
try:
@@ -1596,15 +1654,8 @@ Your Goal: {self.goal}"""
from ..hooks import HookEvent as _HookEvent
import logging
# Map policy strategy to compactor strategy
strategy_map = {
"truncate": LegacyStrategy.TRUNCATE,
"summarise": LegacyStrategy.SUMMARIZE,
"drop_oldest_tools": LegacyStrategy.PRUNE,
"sliding_window": LegacyStrategy.SLIDING,
}
compactor.strategy = strategy_map.get(policy.strategy.value, LegacyStrategy.PRUNE)
# Map policy strategy to compactor strategy (single source of truth)
compactor.strategy = LegacyStrategy.from_policy_value(policy.strategy.value)
# Execute hooks
try:
@@ -3345,6 +3396,36 @@ Your Goal: {self.goal}"""
# Append formatted knowledge to the prompt
prompt = f"{prompt}\n\n{formatted_context}"
# Sync llm_prompt with the retrieved context so the RAG block
# actually reaches the model. llm_prompt (not prompt) is what
# gets sent to the LLM in both the custom-LLM and OpenAI paths
# below; without this the retrieved knowledge is appended to
# `prompt` but never delivered, so the model answers from
# parametric memory only. Append (rather than reassign) to
# preserve any response-template instruction already baked into
# llm_prompt.
if isinstance(llm_prompt, str):
llm_prompt = f"{llm_prompt}\n\n{formatted_context}"
elif isinstance(llm_prompt, list):
# Multimodal prompt: append the retrieved context to the
# last text part so attachment-bearing turns still receive
# the RAG block. Copy dicts before mutating to avoid
# aliasing the caller's attachment structures; if no text
# part exists, add one.
appended = False
for i in range(len(llm_prompt) - 1, -1, -1):
item = llm_prompt[i]
if isinstance(item, dict) and item.get("type") == "text":
updated = dict(item)
updated["text"] = f"{updated.get('text', '')}\n\n{formatted_context}"
llm_prompt[i] = updated
appended = True
break
if not appended:
llm_prompt = list(llm_prompt) + [
{"type": "text", "text": formatted_context}
]
if self._using_custom_llm:
# Track messages THIS turn appends so a failure rolls back only our
# own messages, never a concurrent turn's (see memory_mixin).
@@ -3518,6 +3599,13 @@ Your Goal: {self.goal}"""
# Apply guardrail validation for custom LLM response
try:
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=processed_history)
# Coerce into the requested Pydantic model so callers
# get a validated instance, not a raw JSON string.
# Only output_pydantic changes the return type; output_json
# 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)
# 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)
@@ -3525,7 +3613,7 @@ Your Goal: {self.goal}"""
logging.error(f"Agent {self.name}: Guardrail validation failed for custom LLM: {e}")
# Rollback chat history on guardrail failure
self._rollback_chat_history_to(chat_history_length)
return None
return self._guardrail_blocked_message(e)
except ToolExecutionError:
raise
except Exception as e:
@@ -3562,41 +3650,48 @@ Your Goal: {self.goal}"""
)
# Track messages THIS turn appends so a failure rolls back only our
# own messages, never a concurrent turn's (see memory_mixin). The
# default (OpenAI) path used to skip this, so its positional rollback
# ``del chat_history[n:]`` erased a concurrent turn's interleaved
# messages and corrupted the shared transcript.
_turn_token = self._begin_turn_tracking()
# Store chat history length for potential rollback
chat_history_length = len(self.chat_history)
# Normalize original_prompt for consistent chat history storage
normalized_content = original_prompt
if isinstance(original_prompt, list):
# Extract text from multimodal prompts
normalized_content = next((item["text"] for item in original_prompt if item.get("type") == "text"), "")
# Prevent duplicate messages
if not (self.chat_history and
self.chat_history[-1].get("role") == "user" and
self.chat_history[-1].get("content") == normalized_content):
# Add user message to chat history BEFORE LLM call so handoffs can access it
self._append_to_chat_history({"role": "user", "content": normalized_content})
# Persist user message to DB (OpenAI path)
self._persist_message("user", normalized_content)
reflection_count = 0
start_time = time.time()
# Apply context management before LLM call (auto-compaction)
# Zero overhead when context=False
system_prompt_content = messages[0].get("content", "") if messages and messages[0].get("role") == "system" else ""
processed_messages, context_result = self._apply_context_management(
messages=messages,
system_prompt=system_prompt_content,
tools=tools,
)
# Use processed messages for the LLM call
messages = processed_messages
# Wrap entire while loop in try-except for rollback on any failure
# Wrap entire turn in try/finally so per-turn tracking is always
# ended (the finally reads the ownership list during rollback).
try:
# Normalize original_prompt for consistent chat history storage
normalized_content = original_prompt
if isinstance(original_prompt, list):
# Extract text from multimodal prompts
normalized_content = next((item["text"] for item in original_prompt if item.get("type") == "text"), "")
# Add user message to chat history BEFORE LLM call so handoffs can
# access it. Use atomic check-then-act to prevent TOCTOU races: an
# unatomic read of chat_history[-1] followed by a separate append
# lets a concurrent turn's user message be mistaken for this turn's
# own duplicate, silently dropping this user turn from the record.
if self._add_to_chat_history_if_not_duplicate("user", normalized_content):
# Persist user message to DB (OpenAI path)
self._persist_message("user", normalized_content)
reflection_count = 0
start_time = time.time()
# Apply context management before LLM call (auto-compaction)
# Zero overhead when context=False
system_prompt_content = messages[0].get("content", "") if messages and messages[0].get("role") == "system" else ""
processed_messages, context_result = self._apply_context_management(
messages=messages,
system_prompt=system_prompt_content,
tools=tools,
)
# Use processed messages for the LLM call
messages = processed_messages
while True:
try:
if self.verbose:
@@ -3642,6 +3737,11 @@ Your Goal: {self.goal}"""
# Apply guardrail validation even for JSON output
try:
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=messages)
# Coerce into the requested Pydantic model so callers
# 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)
# 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)
@@ -3649,7 +3749,7 @@ Your Goal: {self.goal}"""
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
# Rollback chat history on guardrail failure
self._rollback_chat_history_to(chat_history_length)
return None
return self._guardrail_blocked_message(e)
if not self.self_reflect:
# User message already added before LLM call via _build_messages
@@ -3670,7 +3770,7 @@ Your Goal: {self.goal}"""
logging.error(f"Agent {self.name}: Guardrail validation failed for reasoning content: {e}")
# Rollback chat history on guardrail failure
self._rollback_chat_history_to(chat_history_length)
return None
return self._guardrail_blocked_message(e)
# Apply guardrail to regular response
try:
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token, messages=messages)
@@ -3681,7 +3781,7 @@ Your Goal: {self.goal}"""
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
# Rollback chat history on guardrail failure
self._rollback_chat_history_to(chat_history_length)
return None
return self._guardrail_blocked_message(e)
reflection_prompt = f"""
Reflect on your previous response: '{response_text}'.
@@ -3749,7 +3849,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Rollback chat history on guardrail failure
self._rollback_chat_history_to(chat_history_length)
self._end_run(None, "error", {"error": str(e)})
return None
return self._guardrail_blocked_message(e)
# Check if we've hit max reflections
if reflection_count >= self.max_reflect - 1:
@@ -3767,7 +3867,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.error(f"Agent {self.name}: Guardrail validation failed after max reflections: {e}")
# Rollback chat history on guardrail failure
self._rollback_chat_history_to(chat_history_length)
return None
return self._guardrail_blocked_message(e)
# If not satisfactory and not at max reflections, continue with regeneration
logging.debug(f"{self.name} reflection count {reflection_count + 1}, continuing reflection process")
@@ -3814,6 +3914,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Rollback chat history
self._rollback_chat_history_to(chat_history_length)
return None
finally:
# End per-turn tracking after any rollback has run (rollback
# reads the ownership list, so this must come last).
self._end_turn_tracking(_turn_token)
def clean_json_output(self, output: str) -> str:
"""Clean JSON output while preserving the legacy agent method."""
@@ -4173,6 +4277,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Apply guardrail validation for custom LLM response
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id, messages=effective_history)
# Coerce into the requested Pydantic model so callers
# 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)
# Execute callback after validation
self._execute_callback_and_display(normalized_content, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(prompt, validated_response, start_time)
@@ -4180,7 +4289,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.error(f"Agent {self.name}: Guardrail validation failed for custom LLM: {e}")
# Rollback chat history on guardrail failure
self._rollback_chat_history_to(chat_history_length)
return None
return self._guardrail_blocked_message(e)
except ToolExecutionError:
raise
except Exception as e:
@@ -4206,6 +4315,15 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if durable_context is not None:
messages = await durable_context.arestore_messages(messages)
# Track messages THIS turn appends so a failure rolls back only our
# own messages, never a concurrent turn's (see memory_mixin). The
# default (OpenAI) path used to skip this, so its positional rollback
# ``del chat_history[n:]`` erased a concurrent turn's interleaved
# messages. Each achat() turn runs in its own task/context and
# _clear_turn_tracking() ran at the top of this turn, so this list is
# cleaned up when the next turn on this task starts.
self._begin_turn_tracking()
# Store chat history length for potential rollback
chat_history_length = len(self.chat_history)
@@ -4215,12 +4333,12 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Extract text from multimodal prompts
normalized_content = next((item["text"] for item in original_prompt if item.get("type") == "text"), "")
# Prevent duplicate messages
if not (self.chat_history and
self.chat_history[-1].get("role") == "user" and
self.chat_history[-1].get("content") == normalized_content):
# Add user message to chat history BEFORE LLM call so handoffs can access it
self._append_to_chat_history({"role": "user", "content": normalized_content})
# Add user message to chat history BEFORE LLM call so handoffs can
# access it. Use atomic check-then-act to prevent TOCTOU races: an
# unatomic read of chat_history[-1] followed by a separate append
# lets a concurrent turn's user message be mistaken for this turn's
# own duplicate, silently dropping this user turn from the record.
self._add_to_chat_history_if_not_duplicate("user", normalized_content)
# --- Proactive Context Budget Management (async standard OpenAI path) ---
try:
@@ -4309,6 +4427,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Apply guardrail validation even for JSON output
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, messages=messages)
# Coerce into the requested Pydantic model so callers
# 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)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
@@ -4316,7 +4439,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
# Rollback chat history on guardrail failure
self._rollback_chat_history_to(chat_history_length)
return None
return self._guardrail_blocked_message(e)
# For regular responses (no self-reflection)
if not self.self_reflect:
@@ -4339,7 +4462,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.error(f"Agent {self.name}: Guardrail validation failed for reasoning content: {e}")
# Rollback chat history on guardrail failure
self._rollback_chat_history_to(chat_history_length)
return None
return self._guardrail_blocked_message(e)
else:
# Apply guardrail to regular response content
try:
@@ -4351,7 +4474,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
# Rollback chat history on guardrail failure
self._rollback_chat_history_to(chat_history_length)
return None
return self._guardrail_blocked_message(e)
# If self-reflection is enabled, implement reflection logic
if self.self_reflect:
@@ -4391,7 +4514,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
# Rollback chat history on guardrail failure
self._rollback_chat_history_to(chat_history_length)
return None
return self._guardrail_blocked_message(e)
_areflection_kwargs = {}
if temperature is not None:
@@ -4425,7 +4548,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
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)
return None
return self._guardrail_blocked_message(e)
# Check if we've hit max reflections
if reflection_count >= self.max_reflect - 1:
@@ -4444,7 +4567,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.error(f"Agent {self.name}: Guardrail validation failed after max reflections: {e}")
# Rollback chat history on guardrail failure
self._rollback_chat_history_to(chat_history_length)
return None
return self._guardrail_blocked_message(e)
# Regenerate response based on reflection
regenerate_messages = reflection_messages + [
@@ -4494,7 +4617,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection error: {guard_e}")
# Rollback chat history on guardrail failure
self._rollback_chat_history_to(chat_history_length)
return None
return self._guardrail_blocked_message(guard_e)
continue
# This should never be reached due to the returns above
@@ -4544,6 +4667,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if get_logger().getEffectiveLevel() == logging.DEBUG:
total_time = time.time() - start_time
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
# Coerce into the requested Pydantic model so callers
# get a validated instance, not a raw JSON string.
# output_json intentionally stays a string (backward compat).
if output_pydantic:
response_text = self._coerce_structured_output(response_text, output_pydantic)
# Execute callback after JSON/Pydantic completion
self._execute_callback_and_display(original_prompt, response_text, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
@@ -4658,7 +4786,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.error(f"Agent {self.name}: Guardrail validation failed for OpenAI client: {e}")
# Rollback chat history on guardrail failure
self._rollback_chat_history_to(chat_history_length)
return None
return self._guardrail_blocked_message(e)
except ToolExecutionError:
raise
except Exception as e:
@@ -4682,6 +4810,16 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# dangling, unanswered user message (mirrors the sync path).
self._rollback_chat_history_to(chat_history_length)
return None
finally:
# Unregister this turn's ownership list from the process-wide
# live-turn registry once the turn finishes (success or failure).
# The sync path does this in _end_turn_tracking's finally; the async
# path previously relied only on the *next* turn's
# _clear_turn_tracking(), so a terminal achat/arun/astart left its
# completed ownership globally registered and a later ephemeral()
# cleanup treated its committed messages as still live. Rollback in
# the except blocks above already read ownership before this runs.
self._clear_turn_tracking()
async def _achat_completion(self, response, tools, reasoning_steps=False):
"""Async version of _chat_completion method"""
@@ -4694,13 +4832,43 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
for tool_call in message.tool_calls:
try:
function_name = tool_call.function.name
# Parse JSON arguments safely
# Parse JSON arguments safely. A parse failure is NOT {}: a
# truncated/malformed argument string must not silently run
# the tool with defaults and be reported as success. Surface
# a retryable tool-error to the model instead (mirrors
# llm.py's _tool_arguments_parse_failed handling).
from .tool_execution import (
_TOOL_ARGUMENTS_PARSE_FAILED,
tool_arguments_parse_failed,
tool_parse_error_message,
)
try:
arguments = json.loads(tool_call.function.arguments)
except json.JSONDecodeError as json_error:
arguments = json.loads(tool_call.function.arguments) if tool_call.function.arguments else {}
except (json.JSONDecodeError, TypeError) as json_error:
# TypeError: a custom client can hand back a non-text
# argument value, which json.loads rejects. Treat it the
# same as malformed JSON (mirrors llm.py's parser).
logging.error(f"Failed to parse tool arguments as JSON: {json_error}")
arguments = {}
arguments = _TOOL_ARGUMENTS_PARSE_FAILED
if tool_arguments_parse_failed(arguments):
# Report the failure to streaming observers before skipping
# execution, so a malformed call is never silently dropped
# (mirrors the sync streaming path).
self._notify_tool_call(
function_name,
{},
None,
elapsed_time=0.0,
success=False,
)
results.append(
tool_parse_error_message(
function_name,
getattr(tool_call, "id", None),
)["content"]
)
continue
# Find the matching tool by comparing every supported identifier:
# __name__ (plain callables), .name (BaseTool instances like
# BrowserBaseTool, or aliased FunctionTools), or the class name.
@@ -5287,13 +5455,43 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
for tool_call in tool_calls_data:
if tool_call['id'] and tool_call['function']['name']:
try:
# Parse JSON arguments safely
# Parse JSON arguments safely. A parse failure
# is NOT {}: a truncated/malformed argument
# string must not silently run the tool with
# defaults and report success. Surface a
# retryable tool-error to the model instead.
from .tool_execution import (
_TOOL_ARGUMENTS_PARSE_FAILED,
tool_arguments_parse_failed,
tool_parse_error_message,
)
try:
parsed_args = json.loads(tool_call['function']['arguments']) if tool_call['function']['arguments'] else {}
except json.JSONDecodeError as json_error:
except (json.JSONDecodeError, TypeError) as json_error:
# TypeError: a non-text argument value also
# cannot be parsed; treat it as malformed.
logging.error(f"Failed to parse tool arguments as JSON: {json_error}")
parsed_args = {}
parsed_args = _TOOL_ARGUMENTS_PARSE_FAILED
if tool_arguments_parse_failed(parsed_args):
self._notify_tool_call(
tool_call['function']['name'],
{},
None,
elapsed_time=0.0,
success=False,
)
_err_msg = tool_parse_error_message(
tool_call['function']['name'],
tool_call['id'],
)
self._append_to_chat_history(_err_msg)
self._persist_message(
"tool",
_err_msg["content"],
tool_call_id=tool_call['id'],
)
continue
executor = self._durable_sync_tool_executor(
self.execute_tool
)
@@ -5594,6 +5792,28 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
return llm_summarize_async
def _resolve_compaction_max_tokens(self, execution_cfg):
"""
Resolve the compaction trigger budget.
An explicit ``ExecutionConfig.max_context_tokens`` always wins. When it
is unset, size the trigger to the active model's context window (reusing
the model-aware ``budgeter`` the in-loop path already uses) instead of a
flat default, so large-context models are not compacted prematurely.
Falls back to a static default only for the offline / unknown-model case.
"""
_explicit = getattr(execution_cfg, 'max_context_tokens', None)
if _explicit:
return _explicit
try:
from ..context.budgeter import get_model_limit, get_output_reserve
model_name = self.llm if isinstance(self.llm, str) else "gpt-4o-mini"
limit = get_model_limit(model_name)
reserve = get_output_reserve(model_name)
return max(1, int(limit * 0.8) - reserve)
except Exception:
return 8000
def _apply_context_compaction(self, messages, hook_event_class):
"""
Apply context compaction to messages if enabled (sync version).
@@ -5613,7 +5833,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
from ..compaction import ContextCompactor
from ..compaction.strategy import CompactionStrategy
_max_tok = getattr(_execution_cfg, 'max_context_tokens', None) or 8000
_max_tok = self._resolve_compaction_max_tokens(_execution_cfg)
_strategy = getattr(_execution_cfg, 'compaction_strategy', None) or CompactionStrategy.TRUNCATE
# Create LLM summarization function if strategy is LLM_SUMMARIZE
@@ -5645,17 +5865,20 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Perform compaction
if _strategy == CompactionStrategy.LLM_SUMMARIZE and _llm_fn:
import asyncio
try:
# Run async compaction in event loop
compacted_msgs, _cr = asyncio.run(_compactor.compact_async(messages))
except RuntimeError:
# If already in async context, fall back to sync (naive) compaction
logging.warning(
f"[compaction] {self.name}: LLM_SUMMARIZE fell back to naive summarization "
f"(asyncio.run not available in sync context)"
)
compacted_msgs, _cr = _compactor.compact(messages)
# Drive the async summariser through the shared bridge so it runs
# correctly whether or not a loop is already running (FastAPI /
# Jupyter / a bot handler). The previous code caught bare
# RuntimeError from ``asyncio.run`` ("event loop already
# running") and silently downgraded LLM summarisation to naive
# truncation whenever a loop was live; the bridge removes that
# spurious fallback so the real summariser always runs. Provider
# errors inside the summariser remain best-effort (see
# ``_llm_summarize_async``, which falls back to a naive summary on
# failure) — compaction never hard-fails the turn.
from ..utils.async_bridge import run_coroutine_from_any_context
compacted_msgs, _cr = run_coroutine_from_any_context(
_compactor.compact_async(messages)
)
else:
compacted_msgs, _cr = _compactor.compact(messages)
@@ -5700,7 +5923,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
from ..compaction import ContextCompactor
from ..compaction.strategy import CompactionStrategy
_max_tok = getattr(_execution_cfg, 'max_context_tokens', None) or 8000
_max_tok = self._resolve_compaction_max_tokens(_execution_cfg)
_strategy = getattr(_execution_cfg, 'compaction_strategy', None) or CompactionStrategy.TRUNCATE
# Create LLM summarization function if strategy is LLM_SUMMARIZE
@@ -31,23 +31,66 @@ from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
class _DynamicLimiter:
"""Resizable counting limiter: capacity can change with holders outstanding.
Unlike swapping a fresh threading.Semaphore on every retune (which lets
holders of the old primitive and the release() path operate on different
objects), this keeps one long-lived object per agent name and mutates its
capacity in place. Every acquirer and every release() therefore always
operate on the same shared state, so a retune takes effect immediately and
the configured cap can never be silently exceeded. Loop-neutral: uses a
threading.Condition so it is a true global cap across event loops/threads.
"""
def __init__(self, limit: int):
self._limit = limit
self._held = 0
self._cond = threading.Condition()
def set_limit(self, limit: int) -> None:
with self._cond:
self._limit = limit
self._cond.notify_all()
def acquire(self, blocking: bool = True) -> bool:
with self._cond:
while self._limit > 0 and self._held >= self._limit:
if not blocking:
return False
self._cond.wait(timeout=0.05)
self._held += 1
return True
def release(self) -> None:
with self._cond:
self._held = max(0, self._held - 1)
self._cond.notify_all()
def has_holders(self) -> bool:
with self._cond:
return self._held > 0
class ConcurrencyRegistry:
"""Registry for per-agent concurrency limits.
Thread-safe. Each agent name maps to a loop-neutral threading.Semaphore, so
the limit is a true global cap across every event loop and thread.
Thread-safe. Each agent name maps to a loop-neutral, resizable limiter, so
the limit is a true global cap across every event loop and thread and a
retune takes effect immediately for in-flight holders.
Limit of 0 means unlimited (no throttling).
"""
def __init__(self, default_limit: int = 0):
self._default_limit = default_limit
self._limits: Dict[str, int] = {}
# One loop-neutral threading.Semaphore per agent. Unlike asyncio.Semaphore
# (which binds to the loop it is first awaited on), a threading.Semaphore
# is shared safely across every event loop and thread, so the per-agent
# limit is a true global cap under multi-loop / multi-thread deployments.
# Async callers acquire it in an executor so they never block their loop.
self._semaphores: Dict[str, threading.Semaphore] = {}
# One long-lived, loop-neutral _DynamicLimiter per agent. Its capacity is
# mutated in place on retune (never swapped for a fresh object), so every
# acquirer and every release() always share the same state and the
# configured cap can never be silently exceeded. Loop-neutral means the
# per-agent limit is a true global cap under multi-loop / multi-thread
# deployments; async callers poll without blocking their loop.
self._limiters: Dict[str, _DynamicLimiter] = {}
self._lock = threading.Lock()
def set_limit(self, agent_name: str, max_concurrent: int) -> None:
@@ -59,8 +102,11 @@ class ConcurrencyRegistry:
"""
with self._lock:
self._limits[agent_name] = max_concurrent
# Reset semaphore so next acquire creates a fresh one at the new limit
self._semaphores.pop(agent_name, None)
# Mutate the existing limiter in place so the retune takes effect
# immediately for in-flight holders without ever swapping the object.
limiter = self._limiters.get(agent_name)
if limiter is not None:
limiter.set_limit(max_concurrent)
def get_limit(self, agent_name: str) -> int:
"""Get concurrency limit for an agent."""
@@ -68,39 +114,57 @@ class ConcurrencyRegistry:
return self._limits.get(agent_name, self._default_limit)
def remove_limit(self, agent_name: str) -> None:
"""Remove concurrency limit for an agent (reverts to default)."""
"""Remove concurrency limit for an agent (reverts to default).
The limiter object is kept alive while any permit is still held, so
in-flight acquire()/release() pairs keep operating on the same shared
state; dropping it mid-flight would strand those permits and let a
later set_limit() start from a fresh, empty count and admit work beyond
the configured cap. Once no permits are outstanding it is safe to drop.
"""
with self._lock:
self._limits.pop(agent_name, None)
self._semaphores.pop(agent_name, None)
# Retune the surviving limiter to unlimited so it stops blocking
# while remaining the single accounting object for its holders.
limiter = self._limiters.get(agent_name)
if limiter is None:
return
limiter.set_limit(0)
if not limiter.has_holders():
self._limiters.pop(agent_name, None)
def _get_semaphore(self, agent_name: str) -> Optional[threading.Semaphore]:
"""Get or create the loop-neutral semaphore for an agent.
def _get_limiter(self, agent_name: str) -> Optional[_DynamicLimiter]:
"""Get or create the loop-neutral limiter for an agent.
Returns None if unlimited. The same threading.Semaphore is shared across
every event loop and thread, so the configured limit is a true global cap.
Returns None only when the agent has never been given a limit (pure
unlimited fast path). Once a limiter exists it is always returned — even
while unlimited (limit 0) — so acquire() and release() stay symmetric
across a mid-flight retune to/from unlimited and the count can never be
corrupted. The same _DynamicLimiter is shared across every event loop
and thread, so a configured limit is a true global cap.
"""
with self._lock:
limit = self._limits.get(agent_name, self._default_limit)
if limit <= 0:
return None
sem = self._semaphores.get(agent_name)
if sem is None:
sem = threading.Semaphore(limit)
self._semaphores[agent_name] = sem
return sem
limiter = self._limiters.get(agent_name)
if limiter is None:
if limit <= 0:
return None
limiter = _DynamicLimiter(limit)
self._limiters[agent_name] = limiter
return limiter
async def acquire(self, agent_name: str) -> None:
"""Acquire concurrency slot for agent. No-op if unlimited.
Waits on the loop-neutral semaphore in short, cancellable polls so the
Waits on the loop-neutral limiter in short, cancellable polls so the
running event loop is never blocked while other tasks hold permits, and
a cancelled/timed-out await never leaves a thread blocked on acquire().
"""
sem = self._get_semaphore(agent_name)
if sem is None:
limiter = self._get_limiter(agent_name)
if limiter is None:
return
while True:
if sem.acquire(blocking=False):
if limiter.acquire(blocking=False):
return
# Yield to the loop; on cancellation this raises and no permit leaks.
await asyncio.sleep(0.005)
@@ -110,21 +174,18 @@ class ConcurrencyRegistry:
Prefer async acquire() when possible. Blocks the calling thread until a
permit is available. Safe to call whether or not a loop is running in the
current thread, since the semaphore is loop-neutral.
current thread, since the limiter is loop-neutral.
"""
sem = self._get_semaphore(agent_name)
if sem is not None:
sem.acquire()
limiter = self._get_limiter(agent_name)
if limiter is not None:
limiter.acquire()
def release(self, agent_name: str) -> None:
"""Release concurrency slot for agent. No-op if unlimited."""
with self._lock:
sem = self._semaphores.get(agent_name)
if sem is not None:
try:
sem.release()
except ValueError:
pass # Already fully released
limiter = self._limiters.get(agent_name)
if limiter is not None:
limiter.release()
@asynccontextmanager
async def throttle(self, agent_name: str):
@@ -58,8 +58,8 @@ def save_cost_report(
}
filepath = COST_DIR / f"{safe_name}.json"
with open(filepath, "w") as f:
json.dump(report, f, indent=2)
from ..utils.atomic_io import atomic_write_json
atomic_write_json(str(filepath), report, indent=2)
logger.info(f"[costs] Saved cost report to {filepath}")
return str(filepath)
@@ -898,14 +898,18 @@ You are a professional research analyst. When conducting research:
if grounding_file_prompt:
create_params["grounding_file_prompt"] = grounding_file_prompt
interaction = self.gemini_client.interactions.create(**create_params)
interaction = await asyncio.to_thread(
self.gemini_client.interactions.create, **create_params
)
if self.verbose:
self.logger.debug(f"Gemini research started (async): {interaction.id}")
start_time = time.time()
while True:
interaction = self.gemini_client.interactions.get(interaction.id)
interaction = await asyncio.to_thread(
self.gemini_client.interactions.get, interaction.id
)
if interaction.status == "completed":
if self.verbose:
@@ -22,49 +22,28 @@ logger = logging.getLogger(__name__)
from typing import List, Optional, Any, Dict, Union, Generator, TYPE_CHECKING
# Shared HTTP launch state (Agent.launch() multi-endpoint registration)
_server_lock = threading.Lock()
_server_started: Dict[int, bool] = {}
_registered_agents: Dict[int, Dict[str, str]] = {}
_shared_apps: Dict[int, Any] = {}
# Shared HTTP launch state — the process-wide registry is defined in
# agent/server_registry.py and shared with PraisonAIAgents.launch() so a
# standalone Agent and a team launched on the same port cannot double-bind it or
# hide each other's endpoints.
from .server_registry import _server_registry
def cleanup_launch_registration(agent_id: str) -> None:
"""Remove launch() endpoints registered for ``agent_id`` from the shared
HTTP state that ``Agent.launch()`` actually populates.
``Agent.launch()`` writes routes into the module-level ``_registered_agents``
and ``_shared_apps`` dicts above. This tears those routes back down so a
closed agent's endpoint stops accepting requests and the request handler
closure no longer keeps the Agent object graph alive.
``Agent.launch()`` reserves routes on the shared ``_server_registry``. This
tears those routes back down so a closed agent's endpoint stops accepting
requests and the request handler closure no longer keeps the Agent object
graph alive.
The endpoint-registry removal and the matching FastAPI route teardown are
performed together under the registry lock (see
``_AgentServerRegistry.teardown_routes_for``) so cleanup cannot race a
concurrent launch that reserves and registers a route on the same port.
"""
with _server_lock:
for port, paths in list(_registered_agents.items()):
stale_paths = [p for p, aid in paths.items() if aid == agent_id]
for p in stale_paths:
del paths[p]
app = _shared_apps.get(port)
if app is not None:
try:
# Only drop the agent-owned POST route. launch() always
# registers the handler via ``.post(path)``, so filtering
# on both path AND method preserves unrelated routes that
# share the path with a different verb (e.g. the built-in
# GET /health and GET /).
app.router.routes = [
r for r in app.router.routes
if not (
getattr(r, "path", None) == p
and "POST" in (getattr(r, "methods", None) or set())
)
]
# Invalidate cached OpenAPI schema so removed routes
# disappear from /openapi.json and /docs.
app.openapi_schema = None
except Exception:
pass
if not paths:
_registered_agents.pop(port, None)
_server_registry.teardown_routes_for(agent_id)
if TYPE_CHECKING:
pass
@@ -221,8 +200,12 @@ class ExecutionMixin:
response = await self.achat(prompt, **kwargs)
response_str = str(response) if response else ""
# Auto-save session after chat
self._auto_save_session()
# Auto-save session after chat. Offloaded to a worker thread so
# the synchronous FileLock/JSON read-modify-write never stalls
# the shared event loop (the session store is a process-wide
# singleton, so a blocking write here blocks every concurrent
# async agent task on the same loop).
await asyncio.to_thread(self._auto_save_session)
# Wrap in AutonomyResult for consistent API
from .autonomy import AutonomyResult
@@ -493,7 +476,8 @@ class ExecutionMixin:
extra={"session_id": sid, "managed_session_id": msid},
exc_info=True,
)
self._auto_save_session()
# Offload the blocking session write off the event loop.
await asyncio.to_thread(self._auto_save_session)
return result
@@ -1754,12 +1738,17 @@ Write the complete compiled report:"""
# Policy/guardrail gate (protocol-driven). Mirrors the sync path in
# _execute_tool_impl so async callers cannot bypass a PolicyEngine
# deny or a tool-call guardrail. The check is pure/sync (no awaits).
# deny or a tool-call guardrail. A user-supplied guardrail/policy may
# do blocking work (an LLM moderation call, a DB/network lookup), so
# offload to a worker thread rather than run it inline and stall the
# event loop for every other coroutine sharing it.
check = getattr(self, "_check_tool_policy_and_guardrails", None)
if check is not None:
# ``tools_override`` is threaded through so a run-scoped tool
# list cannot bypass the per-tool guardrails its tools declare.
policy_result = check(function_name, arguments, tools_override)
policy_result = await asyncio.to_thread(
check, function_name, arguments, tools_override
)
if isinstance(policy_result, dict):
return policy_result # Error dict
_, arguments = policy_result
@@ -1786,9 +1775,12 @@ Write the complete compiled report:"""
# Tool-result guardrail gate — the native path applies this
# below, but the MCP branch returns early, so gate here too
# or successful MCP output would bypass validation entirely.
# Offloaded to a thread: a user guardrail may block on I/O.
apply_result_guardrails = getattr(self, "_apply_tool_result_guardrails", None)
if apply_result_guardrails is not None:
mcp_result = apply_result_guardrails(function_name, mcp_result)
mcp_result = await asyncio.to_thread(
apply_result_guardrails, function_name, mcp_result
)
return mcp_result
# Try to find the function in the override tools list first, then agent's tools list.
@@ -1798,7 +1790,10 @@ Write the complete compiled report:"""
func = None
tools_to_search = tools_override if tools_override is not None else self.tools
from ..tools.base import BaseTool
plugin_owners = getattr(self, "_plugin_tool_owners", None)
for tool in tools_to_search:
if plugin_owners and not self._is_plugin_tool_active(tool):
continue
if isinstance(tool, BaseTool) and getattr(tool, 'name', None) == function_name:
func = tool
break
@@ -1893,6 +1888,21 @@ Write the complete compiled report:"""
from ..streaming.events import tool_progress_channel
# breaker.acall() records the invocation outcome itself. Both record
# sites below then recorded the same failure a SECOND time -- once
# off the returned error dict, once in the raised-exception handler
# -- so every async tool failure counted twice and the breaker
# opened after ceil(threshold/2) calls (3 instead of the configured
# 5), out of parity with the sync path. This flag marks that the
# breaker already owns the outcome; it stays False when
# asyncio.wait_for times out, since the breaker never got a result.
#
# Declared outside the try: the except handler below reads it, and
# roughly seventy lines run between the try and the breaker setup.
# An exception in that window would otherwise raise NameError here
# and mask the original failure.
breaker_saw_outcome = {"done": False}
try:
# BaseTool instances (plugin system, e.g. BrowserBaseTool) are not
# directly callable — dispatch to their .run() method like the sync path.
@@ -2018,6 +2028,12 @@ Write the complete compiled report:"""
async def _invoke_guarded():
if breaker is None:
return await _invoke()
# Set before the await: acall records an outcome however it
# exits (returning, raising _ToolFailure, or letting a raw
# tool exception through). The only exit that records
# nothing is _CircuitBreakerException, and that path returns
# _circuit_open_result() without reaching either record site.
breaker_saw_outcome["done"] = True
try:
return await breaker.acall(_invoke_for_breaker)
except _ToolFailure as tf:
@@ -2125,9 +2141,14 @@ Write the complete compiled report:"""
# sync path in tool_execution.py. Runs on the raw result before it
# re-enters the LLM context so a guardrail can inspect or redact
# unsafe tool output. Fail-closed. Zero overhead when unset.
# Offloaded to a thread: a user guardrail may block on I/O (an LLM
# moderation call, a network lookup), which would otherwise stall
# the event loop for every other coroutine sharing it.
apply_result_guardrails = getattr(self, "_apply_tool_result_guardrails", None)
if apply_result_guardrails is not None:
result = apply_result_guardrails(function_name, result, tools_override)
result = await asyncio.to_thread(
apply_result_guardrails, function_name, result, tools_override
)
# Loop guard (post-execution) — record the outcome and surface a
# block/halt decision back to the model on this same turn, mirroring
@@ -2250,18 +2271,13 @@ Write the complete compiled report:"""
NOTE: This implementation will be moved to wrapper layer in future version.
For now, it maintains backward compatibility while following lazy import patterns.
"""
global _server_started, _registered_agents, _shared_apps, _server_lock
from .launch_security import authorise_launch_request, resolve_launch_host
# Try to import FastAPI dependencies - lazy loading
try:
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
import threading
import time
import asyncio
# Define the request model here since we need pydantic
@@ -2287,130 +2303,102 @@ Write the complete compiled report:"""
# process-wide launch token and retroactively 401 a running endpoint.
host = resolve_launch_host(host)
should_start = False
with _server_lock:
# Initialize port-specific collections if needed (once per port)
if port not in _registered_agents:
_registered_agents[port] = {}
# Get or create the shared FastAPI app for this port (shared with
# PraisonAIAgents.launch via the same _server_registry singleton).
app, is_new_app = _server_registry.get_or_create_app(
port,
title=f"PraisonAI Agents API (Port {port})",
)
# Initialize shared FastAPI app if not already created for this port
if _shared_apps.get(port) is None:
_shared_apps[port] = FastAPI(
title=f"PraisonAI Agents API (Port {port})",
description="API for interacting with PraisonAI Agents"
if is_new_app:
# Add a root endpoint with a welcome message
@app.get("/")
async def root():
return {
"message": f"Welcome to PraisonAI Agents API on port {port}. See /docs for usage.",
"endpoints": _server_registry.list_routes(port)
}
# Add healthcheck endpoint
@app.get("/health")
async def healthcheck():
return {
"status": "ok",
"endpoints": _server_registry.list_routes(port)
}
# Normalize path to ensure it starts with /
if not path.startswith('/'):
path = f'/{path}'
# Atomically reserve the route; on collision the registry returns a
# de-duplicated path so multiple agents can share a single port.
path, original_path = _server_registry.reserve_route(port, path, self.agent_id)
if original_path is not None:
logging.warning(f"Path '{original_path}' is already registered on port {port}.")
print(f"⚠️ Warning: Path '{original_path}' is already registered on port {port}.")
print(f"🔄 Using '{path}' instead")
# Define the endpoint handler
@app.post(path)
async def handle_agent_query(request: Request, query_data: Optional[AgentQuery] = None):
if not authorise_launch_request(request):
raise HTTPException(status_code=401, detail="Unauthorized")
# Handle both direct JSON with query field and form data
if query_data is None:
try:
request_data = await request.json()
if "query" not in request_data:
raise HTTPException(status_code=400, detail="Missing 'query' field in request")
query = request_data["query"]
except Exception:
# Fallback to form data or query params
form_data = await request.form()
if "query" in form_data:
query = form_data["query"]
else:
raise HTTPException(status_code=400, detail="Missing 'query' field in request")
else:
query = query_data.query
try:
# Use async version if available, otherwise use sync version
if asyncio.iscoroutinefunction(self.chat):
response = await self.achat(query, task_name=None, task_description=None, task_id=None)
else:
# Run sync function in a thread to avoid blocking
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(None, lambda p=query: self.chat(p))
return {"response": response}
except Exception as e:
logging.error(f"Error processing query: {str(e)}", exc_info=True)
return JSONResponse(
status_code=500,
content={"error": f"Error processing query: {str(e)}"}
)
# Add a root endpoint with a welcome message
@_shared_apps[port].get("/")
async def root():
return {
"message": f"Welcome to PraisonAI Agents API on port {port}. See /docs for usage.",
"endpoints": list(_registered_agents[port].keys())
}
# Invalidate the cached OpenAPI schema so routes registered by later
# shared-port launch() calls still show up in /openapi.json and /docs.
# FastAPI caches app.openapi_schema on first access and does not
# regenerate it when new routes are added afterwards.
app.openapi_schema = None
# Add healthcheck endpoint
@_shared_apps[port].get("/health")
async def healthcheck():
return {
"status": "ok",
"endpoints": list(_registered_agents[port].keys())
}
print(f"🚀 Agent '{self.name}' available at http://{host}:{port}")
# The path registration below must run on EVERY call, not just when the
# port is new, so multiple agents can share a single port.
# Normalize path to ensure it starts with /
if not path.startswith('/'):
path = f'/{path}'
# Check if path is already registered for this port
if path in _registered_agents[port]:
logging.warning(f"Path '{path}' is already registered on port {port}. Please use a different path.")
print(f"⚠️ Warning: Path '{path}' is already registered on port {port}.")
# Use a modified path to avoid conflicts
original_path = path
path = f"{path}_{self.agent_id[:6]}"
logging.warning(f"Using '{path}' instead of '{original_path}'")
print(f"🔄 Using '{path}' instead")
# Register the agent to this path
_registered_agents[port][path] = self.agent_id
# Define the endpoint handler
@_shared_apps[port].post(path)
async def handle_agent_query(request: Request, query_data: Optional[AgentQuery] = None):
if not authorise_launch_request(request):
raise HTTPException(status_code=401, detail="Unauthorized")
# Handle both direct JSON with query field and form data
if query_data is None:
try:
request_data = await request.json()
if "query" not in request_data:
raise HTTPException(status_code=400, detail="Missing 'query' field in request")
query = request_data["query"]
except Exception:
# Fallback to form data or query params
form_data = await request.form()
if "query" in form_data:
query = form_data["query"]
else:
raise HTTPException(status_code=400, detail="Missing 'query' field in request")
else:
query = query_data.query
try:
# Use async version if available, otherwise use sync version
if asyncio.iscoroutinefunction(self.chat):
response = await self.achat(query, task_name=None, task_description=None, task_id=None)
else:
# Run sync function in a thread to avoid blocking
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(None, lambda p=query: self.chat(p))
return {"response": response}
except Exception as e:
logging.error(f"Error processing query: {str(e)}", exc_info=True)
return JSONResponse(
status_code=500,
content={"error": f"Error processing query: {str(e)}"}
)
# Invalidate the cached OpenAPI schema so routes registered by later
# shared-port launch() calls still show up in /openapi.json and /docs.
# FastAPI caches app.openapi_schema on first access and does not
# regenerate it when new routes are added afterwards.
_shared_apps[port].openapi_schema = None
print(f"🚀 Agent '{self.name}' available at http://{host}:{port}")
# Check and mark server as started atomically to prevent race conditions
should_start = not _server_started.get(port, False)
if should_start:
_server_started[port] = True
# Server start/wait outside the lock to avoid holding it during sleep
if should_start:
# Start the server in a separate thread
def run_server():
try:
print(f"✅ FastAPI server started at http://{host}:{port}")
print(f"📚 API documentation available at http://{host}:{port}/docs")
print(f"🔌 Available endpoints: {', '.join(list(_registered_agents[port].keys()))}")
uvicorn.run(_shared_apps[port], host=host, port=port, log_level="debug" if debug else "info")
except Exception as e:
logging.error(f"Error starting server: {str(e)}", exc_info=True)
print(f"❌ Error starting server: {str(e)}")
# Run server in a background thread
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
# Wait for a moment to allow the server to start and register endpoints
self._safe_sleep(0.5)
else:
# If server is already running, wait a moment to make sure the endpoint is registered
self._safe_sleep(0.1)
print(f"🔌 Available endpoints on port {port}: {', '.join(list(_registered_agents[port].keys()))}")
# Start the shared server for this port if it is not already running.
# start_server_if_needed atomically checks-and-marks the port and blocks
# on the registry's readiness Event (configurable via
# PRAISONAI_SERVER_READY_TIMEOUT) instead of a heuristic sleep, matching
# PraisonAIAgents.launch().
started = _server_registry.start_server_if_needed(
port, host, log_level="debug" if debug else "info"
)
endpoints = _server_registry.list_routes(port)
if started:
print(f"✅ FastAPI server started at http://{host}:{port}")
print(f"📚 API documentation available at http://{host}:{port}/docs")
print(f"🔌 Available endpoints on port {port}: {', '.join(endpoints)}")
# Get the stack frame to check if this is the last launch() call in the script
import inspect
@@ -569,17 +569,13 @@ class Handoff:
handoffs to the same agent serialize on the event loop instead of
blocking it, keeping each seeded ``achat`` turn atomic.
``DualLock`` keeps its sync (threading ``RLock``) and async (per-loop
``asyncio.Lock``) sides independent, so the async lock alone would NOT
exclude a concurrent *synchronous* handoff running on another thread —
both could seed/restore the same shared ``chat_history`` at once, and a
cross-thread sync seed landing *between* our apply and restore would
corrupt the awaited turn. To close that gap we ALSO hold the sync
thread-lock for the whole seeded turn. The async lock (acquired first)
has already serialized every same-loop async handoff to this target, so
the thread-lock here only ever contends with a genuine cross-thread sync
handoff to the same agent — exactly the case that must be mutually
excluded — which is a rare mixed sync+async fan-out to one shared target.
``DualLock`` backs ``sync()`` and ``async_lock()`` onto one shared mutex,
so ``async_lock()`` alone already excludes a concurrent *synchronous*
handoff on another thread. We still take the inner ``sync()`` so the
seed/restore reads as one explicit critical section; because
``async_lock()`` registers the event-loop thread as the mutex owner, that
nested ``sync()`` is treated as re-entrant on the same thread instead of
self-deadlocking against the mutex the async side already holds.
"""
lock = _get_handoff_seed_lock(self.agent)
async with lock.async_lock():
@@ -3,6 +3,7 @@ ImageAgent - A specialized agent class for generating images using AI models.
This class extends the base Agent class to provide specific functionality for image generation,
including support for different image models, sizes, and quality settings.
"""
import asyncio
from typing import Optional, Any, Dict, Union, List
from ..agent.agent import Agent
from pydantic import BaseModel, Field
@@ -180,8 +181,8 @@ class ImageAgent(Agent):
logging.error(error_msg)
raise
async def agenerate_image(self, prompt: str, **kwargs) -> Dict[str, Any]:
"""Async wrapper for generate_image."""
return self.generate_image(prompt, **kwargs)
"""Offload the synchronous generation path to keep the event loop responsive."""
return await asyncio.to_thread(self.generate_image, prompt, **kwargs)
# Aliases for consistency with other agents
def generate(self, prompt: str, **kwargs) -> Dict[str, Any]:
@@ -190,7 +191,7 @@ class ImageAgent(Agent):
async def agenerate(self, prompt: str, **kwargs) -> Dict[str, Any]:
"""Async alias for generate_image()."""
return self.generate_image(prompt, **kwargs)
return await self.agenerate_image(prompt, **kwargs)
def chat(self, prompt: str, **kwargs) -> Dict[str, Any]:
"""Generate an image from the prompt."""
@@ -302,8 +303,8 @@ class ImageAgent(Agent):
size: Optional[str] = None,
**kwargs
) -> Dict[str, Any]:
"""Async version of edit()."""
return self.edit(image, prompt, mask, n, size, **kwargs)
"""Offload the synchronous editing path to keep the event loop responsive."""
return await asyncio.to_thread(self.edit, image, prompt, mask, n, size, **kwargs)
def variation(
self,
@@ -372,6 +373,5 @@ class ImageAgent(Agent):
size: Optional[str] = None,
**kwargs
) -> Dict[str, Any]:
"""Async version of variation()."""
return self.variation(image, n, size, **kwargs)
"""Offload the synchronous variation path to keep the event loop responsive."""
return await asyncio.to_thread(self.variation, image, n, size, **kwargs)
@@ -764,6 +764,50 @@ class MemoryMixin:
except Exception as e:
logging.debug(f"Auto-memory extraction failed: {e}")
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.
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),
the write is offloaded to a worker thread so it never stalls the loop
(AGENTS.md §4.5). On the sync path (no running loop) it writes inline.
"""
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:
return
text = f"User: {user_message}\nAssistant: {assistant_response}"
metadata = {"agent_id": getattr(self, "agent_id", getattr(self, "name", None))}
def _do_store():
try:
store(text, metadata=metadata)
except Exception as e:
logging.debug(f"Memory turn persistence failed: {e}")
try:
import asyncio
asyncio.get_running_loop()
except RuntimeError:
# No running loop (sync chat/run/start path): write inline.
_do_store()
return
# Inside a live loop: offload the blocking write so it cannot stall the
# event loop. Fire-and-forget — persistence is best-effort.
try:
import asyncio
asyncio.get_running_loop().run_in_executor(None, _do_store)
except Exception as e:
logging.debug(f"Memory turn persistence scheduling failed: {e}")
_do_store()
def _process_auto_learning(self):
"""Process auto-learning extraction after agent response.
@@ -884,45 +928,56 @@ class MemoryMixin:
if not self.auto_save:
return
try:
# Filter out history markers before saving
clean_history = [
{k: v for k, v in msg.items() if k != "_from_history"}
for msg in self.chat_history
]
# Issue 1 FIX: Only save NEW messages since last save
# Track last saved index to avoid duplicates
last_saved = getattr(self, '_auto_save_last_index', 0)
new_messages = clean_history[last_saved:]
if not new_messages:
return # Nothing new to save
# G-1 FIX: Use SessionStore for conversation history persistence
# This maintains clean separation: Memory = facts, SessionStore = turns
if self._session_store is not None:
# Persist only NEW messages to SessionStore
for msg in new_messages:
self._session_store.add_message(
self.auto_save, # Use auto_save name as session_id
role=msg.get("role", "user"),
content=msg.get("content", ""),
# Serialize the whole read-modify-write of _auto_save_last_index. This
# method now runs via asyncio.to_thread, so two concurrent async runs
# sharing one Agent can execute it in separate worker threads. Without
# this lock both could read the same last-saved index, then each persist
# the same new turns, duplicating conversation history — the store's
# file lock serializes the writes but does not protect this index.
lock = getattr(self, '_auto_save_lock', None)
if lock is None:
lock = threading.Lock()
self._auto_save_lock = lock
with lock:
try:
# Filter out history markers before saving
clean_history = [
{k: v for k, v in msg.items() if k != "_from_history"}
for msg in self.chat_history
]
# Issue 1 FIX: Only save NEW messages since last save
# Track last saved index to avoid duplicates
last_saved = getattr(self, '_auto_save_last_index', 0)
new_messages = clean_history[last_saved:]
if not new_messages:
return # Nothing new to save
# G-1 FIX: Use SessionStore for conversation history persistence
# This maintains clean separation: Memory = facts, SessionStore = turns
if self._session_store is not None:
# Persist only NEW messages to SessionStore
for msg in new_messages:
self._session_store.add_message(
self.auto_save, # Use auto_save name as session_id
role=msg.get("role", "user"),
content=msg.get("content", ""),
)
# Update last saved index
self._auto_save_last_index = len(clean_history)
logging.debug(f"Auto-saved {len(new_messages)} new messages to SessionStore: {self.auto_save}")
elif self._memory_instance and hasattr(self._memory_instance, 'save_session'):
# Fallback to Memory.save_session() for backward compatibility
# Memory.save_session() replaces entire history, so no duplicate issue
self._memory_instance.save_session(
name=self.auto_save,
conversation_history=clean_history,
metadata={"agent_name": self.name, "user_id": self.user_id}
)
# Update last saved index
self._auto_save_last_index = len(clean_history)
logging.debug(f"Auto-saved {len(new_messages)} new messages to SessionStore: {self.auto_save}")
elif self._memory_instance and hasattr(self._memory_instance, 'save_session'):
# Fallback to Memory.save_session() for backward compatibility
# Memory.save_session() replaces entire history, so no duplicate issue
self._memory_instance.save_session(
name=self.auto_save,
conversation_history=clean_history,
metadata={"agent_name": self.name, "user_id": self.user_id}
)
logging.debug(f"Auto-saved session to Memory: {self.auto_save}")
except Exception as e:
logging.debug(f"Error auto-saving session: {e}")
logging.debug(f"Auto-saved session to Memory: {self.auto_save}")
except Exception as e:
logging.debug(f"Error auto-saving session: {e}")
def _save_output_to_file(self, content: str) -> bool:
"""Save agent output to file if output_file is configured.
@@ -0,0 +1,226 @@
"""
Shared HTTP server registry for launch() paths.
Both ``Agent.launch()`` (single-agent) and ``PraisonAIAgents.launch()``
(multi-agent) let multiple ``launch()`` calls share one FastAPI app / uvicorn
server per port. They MUST consult the *same* process-wide registry so a
standalone agent and a team launched on the same port see each other's state:
one "is this port started?" check, one FastAPI app, one set of routes. Keeping
two independent registries caused duplicate binds (``Address already in use``)
and routes registered through one launcher being invisible on the other's app.
"""
import os
import threading
import time
import uuid
from typing import Any, Dict, List, Optional, Tuple
from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
class _AgentServerRegistry:
"""Encapsulates all shared HTTP server state with proper synchronization."""
def __init__(self):
self._lock = threading.Lock()
self._started: Dict[int, bool] = {}
self._endpoints: Dict[int, Dict[str, str]] = {}
self._apps: Dict[int, Any] = {} # FastAPI apps
self._ready_events: Dict[int, threading.Event] = {}
def get_or_create_app(self, port: int, title: str = "AgentTeam API") -> Tuple[Any, bool]:
"""Thread-safe app creation. Returns (app, is_new)."""
with self._lock:
if port not in self._apps:
# Lazy import to avoid optional dependency at module level
from fastapi import FastAPI
self._apps[port] = FastAPI(title=title)
self._endpoints[port] = {}
self._ready_events[port] = threading.Event()
return self._apps[port], True
return self._apps[port], False
def register_route(self, port: int, path: str, endpoint_id: str = "registered") -> None:
"""Thread-safe route registration tracking."""
with self._lock:
if port not in self._endpoints:
self._endpoints[port] = {}
self._endpoints[port][path] = endpoint_id
def reserve_route(self, port: int, path: str, endpoint_id: str) -> Tuple[str, Optional[str]]:
"""Atomically reserve and register a route.
Returns:
tuple[str, Optional[str]]: (final_path, original_path_if_collided).
If there is no collision, final_path equals the requested path and
original_path_if_collided is None.
"""
with self._lock:
if port not in self._endpoints:
self._endpoints[port] = {}
original_path = path
while path in self._endpoints[port]:
path = f"{original_path}_{str(uuid.uuid4())[:6]}"
self._endpoints[port][path] = endpoint_id
return path, (original_path if path != original_path else None)
def list_routes(self, port: int) -> List[str]:
"""Return a snapshot list of registered routes for a port."""
with self._lock:
return list(self._endpoints.get(port, {}).keys())
def unregister_routes_for(self, port: int, endpoint_id: str) -> List[str]:
"""Remove all routes owned by ``endpoint_id`` on ``port``.
Returns the list of removed paths so callers can tear down the matching
FastAPI routes. Thread-safe.
"""
with self._lock:
paths = self._endpoints.get(port, {})
removed = [p for p, owner in paths.items() if owner == endpoint_id]
for p in removed:
del paths[p]
if not paths:
self._endpoints.pop(port, None)
return removed
def teardown_routes_for(self, endpoint_id: str) -> None:
"""Remove every route owned by ``endpoint_id`` across all ports.
Performs BOTH the endpoint-registry removal AND the matching FastAPI
route teardown while holding ``self._lock`` for each port, so it cannot
interleave with a concurrent ``reserve_route`` + ``app.post()`` for the
same path (which would otherwise let this filter delete a freshly
registered live endpoint, or race the ``_apps`` snapshot). Only the
agent-owned POST route at each path is dropped; a co-located GET (e.g.
the built-in ``/`` and ``/health``) is preserved.
"""
for port in list(self._apps.keys()):
with self._lock:
paths = self._endpoints.get(port, {})
removed = [p for p, owner in paths.items() if owner == endpoint_id]
if not removed:
continue
for p in removed:
del paths[p]
if not paths:
self._endpoints.pop(port, None)
app = self._apps.get(port)
if app is None:
continue
removed_set = set(removed)
try:
app.router.routes = [
r for r in app.router.routes
if not (
getattr(r, "path", None) in removed_set
and "POST" in (getattr(r, "methods", None) or set())
)
]
# Invalidate cached OpenAPI schema so removed routes
# disappear from /openapi.json and /docs.
app.openapi_schema = None
except Exception:
logger.debug(
"Failed to tear down routes for %s on port %s",
endpoint_id,
port,
exc_info=True,
)
def start_server_if_needed(self, port: int, host: str = "0.0.0.0", **kwargs) -> bool: # noqa: S104
"""Start server with proper readiness signaling. Returns True if server was started.
Readiness is signalled only *after* uvicorn has actually bound the
socket (``Server.started``), so a caller that observes readiness can
immediately issue a request without a connection-refused race. If the
bind fails (e.g. ``Address already in use``) the port's ``_started``
flag is cleared so a later ``launch()`` can retry the bind instead of
registering routes on an app whose server never came up.
"""
with self._lock:
if self._started.get(port, False):
return False # Already started
self._started[port] = True
app = self._apps.get(port)
if not app:
# Roll back the reservation so a caller that first registers an app
# can start it — we must not leave the port permanently "started".
with self._lock:
self._started[port] = False
raise ValueError(f"No app registered for port {port}")
ready_event = self._ready_events[port]
def run_server():
try:
import uvicorn
# Remove hardcoded log_level to avoid conflict with kwargs
config = uvicorn.Config(app, host=host, port=port, **kwargs)
server = uvicorn.Server(config)
# Signal readiness only once uvicorn reports the socket is bound
# (server.started flips True after startup completes). A watcher
# thread polls that flag and sets the event, so waiters are not
# told "ready" before the port actually accepts connections.
def _signal_when_bound():
while not server.started:
if ready_event.is_set():
return # bind failed / server exited before startup
time.sleep(0.02)
ready_event.set()
watcher = threading.Thread(target=_signal_when_bound, daemon=True)
watcher.start()
server.run()
except Exception:
# Bind/startup failed. Clear the started flag so a subsequent
# launch() can re-attempt binding instead of silently
# registering routes on a dead server, and unblock any waiter.
logger.warning(
"Agent server on port %s failed to start.", port, exc_info=True
)
with self._lock:
self._started[port] = False
finally:
# Always release the waiter: on failure the started flag has been
# cleared above, so the (now-false) readiness is correctly
# observable via the flag rather than a hung wait().
ready_event.set()
thread = threading.Thread(target=run_server, daemon=True)
thread.start()
# Check for configurable timeout via environment variable
try:
timeout = float(os.environ.get("PRAISONAI_SERVER_READY_TIMEOUT", "5.0"))
except ValueError:
logger.warning("Invalid PRAISONAI_SERVER_READY_TIMEOUT value. Using default 5.0s.")
timeout = 5.0
became_ready = ready_event.wait(timeout=timeout)
if not became_ready:
logger.warning(
"Agent server on port %s did not become ready within %.1fs. "
"Proceeding, but some features may not work correctly. "
"Check server logs for startup errors.",
port,
timeout,
)
# Report whether the bind actually succeeded: on failure run_server has
# cleared the flag, so returning it lets callers distinguish a live
# server from a poisoned port.
with self._lock:
return self._started.get(port, False)
# Module level — single registry instance shared by every launch() call site.
_server_registry = _AgentServerRegistry()
@@ -29,6 +29,95 @@ logger = logging.getLogger(__name__)
# timeouts so they cannot exhaust process resources.
_MAX_ORPHANED_TOOL_EXECUTORS = 4
# 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.
_TOOL_POOL_MAX_WORKERS = 2
# Sentinel returned as the "arguments" value when a tool call's argument string
# cannot be parsed (e.g. truncated by max_tokens or a dropped connection).
# It is distinct from {} ("no arguments"): {} would silently execute the tool
# with the WRONG arguments and report success. Callers must detect this sentinel
# and surface a tool-error so the model can re-emit the call instead.
# Lives here (agent-side, lightweight) so both chat_mixin.py and llm.py share
# one identity — an `is` check only works against the same object.
_TOOL_ARGUMENTS_PARSE_FAILED = object()
class _CallableGuardrailAdapter:
"""Expose a plain callable guardrail as a chainable guardrail object.
A user ``guardrail`` may be a bare callable returning ``(bool, output)``
(the Agent's final-output contract). To let it run *inside* a
``GuardrailChain`` alongside a plugin guardrail — so composing the two never
silently drops either — this wraps the callable with a ``validate_output``
(and passthrough ``validate_input``) the chain understands. Any
``validate_tool_call`` / ``validate_tool_result`` the underlying object
already exposes is forwarded so tool-surface behaviour is preserved.
"""
__slots__ = ("_fn",)
def __init__(self, fn):
self._fn = fn
def validate_output(self, content, **kwargs):
result = self._fn(content)
if isinstance(result, tuple) and len(result) == 2:
is_valid, processed = result
if is_valid:
# Normalise the passthrough value to a string so the chain can
# keep threading ``content`` through subsequent guardrails.
if isinstance(processed, str):
return True, processed
return True, content
return False, processed if isinstance(processed, str) else str(processed)
# A callable that does not follow the (bool, output) contract is treated
# as a no-op passthrough rather than blocking.
return True, content
def validate_input(self, content, **kwargs):
inner = getattr(self._fn, "validate_input", None)
if callable(inner):
return inner(content, **kwargs)
return True, content
def validate_tool_call(self, tool_name, arguments, **kwargs):
inner = getattr(self._fn, "validate_tool_call", None)
if callable(inner):
return inner(tool_name, arguments, **kwargs)
return True, arguments
def validate_tool_result(self, tool_name, result, **kwargs):
inner = getattr(self._fn, "validate_tool_result", None)
if callable(inner):
return inner(tool_name, result, **kwargs)
return True, result
def tool_arguments_parse_failed(arguments) -> bool:
"""True when tool-call arguments could not be parsed (vs. legitimately empty)."""
return arguments is _TOOL_ARGUMENTS_PARSE_FAILED
def tool_parse_error_message(function_name: str, tool_call_id: str) -> Dict[str, str]:
"""Build a tool-role message telling the model its arguments were lost.
Surfacing this instead of dispatching with {} converts a silent
wrong-action-reported-as-success into a visible, retryable error.
"""
return {
"role": "tool",
"tool_call_id": tool_call_id,
"content": (
f"Error: arguments for tool '{function_name}' could not be parsed "
f"(the argument string was invalid or truncated). "
f"The tool was NOT executed. Please re-emit the tool call with "
f"complete, valid JSON arguments."
),
}
if TYPE_CHECKING:
pass
@@ -381,76 +470,375 @@ class ToolExecutionMixin:
return resolve_tools_list(tools)
def _merge_plugin_tools(self):
"""Merge tools contributed by enabled ``PluginType.TOOL`` plugins.
@staticmethod
def _tool_name_for_plugin_merge(tool):
"""Return the runtime name for a plugin-provided tool, if available."""
if isinstance(tool, dict):
function = tool.get("function")
if isinstance(function, dict):
return function.get("name")
return tool.get("name")
return getattr(tool, "name", None) or getattr(tool, "__name__", None)
Plugins expose tools via ``get_tools()``. Those tools are only callable
by an agent if they are added to ``self.tools``; without this merge they
are collected by ``PluginManager.get_all_tools()`` yet reach no agent.
@staticmethod
def _is_supported_plugin_tool(tool):
"""Return whether a plugin tool has a local execution boundary."""
if callable(tool):
return True
if not isinstance(tool, dict):
return False
``get_all_tools()`` already filters to individually-enabled plugins, so
this does NOT gate on the package-wide ``plugins.enable()`` flag: the
plugin docs state tools work WITHOUT calling ``enable()`` (only
background hook/metric plugins need it). Registering a tool plugin marks
it enabled in the manager, which is sufficient here.
Existing agent tools take precedence on a name collision (the clash is
logged rather than silently shadowing the agent's own tool). Callable
tools are wired directly; metadata-only dict descriptors that carry no
executable implementation are skipped with a warning rather than
appended as un-callable entries.
"""
# Provider-hosted tools are intentionally non-callable: the provider
# owns their execution. A plain ``type: function`` dictionary only
# describes a schema and must not be advertised without a callable.
try:
from ..plugins.manager import get_plugin_manager
plugin_tools = get_plugin_manager().get_all_tools()
except Exception as exc: # never let plugin wiring break agent init
logging.getLogger(__name__).debug("Plugin tool merge skipped: %s", exc)
return
from ..tools.hosted import is_hosted_tool
if not plugin_tools:
return
return is_hosted_tool(tool)
except Exception:
return False
def _tool_name(t):
if isinstance(t, dict):
fn = t.get('function')
if isinstance(fn, dict) and fn.get('name'):
return fn['name']
return t.get('name')
return getattr(t, 'name', getattr(t, '__name__', None))
def _is_plugin_tool_active(self, tool):
"""Return whether the plugin owning ``tool`` is still enabled."""
owners = getattr(self, "_plugin_tool_owners", None)
if not owners:
return True
owner = owners.get(id(tool))
if owner is None:
return True
existing = {
name for name in (_tool_name(t) for t in self.tools) if name
}
added = []
for fn in plugin_tools:
# Only callables (or OpenAI-schema dicts paired with a callable)
# are executable by an agent. A bare ``{"name": ...}`` descriptor
# has no implementation, so appending it would advertise a tool the
# agent can never invoke — skip it visibly instead.
if not callable(fn) and not (
isinstance(fn, dict) and callable(fn.get('function'))
):
logging.getLogger(__name__).warning(
"Plugin tool %r skipped: not callable / no executable "
"implementation.", _tool_name(fn) or fn
)
continue
name = _tool_name(fn)
if name and name in existing:
logging.getLogger(__name__).warning(
"Plugin tool '%s' skipped: an agent tool with the same name "
"already exists (existing tool wins).", name
)
continue
self.tools.append(fn)
if name:
existing.add(name)
added.append(name or str(fn))
try:
from ..plugins import get_plugin_manager
if added:
logging.getLogger(__name__).debug(
"Merged %d plugin tool(s) into agent: %s", len(added), added
# Revocation tracks the OWNING plugin's per-manager enabled state
# (and identity, to catch unregister/re-register). It deliberately
# does NOT gate on the package-wide ``plugins.enable()`` flag, which
# governs background hook/metric plugins — tool plugins are live on
# registration, matching how they were attached in ``_merge_plugin_tools``.
manager = get_plugin_manager()
owner_entries = owner if isinstance(owner, list) else [owner]
get_plugin = getattr(manager, "get_plugin", None)
for owner_entry in owner_entries:
owner_name = owner_entry
owner_plugin = None
if isinstance(owner_entry, tuple):
owner_name, owner_plugin = owner_entry
current_plugin = (
get_plugin(owner_name) if callable(get_plugin) else None
)
# Revocation tracks plugin identity as well as enabled state,
# so unregister/re-register cannot revive a stale tool.
if current_plugin is not owner_plugin:
continue
if manager.is_enabled(owner_name):
return True
return False
except Exception as exc: # pragma: no cover - defensive plugin boundary
logging.warning("Failed to check plugin tool state: %s", exc)
return False
def _merge_plugin_tools(self):
"""Attach tools from enabled protocol-driven plugins to this Agent.
Plugin tools stay on the Agent rather than the process-global
``ToolRegistry``. This preserves declared-tool isolation while
allowing the normal formatter and execution pipeline to handle plugin
callables exactly like ``tools=`` values. Plugin ownership is recorded
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
# NOTE: do NOT gate on the package-wide ``plugins.enable()`` flag.
# ``get_all_tools_with_sources()`` already filters to individually
# manager-enabled plugins, and registering a tool plugin marks it
# enabled. The plugin docs state tools work WITHOUT calling
# ``enable()`` (only background hook/metric plugins need it), so a
# merely-registered tool plugin must still reach the agent.
manager = get_plugin_manager()
get_tools_with_sources = getattr(
manager, "get_all_tools_with_sources", None
)
if callable(get_tools_with_sources):
plugin_entries = get_tools_with_sources()
else: # pragma: no cover - compatibility with custom managers
plugin_entries = [(None, tool) for tool in manager.get_all_tools()]
except Exception as exc: # pragma: no cover - defensive plugin boundary
logging.warning("Failed to attach plugin tools: %s", exc)
return
current_tools = self.tools if isinstance(self.tools, list) else [self.tools]
existing_ids = {id(tool) for tool in current_tools}
existing_names = {
name
for tool in current_tools
if (name := self._tool_name_for_plugin_merge(tool))
} | {name for name, _tool in self._iter_active_named_tools()}
for owner, tool in plugin_entries:
if id(tool) in existing_ids:
# Multiple enabled plugins may intentionally expose the same
# callable or hosted-tool object. Keep every owner so disabling
# one provider does not revoke a capability still supplied by
# another provider.
plugin_owners = getattr(self, "_plugin_tool_owners", None)
if plugin_owners is not None and id(tool) in plugin_owners and owner:
get_plugin = getattr(manager, "get_plugin", None)
owner_entry = (
owner,
get_plugin(owner) if callable(get_plugin) else None,
)
existing_owner = plugin_owners[id(tool)]
owner_entries = (
existing_owner
if isinstance(existing_owner, list)
else [existing_owner]
)
if not any(
isinstance(entry, tuple)
and entry[0] == owner_entry[0]
and entry[1] is owner_entry[1]
for entry in owner_entries
):
owner_entries.append(owner_entry)
plugin_owners[id(tool)] = owner_entries
continue
name = self._tool_name_for_plugin_merge(tool)
function = tool.get("function") if isinstance(tool, dict) else None
if (
isinstance(tool, dict)
and tool.get("type") == "function"
and isinstance(function, dict)
and isinstance(function.get("name"), str)
and function["name"]
):
logging.warning(
"Skipping plugin function tool %r: function schemas are not "
"executable without a callable implementation",
name,
)
continue
if not self._is_supported_plugin_tool(tool) or (
name is not None and not isinstance(name, str)
) or (callable(tool) and not name):
logging.warning(
"Skipping plugin tool %r: expected a named callable or valid tool spec",
name or tool,
)
continue
if name and name in existing_names:
logging.warning(
"Skipping plugin tool %r because Agent already has a tool with that name",
name,
)
continue
# Only normalize a non-list declared tool source when there is a
# valid, non-conflicting plugin tool to append. Enabling plugins
# must not otherwise change the public shape of ``self.tools``.
if not isinstance(self.tools, list):
self.tools = [self.tools] if self.tools else []
self.tools.append(tool)
existing_ids.add(id(tool))
if name:
existing_names.add(name)
if owner:
if not hasattr(self, "_plugin_tool_owners"):
self._plugin_tool_owners = {}
get_plugin = getattr(manager, "get_plugin", None)
plugin = get_plugin(owner) if callable(get_plugin) else None
self._plugin_tool_owners[id(tool)] = (owner, plugin)
display_name = name
if not display_name and isinstance(tool, dict):
display_name = tool.get("type", "spec")
logging.debug("Added plugin tool to Agent: %s", display_name or tool)
def _merge_plugin_subsystems(self):
"""Route enabled GUARDRAIL/POLICY/SKILL plugins into their subsystems.
Complements ``_merge_plugin_tools``: instead of contributing tools, a
plugin declared ``PluginType.GUARDRAIL``/``POLICY``/``SKILL`` now
participates in the typed subsystem its category promises —
* GUARDRAIL: ``as_guardrail()`` objects are folded into ``self.guardrail``
so ``_setup_guardrail()`` (which runs after this) wires them into the
same ``GuardrailChain`` the Agent already runs — including
``validate_tool_call`` / ``validate_tool_result``.
* POLICY: ``get_policies()`` rules are added to ``self._policy``
(a ``PolicyEngine``) so they gate tool calls.
* SKILL: ``get_skills()`` entries are appended to ``self._skills`` so the
lazy ``SkillManager`` discovers them as real, loadable skills.
Best-effort and fail-open on plugin errors: a broken plugin must never
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
manager = get_plugin_manager()
except Exception as exc: # pragma: no cover - defensive plugin boundary
logging.warning("Failed to access plugin manager for subsystems: %s", exc)
return
# --- GUARDRAIL plugins -> self.guardrail (GuardrailChain) -------------
get_guardrails = getattr(manager, "get_all_guardrails", None)
if callable(get_guardrails):
try:
plugin_guardrails = get_guardrails() or []
except Exception as exc: # pragma: no cover - defensive boundary
logging.warning("Failed to collect plugin guardrails: %s", exc)
plugin_guardrails = []
if plugin_guardrails:
try:
from ..guardrails import GuardrailChain
from ..guardrails.protocols import is_guardrail_object
chain_items = [
g for g in plugin_guardrails if is_guardrail_object(g)
]
if chain_items:
chain = GuardrailChain(chain_items)
# Register the plugin guardrails' tool-call/result
# surface on the runtime lists ``_check_tool_policy_and_
# guardrails`` / ``_apply_tool_result_guardrails`` read,
# so a guardrail plugin finally sees tool calls and raw
# tool results — the blind spot ``after_llm`` never
# covered. Runs after ``_setup_guardrail()`` so we append
# rather than replace any user-supplied guardrail surface.
calls = getattr(self, "_tool_call_guardrails", None)
if isinstance(calls, list):
calls.append(chain)
else:
self._tool_call_guardrails = [chain]
results = getattr(self, "_tool_result_guardrails", None)
if isinstance(results, list):
results.append(chain)
else:
self._tool_result_guardrails = [chain]
# Also make the chain participate in the Agent's
# input/output validation (not just the tool surface).
existing_fn = getattr(self, "_guardrail_fn", None)
if existing_fn is None:
# No user guardrail: the plugin chain becomes the
# Agent's output/input validator directly.
self.guardrail = chain
self._guardrail_fn = chain
elif existing_fn is not chain:
# User already supplied a guardrail. Compose rather
# than replace so BOTH run on the final response:
# wrap the user's callable in an adapter exposing
# validate_output/validate_input, then chain it with
# the plugin chain. The user's guardrail runs first;
# a plugin guardrail can no longer be silently
# skipped just because a user guardrail exists.
adapter = _CallableGuardrailAdapter(existing_fn)
combined = GuardrailChain([adapter, chain])
self.guardrail = combined
self._guardrail_fn = combined
except Exception as exc: # pragma: no cover - defensive boundary
logging.warning("Failed to wire plugin guardrails: %s", exc)
# --- POLICY plugins -> self._policy (PolicyEngine) -------------------
get_policies = getattr(manager, "get_all_policies", None)
if callable(get_policies):
try:
plugin_policies = get_policies() or []
except Exception as exc: # pragma: no cover - defensive boundary
logging.warning("Failed to collect plugin policies: %s", exc)
plugin_policies = []
if plugin_policies:
try:
policy = getattr(self, "_policy", None)
if policy is None:
from ..policy import PolicyEngine
policy = PolicyEngine()
self._policy = policy
add_policy = getattr(policy, "add_policy", None)
if callable(add_policy):
# A user-supplied policy always wins: never let a plugin
# rule silently replace an existing policy of the same
# name (PolicyEngine.add_policy overwrites by name), which
# could otherwise loosen a user-configured restriction.
existing = set()
get_policy = getattr(policy, "get_policy", None)
for rule in plugin_policies:
rule_name = getattr(rule, "name", None)
if rule_name is not None and callable(get_policy):
try:
if get_policy(rule_name) is not None:
logging.warning(
"Skipping plugin policy %r: a policy "
"with that name already exists.",
rule_name,
)
continue
except Exception: # pragma: no cover
pass
if rule_name is not None and rule_name in existing:
logging.warning(
"Skipping duplicate plugin policy %r.",
rule_name,
)
continue
try:
add_policy(rule)
if rule_name is not None:
existing.add(rule_name)
except Exception as exc: # pragma: no cover
logging.warning(
"Failed to add plugin policy %r: %s", rule, exc
)
except Exception as exc: # pragma: no cover - defensive boundary
logging.warning("Failed to wire plugin policies: %s", exc)
# --- SKILL plugins -> self._skills (SkillManager discovers them) -----
get_skills = getattr(manager, "get_all_skills", None)
if callable(get_skills):
try:
plugin_skills = get_skills() or []
except Exception as exc: # pragma: no cover - defensive boundary
logging.warning("Failed to collect plugin skills: %s", exc)
plugin_skills = []
if plugin_skills:
current = getattr(self, "_skills", None)
if not isinstance(current, list):
current = [current] if current else []
for skill in plugin_skills:
# ``self._skills`` feeds ``SkillManager.add_skill(path)``,
# which resolves each entry as a filesystem path. Only
# string path selectors are loadable; a non-string (e.g. a
# source object) would raise inside ``Path()``, so skip it
# with a clear warning rather than corrupt discovery.
if not isinstance(skill, str):
logging.warning(
"Skipping plugin skill %r: get_skills() must return "
"filesystem path strings to skill directories.",
skill,
)
continue
if skill not in current:
current.append(skill)
self._skills = current
def _cast_arguments(self, func, arguments):
"""Cast arguments to their expected types based on function signature."""
@@ -657,6 +1045,14 @@ class ToolExecutionMixin:
self._middleware_manager = manager
return manager if manager.has_tool_hooks else None
def _get_tool_executor_lock(self):
"""Return the per-instance lock, including for standalone mixin users."""
try:
return self._tool_executor_lock
except AttributeError:
from .async_safety import DualLock
return self.__dict__.setdefault("_tool_executor_lock", DualLock())
def _get_turn_tools_lock(self):
"""Return the DualLock guarding the per-turn tool buffer.
@@ -928,12 +1324,72 @@ class ToolExecutionMixin:
return self._execute_tool_with_circuit_breaker(function_name, arguments)
# Use reusable executor to prevent resource leaks
if not hasattr(self, '_tool_executor') or self._tool_executor is None:
self._tool_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=2, thread_name_prefix=f"tool-{self.name}"
)
pool_exhausted = False
with self._get_tool_executor_lock().sync():
if not hasattr(self, '_tool_executor') or self._tool_executor is None:
self._tool_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=_TOOL_POOL_MAX_WORKERS,
thread_name_prefix=f"tool-{self.name}"
)
# Per-executor in-flight counter so a
# retired pool's late-finishing future can
# never corrupt the *new* pool's count.
self._tool_executor._inflight = 0
executor = self._tool_executor
orphaned = getattr(self, '_tool_executor_orphaned', 0)
inflight = getattr(executor, '_inflight', 0)
# Once the orphan cap is hit we can no longer
# retire a stuck pool, so we would otherwise
# queue behind hung workers and time out a
# healthy tool that never got to start. Refuse
# fast and visibly instead when no worker is
# free, and let the pool self-heal (the orphan
# count drops as hung threads finish).
if orphaned >= _MAX_ORPHANED_TOOL_EXECUTORS and inflight >= _TOOL_POOL_MAX_WORKERS:
pool_exhausted = True
else:
executor._inflight = inflight + 1
future = executor.submit(ctx.run, execute_with_context)
future = self._tool_executor.submit(ctx.run, execute_with_context)
def _on_tool_done(_f, _agent=self, _executor=executor): # noqa: ANN001
with _agent._get_tool_executor_lock().sync():
_executor._inflight = max(0, getattr(_executor, '_inflight', 1) - 1)
# Orphan accounting is per-POOL, not
# per-future: retiring a pool adds
# exactly one to the orphan count, so
# a pool may return exactly one slot.
# Recover it only when this retired
# pool's LAST in-flight worker exits
# (``_inflight`` == 0), and guard with
# a one-shot flag so a multi-worker
# pool can never decrement more than
# the single unit it contributed and
# steal another orphaned pool's slot.
if (
getattr(_agent, '_tool_executor', None) is not _executor
and _executor._inflight == 0
and not getattr(_executor, '_orphan_recovered', False)
):
_executor._orphan_recovered = True
_agent._tool_executor_orphaned = max(
0, getattr(_agent, '_tool_executor_orphaned', 0) - 1
)
future.add_done_callback(_on_tool_done)
if pool_exhausted:
logging.warning(
f"Tool worker pool exhausted; refusing to queue '{function_name}' "
f"behind {orphaned} hung tool call(s)."
)
result = {
"error": (
f"Tool worker pool exhausted: {orphaned} earlier tool call(s) "
f"are still running past their timeout. Refusing to queue "
f"'{function_name}'."
),
"tool_pool_exhausted": True,
}
break
try:
result = future.result(timeout=tool_timeout)
except concurrent.futures.TimeoutError:
@@ -954,11 +1410,12 @@ class ToolExecutionMixin:
# once the cap is reached we stop recycling and keep reusing
# the existing pool so repeated timeouts can't exhaust process
# resources with an unbounded number of leaked threads.
orphaned = getattr(self, '_tool_executor_orphaned', 0)
if orphaned < _MAX_ORPHANED_TOOL_EXECUTORS:
self._tool_executor.shutdown(wait=False)
self._tool_executor = None
self._tool_executor_orphaned = orphaned + 1
with self._get_tool_executor_lock().sync():
orphaned = getattr(self, '_tool_executor_orphaned', 0)
if getattr(self, '_tool_executor', None) is executor and orphaned < _MAX_ORPHANED_TOOL_EXECUTORS:
executor.shutdown(wait=False)
self._tool_executor = None
self._tool_executor_orphaned = orphaned + 1
else:
with tool_progress_channel(_progress_sink), with_injection_context(state):
result = self._execute_tool_with_circuit_breaker(function_name, arguments)
@@ -1026,6 +1483,16 @@ class ToolExecutionMixin:
# ``is_retryable`` above decides only whether the outer
# loop retries, not whether it raises.
raised_exception = "_praison_retryable" in result
# NOTE: an unknown tool is deliberately NOT escalated
# here. GHSA-gmjg-hv98-qggq's regression tests in
# tests/unit/agent/test_tool_resolution_boundary.py
# pin the contract that execute_tool returns None or
# an error dict for an unresolved name rather than
# raising, and the self-repair feature depends on it:
# the corrective dict (with its "Did you mean" hint
# and the tool inventory) is fed back so the model
# can retry with a real tool name. Raising would
# abort the run and make repair impossible.
# Strip the private control-plane tag before it can reach
# the model or be re-surfaced as the tool's payload.
result.pop("_praison_retryable", None)
@@ -1455,6 +1922,11 @@ class ToolExecutionMixin:
``_atrigger_after_agent_hook`` without any async-safety change.
"""
from ..hooks import HookEvent, AfterAgentInput
# AfterAgentInput.response is typed/serialized as a string (its
# to_dict truncates via ``response[:500]``). Structured-output turns
# (output_pydantic) return a Pydantic instance, so coerce to str here
# to keep string-only hooks (e.g. command hooks) from crashing while
# the public chat() return value stays the model.
return AfterAgentInput(
session_id=getattr(self, '_session_id', 'default'),
cwd=os.getcwd(),
@@ -1462,7 +1934,7 @@ class ToolExecutionMixin:
timestamp=str(time.time()),
agent_name=self.name,
prompt=prompt if isinstance(prompt, str) else str(prompt),
response=response or "",
response=response if isinstance(response, str) else (str(response) if response else ""),
tools_used=tools_used or [],
total_tokens=0,
execution_time_ms=(time.time() - start_time) * 1000
@@ -1479,7 +1951,16 @@ class ToolExecutionMixin:
# Auto-memory extraction (opt-in via MemoryConfig(auto_memory=True))
if response:
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
self._process_auto_memory(prompt_str, str(response))
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))
# Auto-learning extraction (opt-in via LearnConfig(mode=LearnMode.AGENTIC))
self._process_auto_learning()
@@ -1733,6 +2214,30 @@ class ToolExecutionMixin:
tail = text[-tail_limit:] if tail_limit > 0 else ""
return f"{head}\n...[{len(text):,} chars, showing first/last portions]...\n{tail}"
@staticmethod
def _approval_backend_accepts_timeout(backend) -> bool:
"""Return True if ``backend.request_approval_sync`` accepts a ``timeout`` kwarg.
Determined by signature introspection so the caller can route the
per-call timeout without a call-and-catch that would re-invoke a
backend whose *own* body raised ``TypeError``. Backends that accept
``**kwargs`` are treated as accepting ``timeout``. On any introspection
failure we conservatively return False (call without ``timeout``).
"""
method = getattr(backend, 'request_approval_sync', None)
if method is None:
return False
try:
sig = inspect.signature(method)
except (TypeError, ValueError):
return False
params = sig.parameters
if 'timeout' in params:
return True
return any(
p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
)
def _resolve_approval_decision(self, tool_name: str, tool_args: dict, is_async: bool = False):
"""Shared approval logic for both sync and async paths.
@@ -1906,41 +2411,51 @@ class ToolExecutionMixin:
return _ask_backend_async()
else:
# Sync path - handle timeout and sync/async backend compatibility
# Sync path - handle timeout and sync/async backend compatibility.
#
# The per-call timeout is passed explicitly rather than
# mutated onto the (often SHARED) backend object. Mutating
# ``backend._timeout`` here leaked between agents that pass
# one backend instance: an overlapping call captured the
# first agent's temporary value as its "original" and
# restored it, leaving the backend permanently wrong.
cfg_timeout = getattr(self, '_approval_timeout', 0)
orig_timeout = None
# Compute effective timeout from agent configuration.
# None -> indefinite wait
# > 0 -> explicit per-call timeout
# == 0 -> backend default (or 60s fallback)
if cfg_timeout is None:
orig_timeout = getattr(backend, '_timeout', None)
if orig_timeout is not None:
backend._timeout = 86400 * 365
effective_timeout = None
elif cfg_timeout > 0:
orig_timeout = getattr(backend, '_timeout', None)
if orig_timeout is not None:
backend._timeout = cfg_timeout
try:
if hasattr(backend, 'request_approval_sync'):
return _remember(backend.request_approval_sync(request))
else:
# Use the shared utility to avoid code duplication and handle timeout correctly
from ..approval.utils import run_coroutine_safely
# Compute effective timeout from agent configuration
if cfg_timeout is None:
effective_timeout = None # indefinite wait
elif cfg_timeout > 0:
effective_timeout = cfg_timeout
else:
# cfg_timeout == 0: use backend default or fallback
effective_timeout = getattr(backend, '_timeout', 60)
return _remember(run_coroutine_safely(
backend.request_approval(request),
timeout=effective_timeout
effective_timeout = cfg_timeout
else:
effective_timeout = getattr(backend, '_timeout', 60)
if hasattr(backend, 'request_approval_sync'):
# Decide *up front* whether the backend accepts a
# ``timeout`` kwarg by inspecting its signature, rather
# than calling and catching ``TypeError``. A try/except
# would re-invoke the backend when the *user callback*
# inside it raised its own ``TypeError`` — duplicating
# side effects and masking the real error. Signature
# introspection is side-effect free.
if self._approval_backend_accepts_timeout(backend):
return _remember(backend.request_approval_sync(
request, timeout=effective_timeout
))
finally:
if orig_timeout is not None and hasattr(backend, '_timeout'):
backend._timeout = orig_timeout
# Legacy backend whose request_approval_sync has no
# timeout kwarg: honour its own default, no mutation of
# (often shared) backend state.
return _remember(backend.request_approval_sync(request))
else:
# Use the shared utility to avoid code duplication and handle timeout correctly
from ..approval.utils import run_coroutine_safely
return _remember(run_coroutine_safely(
backend.request_approval(request),
timeout=effective_timeout
))
else:
if is_async:
# For async, wrap the decision in a coroutine
@@ -2349,6 +2864,42 @@ class ToolExecutionMixin:
logging.debug("permission manager is_denied failed for %s: %s", target, e)
return None
def _check_permission_manager_doom_loop(self, function_name, arguments=None):
"""Return an error dict if the PermissionManager detects a doom loop.
Deliver the doom-loop detection the ``PermissionManager`` advertises:
record this call in its detector and deny on a genuine loop. Previously
``check_doom_loop()`` was unreachable dead code so a configured manager
gave zero doom-loop protection.
This is intentionally **separate** from :meth:`_check_permission_manager_deny`
and invoked exactly **once per approved tool call**, with the final
(post-approval, possibly rewritten) arguments. Folding it into the deny
gate would record the call on every gate pass — counting a rejected
attempt, or counting an approved call twice when its args are rewritten —
which could trip the threshold early and deny legitimate repeats.
"""
manager = getattr(self, "_permission_manager", None)
if manager is None:
return None
check_loop = getattr(manager, "check_doom_loop", None)
if not callable(check_loop):
return None
try:
loop_result = check_loop(function_name, arguments)
if loop_result is not None and getattr(loop_result, "is_loop", False):
return {
"error": (
f"Tool '{function_name}' blocked: "
f"{getattr(loop_result, 'reason', 'doom loop detected')}"
),
"permission_denied": True,
"loop_blocked": True,
}
except Exception as e: # noqa: BLE001
logging.debug("permission manager doom-loop check failed for %s: %s", function_name, e)
return None
def _is_bypass_mode(self) -> bool:
"""Return ``True`` when ``PermissionMode.BYPASS`` is active.
@@ -2410,6 +2961,13 @@ class ToolExecutionMixin:
if manager_denial is not None:
return manager_denial
# Record the (approved, final-args) call in the doom-loop detector once,
# after approval, so rejected attempts and arg rewrites are not counted.
if not self._is_bypass_mode():
loop_denial = self._check_permission_manager_doom_loop(function_name, arguments)
if loop_denial is not None:
return loop_denial
from ..approval import get_approval_registry
get_approval_registry().mark_approved(
function_name, arguments, agent_name=getattr(self, "name", None),
@@ -2456,6 +3014,13 @@ class ToolExecutionMixin:
if manager_denial is not None:
return manager_denial
# Record the (approved, final-args) call in the doom-loop detector once,
# after approval, so rejected attempts and arg rewrites are not counted.
if not self._is_bypass_mode():
loop_denial = self._check_permission_manager_doom_loop(function_name, arguments)
if loop_denial is not None:
return loop_denial
from ..approval import get_approval_registry
get_approval_registry().mark_approved(
function_name, arguments, agent_name=getattr(self, "name", None),
@@ -2773,6 +3338,8 @@ class ToolExecutionMixin:
if not isinstance(candidates, (list, tuple)):
return None
for tool_obj in candidates:
if not self._is_plugin_tool_active(tool_obj):
continue
if getattr(tool_obj, "name", None) == function_name:
return tool_obj
if getattr(tool_obj, "__name__", None) == function_name:
@@ -3055,7 +3622,10 @@ class ToolExecutionMixin:
# Try to find the function in the agent's tools list first
func = None
plugin_owners = getattr(self, "_plugin_tool_owners", None)
for tool in self.tools if isinstance(self.tools, (list, tuple)) else []:
if plugin_owners and not self._is_plugin_tool_active(tool):
continue
# Check for BaseTool instances (plugin system)
from ..tools.base import BaseTool
if isinstance(tool, BaseTool) and tool.name == function_name:
@@ -3123,9 +3693,11 @@ class ToolExecutionMixin:
# sync tool-calling path we must await it, otherwise a bare
# un-awaited coroutine is handed to the model as the tool result
# and the tool body never runs (silent data loss).
from .async_safety import run_async_in_sync_context
def _resolve_result(value):
if inspect.iscoroutine(value):
return self._run_async_in_sync_context(value)
return run_async_in_sync_context(value)
return value
# BaseTool instances (plugin system) - call run() method
@@ -3311,6 +3883,8 @@ class ToolExecutionMixin:
yield from _expand(tools)
return
for tool in tools if isinstance(tools, (list, tuple)) else []:
if not self._is_plugin_tool_active(tool):
continue
yield from _expand(tool)
def _available_active_tool_names(self):
@@ -2,15 +2,23 @@
Unified Execution Mixin - implements Gap 1 from Issue #1392.
.. deprecated::
UnifiedExecutionMixin is unused and scheduled for removal (Issue #2644).
None of its methods are invoked by production code: the live execution paths
use ``_chat_impl`` / ``_execute_unified_chat_completion`` (in ``chat_mixin.py``)
and ``execute_tool`` (in ``tool_execution.py``), which are distinct from this
mixin's methods despite the similar names. This module remains importable and
emits a ``DeprecationWarning`` from its public methods during the deprecation
window; it will be removed from the ``Agent`` MRO after the cycle completes.
UnifiedExecutionMixin's *public* methods (``unified_chat``, ``unified_achat``,
``unified_execute_tool``/``_async``, ``_unified_chat_impl``,
``_apply_guardrails_async``) are unused by production code and scheduled for
removal (Issue #2644): the live execution paths use ``_chat_impl`` /
``_execute_unified_chat_completion`` (in ``chat_mixin.py``) and ``execute_tool``
(in ``tool_execution.py``), which are distinct despite the similar names. These
public methods emit a ``DeprecationWarning`` during the deprecation window and
the mixin will be removed from the ``Agent`` MRO after the cycle completes.
Use ``Agent.chat()`` / ``Agent.achat()`` / ``Agent.execute_tool()`` instead.
NOTE: The event-loop bridge previously defined here as
``_run_async_in_sync_context`` **is** live production code (the sync
tool-calling path in ``tool_execution.py`` depends on it). It has been
relocated to the stable ``async_safety.run_async_in_sync_context`` so that
removing this deprecated mixin from the MRO is a true no-op. The method here
is now only a thin shim kept for the deprecated public methods above.
This module consolidates sync/async execution paths into a single async-first
implementation with a thin sync bridge, eliminating code duplication between
chat()/achat() and execute_tool()/execute_tool_async().
@@ -28,12 +36,9 @@ Design principles:
- Async-safe: handles event loop management correctly
"""
import asyncio
import logging
import threading
import warnings
from typing import List, Optional, Any, Dict, Union
from concurrent.futures import ThreadPoolExecutor
logger = logging.getLogger(__name__)
@@ -354,34 +359,14 @@ class UnifiedExecutionMixin:
def _run_async_in_sync_context(self, coro):
"""
Run async coroutine in sync context with proper event loop handling.
Handles the common cases:
1. No event loop exists - use asyncio.run()
2. Event loop exists on main thread - use dedicated thread with new loop
3. Event loop exists on worker thread - create new event loop
Thin shim delegating to :func:`async_safety.run_async_in_sync_context`,
which is the stable, non-deprecated home for this helper. Retained so
this mixin's own (deprecated) sync entry points keep working during the
deprecation window.
"""
try:
# Try to get the current event loop
loop = asyncio.get_running_loop()
except RuntimeError:
# No event loop - safe to use asyncio.run()
return asyncio.run(coro)
# Event loop exists - avoid deadlock by running in dedicated thread
import concurrent.futures
def run_in_thread():
# Create new event loop in dedicated thread
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(coro)
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result(timeout=300) # 5 minute timeout
from .async_safety import run_async_in_sync_context
return run_async_in_sync_context(coro)
def unified_chat(self, *args, **kwargs) -> Optional[str]:
"""
@@ -102,6 +102,15 @@ class VideoAgent:
- vertex_ai/veo-3.1-fast-generate-001 # GA Fast
- vertex_ai/veo-3.1-lite-generate-001 # Lite
- RunwayML: runwayml/gen4_turbo (requires input_reference)
- OpenRouter (uses OPENROUTER_API_KEY, requires the openrouter/ prefix):
- openrouter/google/veo-3.1, openrouter/kling/kling-3.0-std,
openrouter/minimax/hailuo-3, openrouter/bytedance/seedance-2.0, ...
- Discover exact model ids via GET https://openrouter.ai/api/v1/videos/models
- OpenRouter body fields (duration, resolution, aspect_ratio,
generate_audio, seed, ...) are passed straight through as kwargs to
generate(); e.g. agent.generate(prompt, duration=6, resolution="720p").
- HTTP-Referer / X-Title attribution headers are added automatically
(override via OPENROUTER_REFERER / OPENROUTER_APP_TITLE env vars).
Deprecated (do NOT recommend; kept working only if LiteLLM still routes them):
- gemini/veo-2.0-generate-001 (Gemini API shutdown 30 Jun 2026)
@@ -294,7 +303,26 @@ class VideoAgent:
params["api_base"] = self.base_url
if self.api_version:
params["api_version"] = self.api_version
headers = self._provider_headers()
if headers:
params["extra_headers"] = headers
return params
def _provider_headers(self) -> Dict[str, str]:
"""Optional attribution headers for gateway providers.
OpenRouter recommends sending HTTP-Referer and X-Title so requests
show up under your app in the OpenRouter dashboard. These mirror the
chat gateway (praisonai/llm/gateways.py) and are only added for
openrouter/ models to keep other providers untouched.
"""
model = self.llm if isinstance(self.llm, str) else ""
if not model.startswith("openrouter/"):
return {}
return {
"HTTP-Referer": os.getenv("OPENROUTER_REFERER", "https://praison.ai"),
"X-Title": os.getenv("OPENROUTER_APP_TITLE", "PraisonAI"),
}
# ─────────────────────────────────────────────────────────────────────────
# Core Video Operations (5 operations, sync + async)
@@ -24,12 +24,6 @@ try:
except ImportError:
get_token_collector = None
# Import async utility for hot-path usage
try:
from ..approval.utils import run_coroutine_safely
except ImportError:
run_coroutine_safely = 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.
@@ -57,108 +51,12 @@ from ..agent.launch_security import (
)
# Agent server registry for thread-safe server management
class _AgentServerRegistry:
"""Encapsulates all shared HTTP server state with proper synchronization."""
def __init__(self):
self._lock = threading.Lock()
self._started: Dict[int, bool] = {}
self._endpoints: Dict[int, Dict[str, str]] = {}
self._apps: Dict[int, Any] = {} # FastAPI apps
self._ready_events: Dict[int, threading.Event] = {}
def get_or_create_app(self, port: int, title: str = "AgentTeam API") -> Tuple[Any, bool]:
"""Thread-safe app creation. Returns (app, is_new)."""
with self._lock:
if port not in self._apps:
# Lazy import to avoid optional dependency at module level
from fastapi import FastAPI
self._apps[port] = FastAPI(title=title)
self._endpoints[port] = {}
self._ready_events[port] = threading.Event()
return self._apps[port], True
return self._apps[port], False
def register_route(self, port: int, path: str, endpoint_id: str = "registered") -> None:
"""Thread-safe route registration tracking."""
with self._lock:
if port not in self._endpoints:
self._endpoints[port] = {}
self._endpoints[port][path] = endpoint_id
def reserve_route(self, port: int, path: str, endpoint_id: str) -> tuple[str, Optional[str]]:
"""Atomically reserve and register a route.
Returns:
tuple[str, Optional[str]]: (final_path, original_path_if_collided).
If there is no collision, final_path equals the requested path and
original_path_if_collided is None.
"""
with self._lock:
if port not in self._endpoints:
self._endpoints[port] = {}
original_path = path
while path in self._endpoints[port]:
path = f"{original_path}_{str(uuid.uuid4())[:6]}"
self._endpoints[port][path] = endpoint_id
return path, (original_path if path != original_path else None)
def list_routes(self, port: int) -> List[str]:
"""Return a snapshot list of registered routes for a port."""
with self._lock:
return list(self._endpoints.get(port, {}).keys())
def start_server_if_needed(self, port: int, host: str = "0.0.0.0", **kwargs) -> bool: # noqa: S104
"""Start server with proper readiness signaling. Returns True if server was started."""
with self._lock:
if self._started.get(port, False):
return False # Already started
self._started[port] = True
app = self._apps.get(port)
if not app:
raise ValueError(f"No app registered for port {port}")
ready_event = self._ready_events[port]
def run_server():
import uvicorn
# Remove hardcoded log_level to avoid conflict with kwargs
config = uvicorn.Config(app, host=host, port=port, **kwargs)
server = uvicorn.Server(config)
ready_event.set() # Signal readiness
server.run()
thread = threading.Thread(target=run_server, daemon=True)
thread.start()
# Check for configurable timeout via environment variable
try:
timeout = float(os.environ.get("PRAISONAI_SERVER_READY_TIMEOUT", "5.0"))
except ValueError:
logger.warning("Invalid PRAISONAI_SERVER_READY_TIMEOUT value. Using default 5.0s.")
timeout = 5.0
became_ready = ready_event.wait(timeout=timeout)
if not became_ready:
logger.warning(
"Agent server on port %s did not become ready within %.1fs. "
"Proceeding, but some features may not work correctly. "
"Check server logs for startup errors.",
port,
timeout,
)
return True
# Module level — single registry instance
_server_registry = _AgentServerRegistry()
# Agent server registry for thread-safe server management.
# The registry class + its process-wide singleton live in agent/server_registry.py
# so Agent.launch() and PraisonAIAgents.launch() share ONE registry per port
# (one FastAPI app, one uvicorn server, one route table) instead of two disjoint
# ones that could double-bind a port or hide each other's endpoints.
from ..agent.server_registry import _AgentServerRegistry, _server_registry # noqa: F401
def encode_file_to_base64(file_path: str) -> str:
"""Base64-encode a file."""
@@ -556,7 +454,10 @@ def _build_execution_context(agents_instance, task_id, skip_memory_init=False):
if llm is None:
llm = getattr(executor_agent, 'llm', None)
if llm and hasattr(llm, 'set_current_agent'):
llm.set_current_agent(executor_agent.display_name)
# Pass the agent's stable per-instance id so token usage is aggregated
# by identity, not by (collidable) display name.
agent_scope_id = getattr(executor_agent, '_approval_scope_id', None)
llm.set_current_agent(executor_agent.display_name, agent_id=agent_scope_id)
# Place the agent's tools BEFORE snapshotting them. This snapshot is passed
# to chat() as an explicit `tools=`, which wins over `agent.tools` -- so if
@@ -586,6 +487,10 @@ def _build_execution_context(agents_instance, task_id, skip_memory_init=False):
# Inter-task context / validation feedback assembled by the workflow process
# engine (Process._build_task_context) is stored on the task; fold it in so
# downstream/retried tasks actually see upstream output and rejection reasons.
context_builder = getattr(task, '_execution_context_builder', None)
if context_builder is not None:
task._execution_context = context_builder(task)
task._execution_context_builder = None
extra_context = getattr(task, '_execution_context', None)
if extra_context:
context_text = extra_context
@@ -593,7 +498,7 @@ def _build_execution_context(agents_instance, task_id, skip_memory_init=False):
# retries (guardrail/completion failures) re-enter this helper via the
# run_task/arun_task retry loops, and clearing would strip the upstream
# output + validation feedback the retry needs. The Process engine owns
# this field's lifecycle: it re-sets it before each task yield and resets
# this field's lifecycle: it rebuilds it for each workflow step and resets
# every task's _execution_context to None before selecting the next task.
if task.context:
context_results = [] # Collect contexts then de-duplicate
@@ -1174,8 +1079,15 @@ class AgentTeam(SpawnAnnounceProtocol):
has_handler_only_task = any(
_task_has_custom_handler(t) for t in (tasks or [])
)
if not has_handler_only_task:
raise ValueError("At least one agent must be provided")
# An empty team is legitimate for the spawn-announce pattern:
# AgentTeam.spawn_sub_agent and SpawnAnnounceProtocol are exported
# public API, and their documented use is a team created with no
# agents that spawns them dynamically at runtime. Refusing to
# construct one made that shipped feature unusable. The mistake
# this guard exists to catch -- forgetting to pass agents -- is
# still caught loudly by _require_runnable_agents() before any
# work starts.
self._constructed_without_agents = not has_handler_only_task
# ─────────────────────────────────────────────────────────────────────
# Core initialization
@@ -1568,6 +1480,72 @@ class AgentTeam(SpawnAnnounceProtocol):
task_result = _process_task_result(self, context, agent_output)
return task_result.task_output
def _apply_human_review(self, task, task_id, task_output):
"""Ask a person to approve this task's OUTPUT before the next task uses it.
Fills a real gap between the two things that already existed: the
approval system gates a TOOL CALL, and guardrails validate
automatically. Neither let an orchestrator say "a person must sign this
off". Python parity target: CrewAI's ``Task(human_input=True)``.
Reuses the approval backends rather than prompting directly, so this
works wherever approvals already do -- console, callback, bot, UI --
instead of hard-wiring input() and breaking every non-TTY caller.
Returns:
tuple: (task_output, should_retry)
"""
if not getattr(task, "human_input", False):
return task_output, False
try:
from ..approval import ApprovalRequest, get_approval_registry
except ImportError:
# Never silently skip a review a caller asked for: a task that was
# supposed to be signed off and simply was not is the failure this
# feature exists to prevent.
raise RuntimeError(
f"Task {task_id} sets human_input=True but praisonaiagents.approval "
f"is unavailable, so the output cannot be reviewed. Install it, or "
f"remove human_input."
)
backend = get_approval_registry().get_backend()
prompt = task.human_review_prompt or (
f"Approve the output of task {getattr(task, 'name', None) or task_id}?"
)
request = ApprovalRequest(
tool_name=f"task_output:{getattr(task, 'name', None) or task_id}",
arguments={"output": str(getattr(task_output, "raw", task_output))[:4000]},
risk_level="high",
agent_name=getattr(getattr(task, "agent", None), "name", None),
context={"kind": "task_output_review", "prompt": prompt},
)
decision = backend.request_approval_sync(request)
if getattr(decision, "approved", False):
return task_output, False
reason = getattr(decision, "reason", None) or "a reviewer rejected this output"
if task.retry_count >= task.max_retries:
raise Exception(
f"Task {task_id} was rejected by a reviewer after "
f"{task.max_retries} retries. Last reason: {reason}"
)
task.retry_count += 1
task.status = "in progress"
# Reuse the slot the guardrail retry already fills, so the reviewer's
# reason reaches the re-run's prompt through machinery that exists.
# Must be the SAME shape the guardrail writes: Process._build_task_context
# indexes feedback['validation_response'], so a bare string would raise
# the moment the workflow engine consumed it.
task.validation_feedback = {
'validation_response': reason,
'validated_task': getattr(task, 'name', None) or getattr(task, 'description', None),
'rejected_output': str(getattr(task_output, 'raw', task_output)),
}
return task_output, True
def _apply_task_guardrail(self, task, task_id, task_output):
"""Apply guardrail validation to task output.
@@ -1657,6 +1635,25 @@ class AgentTeam(SpawnAnnounceProtocol):
),
)
async def _aapply_human_review(self, task, task_id, task_output):
"""Async wrapper for _apply_human_review.
The approval backend's ``request_approval_sync`` blocks (console input,
a bot round-trip, a UI wait). Offload it to a thread so the review does
not stall the event loop and every other task running concurrently under
asyncio.gather, mirroring _aapply_task_guardrail.
"""
if not getattr(task, "human_input", False):
return task_output, False
loop = asyncio.get_event_loop()
from ..trace.context_events import copy_context_to_callable
return await loop.run_in_executor(
None,
copy_context_to_callable(
lambda: self._apply_human_review(task, task_id, task_output)
),
)
def _run_task_start_hook(self, task, task_id):
"""Run the on_task_start hook and propagate global variables to the task.
@@ -1745,6 +1742,11 @@ class AgentTeam(SpawnAnnounceProtocol):
# Apply guardrail validation using shared helper (offloaded to
# a thread so a blocking LLM guardrail does not stall the loop)
task_output, should_retry = await self._aapply_task_guardrail(task, task_id, task_output)
if not should_retry:
# A person reviews only what already passed the automatic
# checks, mirroring run_task. Without this an async task
# with human_input=True would release unreviewed output.
task_output, should_retry = await self._aapply_human_review(task, task_id, task_output)
if should_retry:
retries += 1
continue
@@ -2030,6 +2032,20 @@ class AgentTeam(SpawnAnnounceProtocol):
"praisonai_tools_scope_depths", default=()
)
# ── re-entrancy ownership ────────────────────────────────────────────────
# A ContextVar, not an instance attribute keyed by threading.get_ident():
# every asyncio Task scheduled on one event loop shares a single OS thread
# id, so two coroutines that astart() the SAME team under one asyncio.gather
# would both see current == _run_owner_ident and take the re-entrant branch,
# bypassing the lock and interleaving writes to shared task state. Same
# reasoning as _tools_scope_depths above. The sync threading.Lock still
# blocks genuinely concurrent OS threads; the ContextVar only fixes the
# asyncio-tasks-on-one-thread case. Value is id(self) so nested runs on the
# same team pass through while unrelated tasks each get their own ownership.
_run_owner: "contextvars.ContextVar" = contextvars.ContextVar(
"praisonai_run_owner", default=None
)
def _needs_tools_scope(self) -> bool:
"""True when this call should provision the team's shared sandbox."""
if getattr(self, "tools_run_on", None) is None:
@@ -2081,6 +2097,21 @@ class AgentTeam(SpawnAnnounceProtocol):
self._run_lock = lock
return lock
def _execution_identity(self):
"""Identity of the current execution for re-entrancy checks.
Pins ``id(self)`` to the *acquiring* thread and asyncio task so a child
task (which copies the parent ContextVar and would inherit the plain
``id(self)`` marker) cannot be mistaken for legitimate same-execution
nesting and bypass the held lock. A synchronous batch → per-item run
stays on the same thread and task, so it still re-enters as intended.
"""
try:
task = asyncio.current_task()
except RuntimeError:
task = None
return (id(self), threading.get_ident(), id(task) if task is not None else None)
@contextlib.contextmanager
def _guard_execution(self):
"""Refuse concurrent runs on the same instance, re-entrant for one owner.
@@ -2094,9 +2125,16 @@ class AgentTeam(SpawnAnnounceProtocol):
(``variables``/task snapshot) can never interleave with a competing run.
"""
lock = self._execution_lock
current = threading.get_ident()
if getattr(self, '_run_owner_ident', None) == current:
# Re-entrant on the owning thread (e.g. batch → per-item start()).
owner = self._execution_identity()
if self._run_owner.get() == owner:
# Re-entrant on the owning asyncio task / thread (e.g. batch →
# per-item start()). Tracked via ContextVar, not threading.get_ident(),
# so coroutines sharing one OS thread under asyncio.gather don't
# falsely see each other as the same owner and bypass the lock. The
# identity also pins the acquiring thread + asyncio task id, so a
# child task (which copies the parent context and would otherwise
# inherit the owner marker) does NOT falsely re-enter and bypass the
# held lock when a tool/guardrail starts the same team.
yield
return
if not lock.acquire(blocking=False):
@@ -2105,13 +2143,32 @@ class AgentTeam(SpawnAnnounceProtocol):
"safe to run concurrently on the same object. Create a separate "
"AgentTeam/PraisonAIAgents instance per concurrent run."
)
self._run_owner_ident = current
token = self._run_owner.set(owner)
try:
yield
finally:
self._run_owner_ident = None
self._run_owner.reset(token)
lock.release()
def _require_runnable_agents(self):
"""Fail before execution if the team has nothing that can run.
Construction of an empty team is allowed so the spawn-announce pattern
works, so the "you forgot the agents" error moves here, where we can
also see anything spawned in the meantime.
"""
if not getattr(self, "_constructed_without_agents", False):
return
if self.agents:
return
if getattr(self, "_spawned_agents", None):
return
raise ValueError(
"At least one agent must be provided. This team was created with "
"an empty agents list and nothing was spawned into it before "
"starting -- pass agents=[...] or call spawn_sub_agent() first."
)
async def astart(self, content=None, return_dict=False, **kwargs):
"""Async version of start method.
@@ -2120,6 +2177,7 @@ class AgentTeam(SpawnAnnounceProtocol):
return_dict: If True, returns the full results dictionary instead of only the final response
**kwargs: Additional arguments
"""
self._require_runnable_agents()
# Same shared sandbox as start(). Without this, an async team with
# tools_run_on= ran every tool on the host and said nothing.
if self._needs_tools_scope():
@@ -2268,6 +2326,12 @@ class AgentTeam(SpawnAnnounceProtocol):
if task_output and self.completion_checker(task, task_output.raw):
# Apply guardrail validation using shared helper
task_output, should_retry = self._apply_task_guardrail(task, task_id, task_output)
if not should_retry:
# A person reviews only what already passed the automatic
# checks: waking someone to reject output a guardrail
# would have caught wastes the one resource this feature
# spends.
task_output, should_retry = self._apply_human_review(task, task_id, task_output)
if should_retry:
retries += 1
continue
@@ -2318,6 +2382,20 @@ class AgentTeam(SpawnAnnounceProtocol):
def run_all_tasks(self):
"""Synchronous version of run_all_tasks method"""
# The sync entry point (.start()/.run()) drives tasks one at a time and
# cannot batch async_execution=True tasks via asyncio.gather the way
# arun_all_tasks does. Warn once so the parallel fan-out feature the
# constructor docstring/ValueError advertise doesn't silently degrade to
# sequential execution -- point the caller at the async entry point.
if self.process in ("workflow", "sequential") and any(
getattr(t, 'async_execution', False) for t in self.tasks.values()
):
logger.warning(
"One or more tasks have async_execution=True, but the synchronous "
"entry point (.start()/.run()) runs them sequentially -- use "
"`await team.astart()` for parallel fan-out."
)
process = Process(
tasks=self.tasks,
agents=self.agents,
@@ -2416,6 +2494,7 @@ class AgentTeam(SpawnAnnounceProtocol):
result = agents.start(output="silent")
```
"""
self._require_runnable_agents()
# Remote execution: provision ONE sandbox shared by every agent on the
# team, and tear it down even if execution raises.
if self._needs_tools_scope():
@@ -2658,7 +2737,7 @@ class AgentTeam(SpawnAnnounceProtocol):
``variables``, keeping the public Task API backward-compatible.
"""
snapshot = {}
for task_id, task in self.tasks.items():
for task_id, task in getattr(self, "tasks", {}).items():
snapshot[task_id] = {
"status": getattr(task, "status", None),
"result": getattr(task, "result", None),
@@ -2951,8 +3030,9 @@ class AgentTeam(SpawnAnnounceProtocol):
"""
import hashlib
parts = []
for task_id in sorted(self.tasks, key=lambda k: str(k)):
task = self.tasks[task_id]
tasks = getattr(self, "tasks", {})
for task_id in sorted(tasks, key=lambda k: str(k)):
task = tasks[task_id]
agent = getattr(task, "agent", None)
agent_name = getattr(agent, "name", None) or getattr(agent, "display_name", None) or ""
parts.append("|".join([
@@ -3017,10 +3097,11 @@ class AgentTeam(SpawnAnnounceProtocol):
half-restore, which would look like a resume and behave like a fresh run.
"""
restored = 0
tasks = getattr(self, "tasks", {})
for task_id, state in (snapshot or {}).items():
task = self.tasks.get(task_id)
task = tasks.get(task_id)
if task is None:
for key, candidate in self.tasks.items():
for key, candidate in tasks.items():
if str(key) == str(task_id):
task = candidate
break
@@ -3194,21 +3275,45 @@ class AgentTeam(SpawnAnnounceProtocol):
if getattr(agent, "name", None)
}
def _scoped_recent_interactions(self, own_names: set) -> List[Dict[str, Any]]:
def _own_agent_ids(self) -> set:
"""Stable per-instance identities of this instance's own agents.
Used as the collision-free key for token-report scoping so two agents
that share a display name (e.g. the default ``"Agent"``) are never
merged. Falls back to names elsewhere when ids are unavailable.
"""
return {
getattr(agent, "_approval_scope_id", None)
for agent in (self.agents or [])
if getattr(agent, "_approval_scope_id", None)
}
def _scoped_recent_interactions(
self, own_names: set, own_ids: Optional[set] = None
) -> List[Dict[str, Any]]:
"""Return this instance's own recorded interactions, most-recent last.
The process-wide collector keeps a bounded window of every agent's
interactions; filter it to this instance's agent names so per-model
totals, interaction counts, and the detailed report never leak another
concurrent instance's records.
interactions; filter it to this instance's agents so per-model totals,
interaction counts, and the detailed report never leak another
concurrent instance's records. Filtering prefers the collision-free
``agent_id`` when present, falling back to the display name.
"""
own_ids = own_ids or set()
try:
interactions = get_token_collector().get_recent_interactions(
limit=get_token_collector()._max_recent
)
except Exception:
interactions = get_token_collector().get_recent_interactions(limit=100)
return [i for i in interactions if i.get("agent") in own_names]
def _matches(i: Dict[str, Any]) -> bool:
agent_id = i.get("agent_id")
if agent_id is not None and own_ids:
return agent_id in own_ids
return i.get("agent") in own_names
return [i for i in interactions if _matches(i)]
def _scoped_token_summary(self) -> Dict[str, Any]:
"""Return the global token summary filtered to this instance's agents.
@@ -3216,25 +3321,54 @@ class AgentTeam(SpawnAnnounceProtocol):
The process-wide TokenCollector aggregates every agent in the process,
so two concurrent PraisonAIAgents instances would otherwise read each
other's totals. Scope every reported field — ``by_agent``, ``by_model``,
``total_interactions`` and the totals — to this instance's own agent
names so per-instance/per-tenant cost accounting reports only its own
spend. ``by_agent`` is taken from the collector's aggregate breakdown;
``by_model`` and ``total_interactions`` are re-derived from this
instance's own interaction records (the aggregate cannot be split by
model per agent). Falls back to the unfiltered summary when this
instance has no named agents to scope by.
``total_interactions`` and the totals — to this instance's own agents so
per-instance/per-tenant cost accounting reports only its own spend.
Scoping prefers the collector's collision-free ``by_agent_id``
breakdown; ``by_model`` and ``total_interactions`` are re-derived from
this instance's own interaction records (the aggregate cannot be split
by model per agent). Falls back to the unfiltered summary when this
instance has no agents to scope by.
"""
summary = get_token_collector().get_session_summary()
own_names = self._own_agent_names()
own_ids = self._own_agent_ids()
by_agent = summary.get("by_agent", {})
# No named agents to scope by (or nothing tracked yet): fall back to the
by_agent_id = summary.get("by_agent_id", {})
# No agents to scope by (or nothing tracked yet): fall back to the
# unfiltered summary rather than silently reporting zeros.
if not own_names or not by_agent:
if (not own_names and not own_ids) or (not by_agent and not by_agent_id):
return summary
scoped_by_agent = {
name: metrics for name, metrics in by_agent.items() if name in own_names
}
# Prefer the collision-free per-identity breakdown; fall back to
# name-based scoping when ids are unavailable (backward compatible).
scoped_by_agent: Dict[str, Dict[str, int]] = {}
if own_ids and by_agent_id:
for key, entry in by_agent_id.items():
name = entry.get("name") or key
# Match by stable id (collision-free) OR by display name for
# id-less buckets — e.g. the native OpenAI-client path records
# ``agent`` without an ``agent_id``, so the collector keys that
# bucket by name (``key == name``). Dropping those would zero out
# real OpenAI usage even though the interaction and per-model
# rows still show it (Greptile P1). We treat a bucket as id-less
# when its key equals its own display name.
is_id_match = key in own_ids
is_idless_name_match = key == name and name in own_names
if not (is_id_match or is_idless_name_match):
continue
metrics = entry.get("metrics", {})
if name in scoped_by_agent:
scoped_by_agent[name] = {
k: scoped_by_agent[name].get(k, 0) + v
for k, v in metrics.items()
if isinstance(v, (int, float))
}
else:
scoped_by_agent[name] = dict(metrics)
else:
scoped_by_agent = {
name: metrics for name, metrics in by_agent.items() if name in own_names
}
totals = {
"input_tokens": 0, "output_tokens": 0, "cached_tokens": 0,
"reasoning_tokens": 0, "audio_input_tokens": 0, "audio_output_tokens": 0,
@@ -3250,7 +3384,7 @@ class AgentTeam(SpawnAnnounceProtocol):
# process-global summary (which mixes in other instances' models and
# counts). The collector's ``by_agent`` aggregate cannot be split by
# model, so the per-interaction window is the correct source.
own_interactions = self._scoped_recent_interactions(own_names)
own_interactions = self._scoped_recent_interactions(own_names, own_ids)
by_model: Dict[str, Dict[str, int]] = {}
for interaction in own_interactions:
model = interaction.get("model")
@@ -3284,10 +3418,11 @@ class AgentTeam(SpawnAnnounceProtocol):
summary = self._scoped_token_summary()
own_names = self._own_agent_names()
if own_names:
own_ids = self._own_agent_ids()
if own_names or own_ids:
# Only surface this instance's own interactions so the detailed
# report never exposes another concurrent instance's records.
recent = self._scoped_recent_interactions(own_names)[-20:]
recent = self._scoped_recent_interactions(own_names, own_ids)[-20:]
else:
recent = get_token_collector().get_recent_interactions(limit=20)
@@ -1,532 +0,0 @@
"""
Subagent Delegator for PraisonAI Agents.
Provides delegation primitives for spawning subagents with scoped permissions
and context. Inspired by Gemini CLI's delegate-to-agent pattern.
"""
from praisonaiagents._logging import get_logger
import asyncio
import warnings
import threading
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Callable
from enum import Enum
from .profiles import AgentProfile, AgentMode, get_profile, BUILTIN_PROFILES
logger = get_logger(__name__)
class DelegationStatus(Enum):
"""Status of a delegated task."""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
TIMEOUT = "timeout"
@dataclass
class DelegationConfig:
"""Configuration for subagent delegation."""
# Concurrency limits
max_concurrent_subagents: int = 3
max_total_subagents: int = 10
# Timeouts
default_timeout_seconds: float = 300.0
max_timeout_seconds: float = 600.0
# Resource limits
max_steps_per_subagent: int = 50
max_tokens_per_subagent: int = 50000
# Permissions
inherit_permissions: bool = True
allow_nested_delegation: bool = False
# Behavior
auto_cancel_on_parent_cancel: bool = True
collect_results: bool = True
@dataclass
class DelegationTask:
"""A task delegated to a subagent."""
task_id: str
agent_name: str
objective: str
status: DelegationStatus = DelegationStatus.PENDING
# Configuration
timeout_seconds: float = 300.0
max_steps: int = 50
# Context
context: Dict[str, Any] = field(default_factory=dict)
parent_session_id: Optional[str] = None
# Results
result: Optional[str] = None
error: Optional[str] = None
steps_taken: int = 0
tokens_used: int = 0
# Metadata
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class DelegationResult:
"""Result of a delegation operation."""
task_id: str
agent_name: str
success: bool
result: Optional[str] = None
error: Optional[str] = None
steps_taken: int = 0
tokens_used: int = 0
duration_seconds: float = 0.0
class SubagentDelegator:
"""
Manages delegation of tasks to subagents.
Provides:
- Spawning subagents with scoped permissions
- Concurrency control
- Timeout management
- Result collection
- Cancellation support
Example:
delegator = SubagentDelegator()
# Delegate to explorer agent
result = await delegator.delegate(
agent_name="explorer",
objective="Find all authentication-related files",
context={"workspace": "/path/to/project"}
)
# Delegate multiple tasks
results = await delegator.delegate_parallel([
("explorer", "Find auth files"),
("explorer", "Find database models"),
])
"""
def __init__(
self,
config: Optional[DelegationConfig] = None,
agent_factory: Optional[Callable[[str], Any]] = None,
on_task_complete: Optional[Callable[[DelegationResult], None]] = None,
):
"""
Initialize the delegator.
Args:
config: Delegation configuration
agent_factory: Factory function to create agents
on_task_complete: Callback when task completes
"""
warnings.warn(
"SubagentDelegator is deprecated. Use Agent.handoff_to() or "
"parallel_handoffs() instead.",
DeprecationWarning,
stacklevel=2,
)
self.config = config or DelegationConfig()
self.agent_factory = agent_factory
self.on_task_complete = on_task_complete
# State
self._tasks: Dict[str, DelegationTask] = {}
self._running_asyncio_tasks: Dict[str, "asyncio.Task"] = {}
self._running_count: int = 0
self._total_count: int = 0
self._task_counter: int = 0
# Note: asyncio.Semaphore() is created lazily via _get_semaphore() to support
# Python 3.9 where Semaphore() requires an event loop at creation time.
self.__semaphore: Optional[asyncio.Semaphore] = None
self.__semaphore_lock: threading.Lock = threading.Lock() # Thread-safe initialization
def _get_semaphore(self) -> asyncio.Semaphore:
"""Get the semaphore, creating it lazily if needed.
This deferred creation is required for Python 3.9 compatibility
where asyncio.Semaphore() calls get_event_loop() at creation time.
"""
if self.__semaphore is None:
with self.__semaphore_lock: # Thread-safe initialization
if self.__semaphore is None: # Double-check after acquiring lock
self.__semaphore = asyncio.Semaphore(self.config.max_concurrent_subagents)
return self.__semaphore
def get_available_agents(self) -> List[str]:
"""Get list of available agent names for delegation."""
return [
name for name, profile in BUILTIN_PROFILES.items()
if profile.mode in (AgentMode.SUBAGENT, AgentMode.ALL)
and not profile.hidden
]
def get_agent_description(self, agent_name: str) -> str:
"""Get description for an agent."""
profile = get_profile(agent_name)
if profile:
return profile.description
return f"Agent: {agent_name}"
async def delegate(
self,
agent_name: str,
objective: str,
context: Optional[Dict[str, Any]] = None,
timeout_seconds: Optional[float] = None,
max_steps: Optional[int] = None,
) -> DelegationResult:
"""
Delegate a task to a subagent.
Args:
agent_name: Name of the agent to delegate to
objective: Task objective/description
context: Optional context to pass to agent
timeout_seconds: Optional timeout override
max_steps: Optional max steps override
Returns:
DelegationResult with task outcome
"""
# Check limits
if self._total_count >= self.config.max_total_subagents:
return DelegationResult(
task_id="",
agent_name=agent_name,
success=False,
error=f"Max total subagents ({self.config.max_total_subagents}) reached",
)
# Create task
task_id = self._generate_task_id()
task = DelegationTask(
task_id=task_id,
agent_name=agent_name,
objective=objective,
timeout_seconds=timeout_seconds or self.config.default_timeout_seconds,
max_steps=max_steps or self.config.max_steps_per_subagent,
context=context or {},
)
self._tasks[task_id] = task
self._total_count += 1
# Execute with concurrency control. Wrap in a real asyncio.Task so
# cancel_task/cancel_all can actually propagate cancellation to the
# awaited subagent coroutine instead of only flipping a status enum.
async with self._get_semaphore():
# If cancelled while queued on the semaphore, don't start the work.
if task.status == DelegationStatus.CANCELLED:
return DelegationResult(
task_id=task.task_id,
agent_name=task.agent_name,
success=False,
error="Task was cancelled",
)
exec_task = asyncio.ensure_future(self._execute_task(task))
self._running_asyncio_tasks[task_id] = exec_task
try:
return await exec_task
except asyncio.CancelledError:
# Distinguish a delegator-initiated cancel (via cancel_task/cancel_all,
# which sets status=CANCELLED) from an external/cooperative cancel of
# the caller. Only the former is swallowed into a result; an external
# cancel must propagate so wait_for/shutdown behave correctly.
if task.status == DelegationStatus.CANCELLED:
return DelegationResult(
task_id=task.task_id,
agent_name=task.agent_name,
success=False,
error="Task was cancelled",
)
raise
finally:
self._running_asyncio_tasks.pop(task_id, None)
async def delegate_parallel(
self,
tasks: List[tuple],
collect_all: bool = True,
) -> List[DelegationResult]:
"""
Delegate multiple tasks in parallel.
Args:
tasks: List of (agent_name, objective) or (agent_name, objective, context) tuples
collect_all: Wait for all tasks to complete
Returns:
List of DelegationResults
"""
# Create coroutines
coros = []
for task_spec in tasks:
if len(task_spec) == 2:
agent_name, objective = task_spec
context = None
else:
agent_name, objective, context = task_spec
coros.append(self.delegate(agent_name, objective, context))
# Execute
if collect_all:
results = await asyncio.gather(*coros, return_exceptions=True)
# Convert exceptions to failed results
processed = []
for i, result in enumerate(results):
if isinstance(result, Exception):
agent_name = tasks[i][0] if tasks[i] else "unknown"
processed.append(DelegationResult(
task_id="",
agent_name=agent_name,
success=False,
error=str(result),
))
else:
processed.append(result)
return processed
else:
# Return as tasks complete
return await asyncio.gather(*coros)
async def cancel_task(self, task_id: str) -> bool:
"""
Cancel a running task.
Args:
task_id: ID of task to cancel
Returns:
True if task was cancelled
"""
task = self._tasks.get(task_id)
# Only terminal tasks are un-cancellable; PENDING/RUNNING can be cancelled.
_terminal = (
DelegationStatus.COMPLETED,
DelegationStatus.FAILED,
DelegationStatus.CANCELLED,
DelegationStatus.TIMEOUT,
)
if not task or task.status in _terminal:
return False
# Mark cancelled first so a PENDING task (still waiting on the semaphore)
# or a RUNNING coroutine both observe CANCELLED and refuse to complete.
task.status = DelegationStatus.CANCELLED
# Propagate cancellation to the actual coroutine if it is already running.
asyncio_task = self._running_asyncio_tasks.get(task_id)
if asyncio_task is not None:
asyncio_task.cancel()
return True
async def cancel_all(self) -> int:
"""
Cancel all running tasks.
Returns:
Number of tasks cancelled
"""
cancelled = 0
for task_id in list(self._tasks.keys()):
if await self.cancel_task(task_id):
cancelled += 1
return cancelled
def get_task_status(self, task_id: str) -> Optional[DelegationStatus]:
"""Get status of a task."""
task = self._tasks.get(task_id)
return task.status if task else None
def get_running_tasks(self) -> List[DelegationTask]:
"""Get all running tasks."""
return [
t for t in self._tasks.values()
if t.status == DelegationStatus.RUNNING
]
def get_stats(self) -> Dict[str, Any]:
"""Get delegation statistics."""
status_counts = {}
for task in self._tasks.values():
status = task.status.value
status_counts[status] = status_counts.get(status, 0) + 1
return {
"total_tasks": self._total_count,
"running_tasks": self._running_count,
"status_counts": status_counts,
"max_concurrent": self.config.max_concurrent_subagents,
"max_total": self.config.max_total_subagents,
}
async def _execute_task(self, task: DelegationTask) -> DelegationResult:
"""Execute a delegation task."""
import time
start_time = time.time()
# A cancel may have landed between scheduling and execution; honour it.
if task.status == DelegationStatus.CANCELLED:
return DelegationResult(
task_id=task.task_id,
agent_name=task.agent_name,
success=False,
error="Task was cancelled",
)
task.status = DelegationStatus.RUNNING
self._running_count += 1
try:
# Get agent profile
profile = get_profile(task.agent_name)
if not profile:
raise ValueError(f"Unknown agent: {task.agent_name}")
# Create agent
agent = await self._create_agent(profile, task)
# Execute with timeout
try:
result = await asyncio.wait_for(
self._run_agent(agent, task),
timeout=task.timeout_seconds,
)
task.result = result
# Don't clobber a cancellation that landed while we were running.
if task.status != DelegationStatus.CANCELLED:
task.status = DelegationStatus.COMPLETED
except asyncio.TimeoutError:
task.status = DelegationStatus.TIMEOUT
task.error = f"Task timed out after {task.timeout_seconds}s"
except Exception as e:
task.status = DelegationStatus.FAILED
task.error = str(e)
logger.exception(f"Delegation task {task.task_id} failed")
finally:
self._running_count -= 1
duration = time.time() - start_time
# Build result
result = DelegationResult(
task_id=task.task_id,
agent_name=task.agent_name,
success=task.status == DelegationStatus.COMPLETED,
result=task.result,
error=task.error,
steps_taken=task.steps_taken,
tokens_used=task.tokens_used,
duration_seconds=duration,
)
# Callback
if self.on_task_complete:
try:
self.on_task_complete(result)
except Exception as e:
logger.warning(f"Task complete callback error: {e}")
return result
async def _create_agent(self, profile: AgentProfile, task: DelegationTask) -> Any:
"""Create an agent from profile using consolidated params."""
if self.agent_factory:
return self.agent_factory(profile.name)
# Default: create Agent from praisonaiagents
try:
from ..agent.agent import Agent
# Filter tools based on profile
tools = self._get_tools_for_profile(profile)
# Use consolidated params (protocol-compliant)
# output='silent' replaces verbose=False
# execution={max_iter: N} replaces max_iter=N
agent = Agent(
name=profile.name,
role=profile.name.title(),
goal=task.objective,
backstory=profile.system_prompt,
tools=tools,
output='silent',
execution={'max_iter': min(task.max_steps, profile.max_steps)},
)
return agent
except ImportError:
raise RuntimeError("Could not import Agent class")
async def _run_agent(self, agent: Any, task: DelegationTask) -> str:
"""Run an agent on a task."""
# Build prompt with context
prompt = task.objective
if task.context:
context_str = "\n".join(
f"- {k}: {v}" for k, v in task.context.items()
)
prompt = f"Context:\n{context_str}\n\nTask: {task.objective}"
# Execute
if hasattr(agent, 'chat'):
result = agent.chat(prompt)
elif hasattr(agent, 'run'):
result = agent.run(prompt)
else:
raise RuntimeError("Agent has no chat or run method")
return str(result) if result else ""
def _get_tools_for_profile(self, profile: AgentProfile) -> List[Any]:
"""Get tools for a profile, respecting restrictions."""
# For now, return empty list - tools should be injected
# In production, this would filter available tools
return []
def _generate_task_id(self) -> str:
"""Generate a unique task ID."""
self._task_counter += 1
return f"task_{self._task_counter}"
# Convenience function for quick delegation
async def delegate_to_agent(
agent_name: str,
objective: str,
context: Optional[Dict[str, Any]] = None,
timeout_seconds: float = 300.0,
) -> DelegationResult:
"""
Quick delegation to a subagent.
Args:
agent_name: Name of agent to delegate to
objective: Task objective
context: Optional context
timeout_seconds: Timeout in seconds
Returns:
DelegationResult
"""
delegator = SubagentDelegator()
return await delegator.delegate(
agent_name=agent_name,
objective=objective,
context=context,
timeout_seconds=timeout_seconds,
)
@@ -213,7 +213,11 @@ class AllowedToolsFilter:
"env_var_name": self.env_var_name,
"env_value": self.env_value,
"is_ci": self.is_ci,
"whitelist": list(self._whitelist) if self._whitelist else None,
# sorted, not list(): _whitelist is a set, so list() gave a
# different order on different runs. A diagnostic report you cannot
# diff between two runs is much less useful for the debugging it
# exists for.
"whitelist": sorted(self._whitelist) if self._whitelist else None,
**self._diagnostics
}
@@ -13,7 +13,7 @@ from __future__ import annotations
import asyncio
import re
from praisonaiagents._logging import get_logger
from typing import Any
from typing import Any, Optional
from .protocols import ApprovalDecision, ApprovalRequest
@@ -175,7 +175,7 @@ class AutoApproveBackend:
async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
return ApprovalDecision(approved=True, reason="auto-approved", approver="system")
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
def request_approval_sync(self, request: ApprovalRequest, timeout: Optional[float] = None) -> ApprovalDecision:
return ApprovalDecision(approved=True, reason="auto-approved", approver="system")
class ConsoleBackend:
@@ -293,7 +293,7 @@ class ConsoleBackend:
return (True, "always", suggested or None, None)
return (True, "once", None, None)
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
def request_approval_sync(self, request: ApprovalRequest, timeout: Optional[float] = None) -> ApprovalDecision:
"""Synchronous approval via Rich console prompt."""
try:
approved, scope, scope_pattern, feedback = self._prompt_user(request)
@@ -474,10 +474,12 @@ class AgentApproval:
reason=f"Agent approval error: {e}",
)
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
def request_approval_sync(self, request: ApprovalRequest, timeout: Optional[float] = None) -> ApprovalDecision:
"""Synchronous wrapper."""
from .utils import run_coroutine_safely
return run_coroutine_safely(self.request_approval(request), timeout=60)
return run_coroutine_safely(
self.request_approval(request), timeout=60 if timeout is None else timeout
)
class CallbackBackend:
"""Wraps a legacy ``(function_name, arguments, risk_level) -> ApprovalDecision`` callback
@@ -489,7 +491,7 @@ class CallbackBackend:
def __init__(self, callback):
self._callback = callback
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
def request_approval_sync(self, request: ApprovalRequest, timeout: Optional[float] = None) -> ApprovalDecision:
result = self._callback(request.tool_name, request.arguments, request.risk_level)
if isinstance(result, ApprovalDecision):
return result
@@ -189,6 +189,18 @@ class ApprovalProtocol(Protocol):
"""
...
# Backends MAY also implement an optional synchronous entry point:
#
# def request_approval_sync(self, request, timeout=None) -> ApprovalDecision
#
# It is deliberately NOT declared on this runtime-checkable Protocol so an
# async-only backend still satisfies ``isinstance(b, ApprovalProtocol)``.
# When present, the per-call ``timeout`` (seconds; ``None`` = wait forever)
# is passed explicitly by the caller so shared backend state is never
# mutated — mutating a shared backend's timeout leaks between agents.
# Legacy sync backends without the ``timeout`` parameter are still
# supported; the caller falls back to a no-argument call on ``TypeError``.
@runtime_checkable
class ApprovalStoreProtocol(Protocol):
@@ -31,16 +31,24 @@ class QwenCliAuth:
name = "qwen-cli"
def resolve_credentials(self) -> SubscriptionCredentials:
"""Resolve Qwen CLI credentials.
The origin comes from the `PRAISONAI_QWEN_BASE_URL` environment variable when it is set
to a non-empty value, and otherwise from the Qwen default.
"""
tokens = _read_qwen_tokens()
if not tokens:
raise AuthError(
"No Qwen CLI credentials at ~/.qwen/oauth_creds.json. "
"Install Qwen CLI and run 'qwen login'."
)
# Resolve once and use the same value for both: `headers_for` takes the origin the request
# will actually go to, which is not necessarily the Qwen default.
base_url = os.environ.get("PRAISONAI_QWEN_BASE_URL", "").strip() or _QWEN_BASE_URL
return SubscriptionCredentials(
api_key=tokens["access_token"],
base_url=os.environ.get("PRAISONAI_QWEN_BASE_URL", "").strip() or _QWEN_BASE_URL,
headers=self.headers_for(_QWEN_BASE_URL, ""),
base_url=base_url,
headers=self.headers_for(base_url, ""),
auth_scheme="bearer",
source="qwen-cli-file",
)
@@ -69,6 +69,13 @@ class BackgroundRunner:
"""
self.config = config or BackgroundConfig()
self._tasks: Dict[str, BackgroundTask] = {}
# Guards every read/write of ``_tasks``. This runner is a process-wide
# singleton (``get_background_runner``) whose sync wrappers insert on a
# separate background-loop thread via ``run_coroutine_threadsafe`` while
# other surfaces read/mutate the dict on their own thread; without this
# a concurrent insert + iterate/delete can raise
# ``RuntimeError: dictionary changed size during iteration``.
self._tasks_lock: threading.Lock = threading.Lock()
# Lazily create asyncio.Semaphore() to avoid Python 3.9 event loop issues
self._semaphore: Optional[asyncio.Semaphore] = None
self._semaphore_lock: threading.Lock = threading.Lock() # Thread-safe initialization
@@ -86,17 +93,20 @@ class BackgroundRunner:
@property
def tasks(self) -> List[BackgroundTask]:
"""Get all tasks."""
return list(self._tasks.values())
with self._tasks_lock:
return list(self._tasks.values())
@property
def running_tasks(self) -> List[BackgroundTask]:
"""Get currently running tasks."""
return [t for t in self._tasks.values() if t.is_running]
with self._tasks_lock:
return [t for t in self._tasks.values() if t.is_running]
@property
def pending_tasks(self) -> List[BackgroundTask]:
"""Get pending tasks."""
return [t for t in self._tasks.values() if t.status == TaskStatus.PENDING]
with self._tasks_lock:
return [t for t in self._tasks.values() if t.status == TaskStatus.PENDING]
async def submit(
self,
@@ -133,7 +143,8 @@ class BackgroundRunner:
}
)
self._tasks[task.id] = task
with self._tasks_lock:
self._tasks[task.id] = task
# Create the execution coroutine
async def execute():
@@ -235,7 +246,8 @@ class BackgroundRunner:
def get_task(self, task_id: str) -> Optional[BackgroundTask]:
"""Get a task by ID."""
return self._tasks.get(task_id)
with self._tasks_lock:
return self._tasks.get(task_id)
async def cancel_task(self, task_id: str) -> bool:
"""
@@ -247,7 +259,8 @@ class BackgroundRunner:
Returns:
True if cancelled, False if not found or already completed
"""
task = self._tasks.get(task_id)
with self._tasks_lock:
task = self._tasks.get(task_id)
if task is None:
return False
@@ -276,10 +289,12 @@ class BackgroundRunner:
Returns:
List of completed tasks
"""
futures = [t._future for t in self._tasks.values() if t._future]
with self._tasks_lock:
futures = [t._future for t in self._tasks.values() if t._future]
if not futures:
return list(self._tasks.values())
with self._tasks_lock:
return list(self._tasks.values())
try:
await asyncio.wait_for(
@@ -289,16 +304,18 @@ class BackgroundRunner:
except asyncio.TimeoutError:
pass
return list(self._tasks.values())
with self._tasks_lock:
return list(self._tasks.values())
def clear_completed(self):
"""Remove completed tasks from tracking."""
completed_ids = [
task_id for task_id, task in self._tasks.items()
if task.is_completed
]
for task_id in completed_ids:
del self._tasks[task_id]
with self._tasks_lock:
completed_ids = [
task_id for task_id, task in self._tasks.items()
if task.is_completed
]
for task_id in completed_ids:
del self._tasks[task_id]
def list_tasks(self, status: Optional[TaskStatus] = None) -> List[Dict[str, Any]]:
"""
@@ -310,7 +327,8 @@ class BackgroundRunner:
Returns:
List of task dictionaries
"""
tasks = self._tasks.values()
with self._tasks_lock:
tasks = list(self._tasks.values())
if status:
tasks = [t for t in tasks if t.status == status]
@@ -18,7 +18,10 @@ from .protocols import (
BotMessage,
BotUser,
BotChannel,
QuotedRef,
MessageType,
PlatformEvent,
PlatformEventKind,
ChatCommandInfo,
ChatCommandProtocol,
BotOSProtocol,
@@ -135,10 +138,13 @@ __all__ = [
"BotMessage",
"BotUser",
"BotChannel",
"QuotedRef",
"BotConfig",
"DisplayPolicy",
"resolve_display_policy",
"MessageType",
"PlatformEvent",
"PlatformEventKind",
"ChatCommandInfo",
"ChatCommandProtocol",
"BotOSProtocol",
@@ -125,11 +125,29 @@ 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
silence_token: Optional[str] = None
# Bot-to-bot inbound (#5062). Off by default: a bot-authored inbound
# message is dropped as before. When ``allow_bots=True`` the channel accepts
# bot-authored messages and the runaway-loop guard (``BotLoopGuard``) is
# auto-active, tracking each participant pair and suppressing further replies
# once ``bot_loop_protection``'s sliding-window budget is exceeded.
# ``bot_loop_protection`` is a plain dict (enabled / max_events_per_window /
# window_seconds / cooldown_seconds) coerced into a ``BotLoopPolicy``; ``None``
# uses the default budget.
allow_bots: bool = False
bot_loop_protection: Optional[Dict[str, Any]] = None
def __post_init__(self) -> None:
if self.unknown_user_policy not in {"deny", "pair", "allow"}:
raise ValueError(
@@ -174,8 +192,11 @@ 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,
"bot_loop_protection": self.bot_loop_protection,
"metadata": self.metadata,
}
@@ -21,6 +21,7 @@ from typing import (
Callable,
Dict,
List,
Literal,
Mapping,
Optional,
Protocol,
@@ -496,6 +497,43 @@ class BotChannel:
)
@dataclass
class QuotedRef:
"""Resolved content of a message a user replied to or quoted.
Chat platforms carry only the *id* of a quoted message on the admission
path; the referenced text/media must be resolved and attached so the agent
can honour the referent ("do the second one", "why?"). This is that
resolved reference, rendered into the prompt as a compact quoted block.
Attributes:
message_id: Platform-specific id of the quoted message.
text: Resolved text of the quoted message ("" if unavailable).
author: ``"bot"`` when quoting the bot's own message, else ``"user"``.
"""
message_id: str = ""
text: str = ""
author: str = "" # "bot" | "user"
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"message_id": self.message_id,
"text": self.text,
"author": self.author,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "QuotedRef":
"""Create from dictionary."""
return cls(
message_id=data.get("message_id", ""),
text=data.get("text", ""),
author=data.get("author", ""),
)
@dataclass
class BotMessage:
"""Represents a message in a messaging platform.
@@ -510,6 +548,11 @@ class BotMessage:
reply_to: ID of message being replied to
thread_id: Thread identifier (for threaded conversations)
attachments: List of attachment URLs or data
quoted: Resolved reply/quote context (see :class:`QuotedRef`). When a
user replies to or quotes an earlier message the adapter resolves
the referenced content and attaches it here so the agent sees the
referent instead of answering context-blind. Rendered into the
prompt as a quoted block by :meth:`prompt_text`.
metadata: Additional platform-specific metadata
allow_control: Control-trust primitive. ``True`` (the default) only for
interactive human turns; producers set it ``False`` for content of
@@ -530,6 +573,7 @@ class BotMessage:
reply_to: Optional[str] = None
thread_id: Optional[str] = None
attachments: List[Dict[str, Any]] = field(default_factory=list)
quoted: Optional[QuotedRef] = None
metadata: Dict[str, Any] = field(default_factory=dict)
allow_control: bool = True
@@ -545,6 +589,7 @@ class BotMessage:
"reply_to": self.reply_to,
"thread_id": self.thread_id,
"attachments": self.attachments,
"quoted": self.quoted.to_dict() if self.quoted else None,
"metadata": self.metadata,
"allow_control": self.allow_control,
}
@@ -571,6 +616,7 @@ class BotMessage:
reply_to=data.get("reply_to"),
thread_id=data.get("thread_id"),
attachments=data.get("attachments", []),
quoted=QuotedRef.from_dict(data["quoted"]) if data.get("quoted") else None,
metadata=data.get("metadata", {}),
allow_control=data.get("allow_control", True),
)
@@ -581,6 +627,31 @@ class BotMessage:
if isinstance(self.content, str):
return self.content
return self.content.get("text", "")
@property
def prompt_text(self) -> str:
"""Text for the agent turn, with any resolved quote rendered inline.
When the user replied to or quoted an earlier message, the resolved
reference (:attr:`quoted`) is rendered as a compact quoted block above
the new text so the model can honour the referent, e.g.::
[In reply to: "Here are three options: 1) … 2) … 3) …"]
do the second one
With no quote this is identical to :attr:`text`. The quoted content is
injected as context only, never as a control frame — consistent with
the ``allow_control`` primitive.
"""
body = self.text
quoted = self.quoted
if not quoted or not quoted.text.strip():
return body
snippet = " ".join(quoted.text.split())
if len(snippet) > 500:
snippet = snippet[:497] + "…"
block = f'[In reply to: "{snippet}"]'
return f"{block}\n{body}" if body else block
@property
def is_command(self) -> bool:
@@ -620,6 +691,76 @@ class BotMessage:
return parts[1:] if len(parts) > 1 else []
# Normalised inbound platform-event kinds. Mirrors how ``MessageType`` normalises
# inbound messages: a small, portable vocabulary every adapter maps its native
# event onto (Telegram ``MessageReactionUpdated`` / Discord ``on_raw_reaction_add``
# / Slack ``reaction_added`` all become ``"reaction_added"``), so hooks/plugins
# have one uniform surface to subscribe to (Issue #5161).
PlatformEventKind = Literal[
"reaction_added",
"reaction_removed",
"message_edited",
"message_deleted",
"member_joined",
"member_left",
"thread_created",
]
@dataclass
class PlatformEvent:
"""A normalised inbound platform event that is not a text/media message.
Reactions, edits, deletions, membership changes and thread lifecycle are
delivered by the platform SDK but are not messages. This is the single
shared, protocol-only contract every adapter emits into and every hook or
plugin consumes — the inbound counterpart to the outbound ``ReactionResult``
/ message-action surface the bot already ships.
Adapters build one of these from the native event and emit it via
``MessageHookMixin.fire_platform_event`` (praisonai-bot), which routes it to
the matching :class:`~praisonaiagents.hooks.types.HookEvent`
(``REACTION_RECEIVED``/``MESSAGE_EDITED``/``MESSAGE_DELETED``/
``MEMBER_JOINED``/``MEMBER_LEFT``/``THREAD_CREATED``). Platforms that cannot
deliver a given event simply never build one — graceful, capability-gated,
exactly like the outbound ``capabilities["reactions"]`` gate.
Attributes:
kind: Which normalised event this is (see :data:`PlatformEventKind`).
platform: Emitting platform name (``"telegram"``/``"discord"``/…).
chat_id: Channel/chat the event occurred in.
user_id: The user who caused the event (reactor, editor, joiner, …).
message_id: The message the event targets (reactions/edits/deletes).
emoji: The reaction emoji, for reaction add/remove.
new_text: The new content, for an edit.
thread_id: The thread identifier, for thread creation / threaded events.
raw: Escape hatch to the native SDK payload for adapter-specific needs.
"""
kind: PlatformEventKind
platform: str
chat_id: str
user_id: str
message_id: Optional[str] = None
emoji: Optional[str] = None
new_text: Optional[str] = None
thread_id: Optional[str] = None
raw: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary (native ``raw`` payload intentionally omitted)."""
return {
"kind": self.kind,
"platform": self.platform,
"chat_id": self.chat_id,
"user_id": self.user_id,
"message_id": self.message_id,
"emoji": self.emoji,
"new_text": self.new_text,
"thread_id": self.thread_id,
}
@runtime_checkable
class BotMessageProtocol(Protocol):
"""Protocol for bot message handling."""
@@ -138,15 +138,31 @@ class BotLoopPolicy:
"""Build a policy from a config mapping (YAML/kwargs), tolerating None.
Unknown keys are ignored so a forward-compatible config never breaks
construction. ``None`` yields the enabled default policy.
construction. ``None`` yields the enabled default policy. Malformed
numeric values (e.g. ``max_events_per_window: "abc"``) fall back to the
field default rather than raising, so a config typo cannot crash the
inbound path on the first bot message — the guard stays fail-safe.
"""
if not data:
return cls()
def _as_int(value: object, default: int) -> int:
try:
return int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return default
def _as_float(value: object, default: float) -> float:
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return default
return cls(
enabled=bool(data.get("enabled", True)),
max_events_per_window=int(data.get("max_events_per_window", 20)),
window_seconds=float(data.get("window_seconds", 60.0)),
cooldown_seconds=float(data.get("cooldown_seconds", 60.0)),
max_events_per_window=_as_int(data.get("max_events_per_window", 20), 20),
window_seconds=_as_float(data.get("window_seconds", 60.0), 60.0),
cooldown_seconds=_as_float(data.get("cooldown_seconds", 60.0), 60.0),
)
+45 -46
View File
@@ -172,18 +172,16 @@ class EventBus:
Returns:
The published Event object
"""
# Fast path: if no subscribers and no durable sinks, return a minimal
# event without expensive operations.
if not self._subscribers and not self._sinks:
# Convert EventType enum to string
type_str = event_type.value if isinstance(event_type, EventType) else event_type
return Event(
type=type_str,
data=data or {},
source=source,
metadata=metadata or {},
)
# There is no separate no-subscriber shortcut here. It built the same
# Event and returned it, so the only work it saved was the sink
# dispatch and the history append below -- and skipping the append
# meant an event published with nobody listening vanished from
# get_history(). History is a debugging record of what was published;
# whether anyone happened to be subscribed at the time is not something
# it should silently depend on. The append is O(1) under a lock already
# held and the list is capped at _max_history, so the saving was not
# worth the hole it left.
# Convert EventType enum to string
type_str = event_type.value if isinstance(event_type, EventType) else event_type
@@ -212,16 +210,19 @@ class EventBus:
# the event even when there are no in-memory subscribers.
self._dispatch_to_sinks(event)
# Fast path: if no subscribers, skip expensive work
if not self._subscribers:
return event
# Store in history
# Record the event before considering subscribers, for the same reason
# _dispatch_to_sinks runs above: an event that was published happened,
# whether or not anyone was listening for it.
with self._lock:
self._event_history.append(event)
if len(self._event_history) > self._max_history:
self._event_history = self._event_history[-self._max_history:]
# Fast path: with nothing subscribed there is no dispatch to do.
if not self._subscribers:
return event
with self._lock:
# Get matching subscribers
subscribers = [
sub for sub in self._subscribers
@@ -266,17 +267,13 @@ class EventBus:
The published Event object
"""
type_str = event_type.value if isinstance(event_type, EventType) else event_type
# Fast path: if no subscribers and no durable sinks, return a minimal
# event without expensive operations.
if not self._subscribers and not self._sinks:
return Event(
type=type_str,
data=data or {},
source=source,
metadata=metadata or {},
)
# No pre-history no-subscriber shortcut here, matching publish_event's
# already-fixed shape: an event that was published happened, whether or
# not anyone was listening, and skipping the history append below would
# make it vanish from get_history(). The append is O(1) under a lock and
# the list is capped, so the only work worth skipping is subscriber
# matching/dispatch -- which the guard after the append still skips.
event = Event(
type=type_str,
data=data or {},
@@ -289,15 +286,21 @@ class EventBus:
# Durable persistence first (best-effort).
self._dispatch_to_sinks(event)
# Fast path: if no subscribers, skip expensive work
if not self._subscribers:
return event
# Store in history
# History is recorded BEFORE the no-subscriber fast path. Appending to a
# capped list is not the "expensive work" that path exists to skip --
# that is subscriber matching and dispatch. Returning first meant
# get_history() stayed empty unless something happened to be subscribed,
# so the record of what the system did depended on who was watching.
with self._lock:
self._event_history.append(event)
if len(self._event_history) > self._max_history:
self._event_history = self._event_history[-self._max_history:]
# Fast path: if no subscribers, skip subscriber matching and dispatch.
if not self._subscribers:
return event
with self._lock:
subscribers = [
sub for sub in self._subscribers
@@ -329,24 +332,20 @@ class EventBus:
"""
Get recent event history.
Only events published while at least one subscriber was registered are
recorded. #2066 added a fast path that skips history (and the lock, and
Event construction) when ``has_subscribers`` is False, because memory
and sub-agent lifecycle publishes were paying uuid4 + lock + append on
every call with nothing listening.
The consequence is worth stating plainly, because it is surprising in
the case you would most want history -- debugging after the fact, with
nothing subscribed: publishing three events to a bus with no
subscribers and then calling this returns an empty list, not three
events. Subscribe (even a no-op) before publishing if you need a record.
Every event passed to ``publish``/``publish_event``/``publish_async`` is
recorded here (capped at ``_max_history``), regardless of whether any
subscriber was registered at publish time. History is a debugging record
of what the system did; it does not silently depend on who was watching.
Both the sync and async publish paths append before any no-subscriber
fast path runs, so publishing to a bus with nothing subscribed still
shows up here.
Args:
event_type: Optional filter by event type
limit: Maximum number of events to return
Returns:
List of recent events published while subscribers existed
List of recent events published (subscribed or not)
"""
with self._lock:
events = self._event_history.copy()
@@ -83,7 +83,8 @@ class CheckpointService:
auto_checkpoint: bool = True,
max_checkpoints: int = 100,
user_name: str = "PraisonAI Checkpoints",
user_email: str = "[email protected]"
user_email: str = "[email protected]",
max_file_size: int = 2 * 1024 * 1024,
):
"""
Initialize the checkpoint service.
@@ -96,6 +97,8 @@ class CheckpointService:
max_checkpoints: Maximum checkpoints to keep
user_name: Git user.name for commits (default: "PraisonAI Checkpoints")
user_email: Git user.email for commits (default: "[email protected]")
max_file_size: Skip staging files larger than this many bytes
(default 2 MB); 0 disables the cap.
"""
self.config = CheckpointConfig(
workspace_dir=workspace_dir,
@@ -104,10 +107,14 @@ class CheckpointService:
auto_checkpoint=auto_checkpoint,
max_checkpoints=max_checkpoints,
user_name=user_name,
user_email=user_email
user_email=user_email,
max_file_size=max_file_size,
)
self._initialized = False
# Save counter used to run `git gc --auto` on a bounded cadence so the
# shadow object store is periodically pruned during long sessions.
self._saves_since_gc = 0
self._checkpoints: List[Checkpoint] = []
self._event_handlers: Dict[CheckpointEvent, List[Callable]] = {
event: [] for event in CheckpointEvent
@@ -184,6 +191,78 @@ class CheckpointService:
return True
async def _get_project_objects_dir(self) -> Optional[str]:
"""
Return the object database directory of the project's real git repo if
the workspace is inside one, else None.
Uses ``--git-common-dir`` so worktrees resolve to the shared object
store. Best-effort: any failure (no git, not a repo) returns None so the
shadow repo falls back to the standalone path unchanged.
"""
try:
env = self._get_sanitized_env()
for flag in ("--git-common-dir", "--git-dir"):
process = await asyncio.create_subprocess_exec(
"git", "rev-parse", flag,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
cwd=self.config.workspace_dir,
)
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=30)
if process.returncode != 0:
continue
git_dir = stdout.decode().strip()
if not git_dir:
continue
if not os.path.isabs(git_dir):
git_dir = os.path.join(self.config.workspace_dir, git_dir)
objects_dir = os.path.join(git_dir, "objects")
if os.path.isdir(objects_dir):
return os.path.realpath(objects_dir)
except Exception as e:
logger.debug(f"Could not resolve project git objects dir: {e}")
return None
async def _apply_large_repo_config(self) -> None:
"""
Apply large-repo git settings so index writes and untracked scans stay
fast. Each setting is best-effort: older git versions may not support
every key, and a failure must never break checkpointing.
"""
settings = [
("feature.manyFiles", "true"),
("index.version", "4"),
("core.untrackedCache", "true"),
("core.fsmonitor", "true"),
]
for key, value in settings:
try:
await self._run_git("config", key, value)
except Exception as e:
logger.debug(f"Skipping large-repo config {key}={value}: {e}")
async def _seed_from_project_objects(self, objects_dir: str) -> bool:
"""
Point the shadow object store at the project's objects via a file-based
``objects/info/alternates`` so new blobs resolve against existing
objects instead of being re-hashed and re-stored.
A file is used (not GIT_ALTERNATE_OBJECT_DIRECTORIES) because
``_get_sanitized_env`` strips that env var. Returns True on success.
"""
try:
info_dir = os.path.join(self.git_dir, "objects", "info")
os.makedirs(info_dir, exist_ok=True)
with open(os.path.join(info_dir, "alternates"), "w") as f:
f.write(f"{objects_dir}\n")
logger.debug(f"Seeded shadow git objects from {objects_dir}")
return True
except Exception as e:
logger.debug(f"Could not seed shadow git from project objects: {e}")
return False
async def _init_shadow_git(self) -> bool:
"""Initialize the shadow git repository."""
try:
@@ -196,7 +275,17 @@ class CheckpointService:
# Set worktree to workspace
await self._run_git("config", "core.worktree", self.config.workspace_dir)
# Seed objects from the project's real repo (if any) so the first
# checkpoint only stores changed files, and tune for large repos.
# Both are best-effort: no real repo → unchanged standalone path.
objects_dir = await self._get_project_objects_dir()
if objects_dir and objects_dir != os.path.realpath(
os.path.join(self.git_dir, "objects")
):
await self._seed_from_project_objects(objects_dir)
await self._apply_large_repo_config()
# Create exclude file
exclude_dir = os.path.join(self.git_dir, "info")
os.makedirs(exclude_dir, exist_ok=True)
@@ -259,6 +348,64 @@ class CheckpointService:
env.pop(var, None)
return env
async def _unstage_oversized_files(self) -> None:
"""
Remove files larger than ``config.max_file_size`` from the index so they
are not committed into the shadow store. Best-effort: any failure leaves
staging exactly as ``git add -A`` produced it and never breaks the turn.
"""
max_size = self.config.max_file_size
if not max_size or max_size <= 0:
return
try:
# NUL-delimited so paths with spaces/newlines are handled safely.
staged = await self._run_git("diff", "--cached", "--name-only", "-z")
for path in staged.split("\0"):
if not path:
continue
abs_path = os.path.join(self.config.workspace_dir, path)
try:
# Use lstat so symlinks are measured by the link itself, not
# their target: a small link to a large file must stay in the
# checkpoint. Skip anything that isn't a regular file.
if os.path.islink(abs_path) or not os.path.isfile(abs_path):
continue
if os.lstat(abs_path).st_size > max_size:
# Keep the file on disk; only drop it from the index.
await self._run_git("rm", "--cached", "--quiet", "--", path)
logger.debug(f"Skipped oversized file from checkpoint: {path}")
except Exception as e:
logger.debug(f"Could not evaluate size of {path}: {e}")
except Exception as e:
logger.debug(f"Oversized-file skip pass failed (continuing): {e}")
async def _maybe_gc(self, cadence: int = 20) -> None:
"""
Every ``cadence`` saves, pack the shadow repo and make it self-contained.
We seed the shadow store from the project's objects via ``alternates``
for a fast first checkpoint, but a borrowed object can disappear if the
project repo later prunes or repacks it — which would make checkpoint
history unreadable. To keep ``/undo``/``/revert`` durable we periodically
run ``git repack -a -d`` (without ``--local``) so every reachable object,
including borrowed ones, is copied into a local pack the shadow store
owns. ``git gc --auto`` still handles pruning/packing of loose objects.
Best-effort: failures are swallowed so a gc issue never breaks a turn.
"""
self._saves_since_gc += 1
if self._saves_since_gc < cadence:
return
self._saves_since_gc = 0
try:
# -a: pack all reachable objects; -d: drop redundant packs. Omitting
# --local pulls borrowed (alternate) objects into the local pack so
# the shadow store no longer depends on the project object database.
await self._run_git("repack", "-a", "-d", "--quiet")
await self._run_git("gc", "--auto", "--quiet")
except Exception as e:
logger.debug(f"Shadow-store gc skipped: {e}")
async def save(
self,
@@ -295,7 +442,13 @@ class CheckpointService:
try:
# Stage all changes
await self._run_git("add", "-A")
# Drop oversized files from the index so the shadow store never
# copies giant blobs (build outputs, model weights, media). This
# mirrors the existing "never fail the turn" contract: any error
# here is swallowed and staging proceeds as before.
await self._unstage_oversized_files()
# Check if there are changes
try:
await self._run_git("diff", "--cached", "--quiet")
@@ -329,7 +482,12 @@ class CheckpointService:
# Prune old checkpoints if needed
await self._prune_checkpoints()
# Periodically let git pack loose objects and drop unreachable ones
# so the shadow store doesn't leak disk over a long session. Bounded
# cadence keeps the per-turn cost near zero; best-effort only.
await self._maybe_gc()
self._emit(CheckpointEvent.CHECKPOINT_CREATED, checkpoint)
logger.info(f"Created checkpoint: {checkpoint.short_id} - {message}")
@@ -40,6 +40,11 @@ class CheckpointConfig:
max_checkpoints: int = 100 # Maximum checkpoints to keep
user_name: str = field(default_factory=lambda: os.getenv("PRAISONAI_GIT_USER_NAME", "PraisonAI Checkpoints"))
user_email: str = field(default_factory=lambda: os.getenv("PRAISONAI_GIT_USER_EMAIL", "[email protected]"))
# Skip staging files larger than this (bytes); 0 disables the cap. Kept
# after the identity fields so existing positional construction of
# CheckpointConfig(workspace, storage, enabled, auto, max_checkpoints,
# user_name, user_email) is unaffected.
max_file_size: int = 2 * 1024 * 1024
exclude_patterns: List[str] = field(default_factory=lambda: [
".git",
".praisonai",
@@ -14,3 +14,19 @@ class CompactionStrategy(str, Enum):
SMART = "smart"
LLM_SUMMARIZE = "llm_summarize" # Use LLM for summarization
PRUNE = "prune" # Remove old tool outputs
@classmethod
def from_policy_value(cls, value: str) -> "CompactionStrategy":
"""Map a context-policy strategy value to a compactor strategy.
The policy layer (``context.protocols.CompactionStrategy``) uses a
different member set than the compactor. This is the single source of
truth for translating between the two; keep it here rather than
duplicating the mapping at each call site.
"""
return {
"truncate": cls.TRUNCATE,
"summarise": cls.SUMMARIZE,
"drop_oldest_tools": cls.PRUNE,
"sliding_window": cls.SLIDING,
}.get(value, cls.PRUNE)
@@ -33,11 +33,13 @@ Usage:
from dataclasses import dataclass, field
import math
from typing import Dict, List, Any, Optional, Callable, Tuple, Union, FrozenSet
from typing import Dict, List, Any, Optional, Callable, Tuple, Union, FrozenSet, TYPE_CHECKING
from enum import Enum
# Import AutonomyConfig from canonical location (no circular dep)
from ..agent.autonomy import AutonomyConfig
if TYPE_CHECKING:
# Type-only: the eager import pulled agent.autonomy and escalation.types onto
# every `from praisonaiagents import Agent` path (see resolve_autonomy).
from ..agent.autonomy import AutonomyConfig
# Default tool output limit (16000 chars ≈ 4000 tokens)
# Single source of truth shared by OutputConfig.tool_output_limit and
@@ -898,7 +900,7 @@ class ExecutionConfig:
# Code execution (consolidated from allow_code_execution + code_execution_mode)
code_execution: bool = False
code_mode: str = "safe" # "safe" or "unsafe"
code_mode: str = "safe" # "safe", "unsafe" or "isolated"
# Code-execution-with-tools (code mode): when True, model-generated code may
# call the agent's registered tools directly via injected proxies, enabling
@@ -963,6 +965,15 @@ class ExecutionConfig:
# Validate the unified step budget early (before any early returns below).
if self.max_steps is not None and self.max_steps < 1:
raise ValueError("ExecutionConfig.max_steps must be >= 1 when set.")
# code_mode is a security-shaped switch: "safe" (subprocess, no tools),
# "unsafe" (same-process + tools, timeout not enforced), or "isolated"
# (subprocess + tools bridged back to the parent under the approval
# gate). Reject typos loudly rather than silently degrading isolation.
if self.code_mode not in ("safe", "unsafe", "isolated"):
raise ValueError(
"ExecutionConfig.code_mode must be 'safe', 'unsafe' or "
f"'isolated'; got {self.code_mode!r}."
)
# Handle context_compaction serialization round-trip
if isinstance(self.context_compaction, dict):
from ..context.policy import ContextCompactionPolicy
@@ -1384,13 +1395,21 @@ class HooksConfig:
# Middleware list
middleware: List[Any] = field(default_factory=list)
# Optional per-agent HookRegistry for event-based hooks (BEFORE_TOOL,
# AFTER_AGENT, ...). When None the agent uses the process-wide default
# registry (so plugins bridged into it still fire). Set this to a private
# ``HookRegistry()`` to keep event hooks registered for THIS agent from
# firing on unrelated agents that share the default registry.
registry: Optional[Any] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"on_step": str(self.on_step) if self.on_step else None,
"on_tool_call": str(self.on_tool_call) if self.on_tool_call else None,
"middleware": [str(m) for m in self.middleware],
"registry": type(self.registry).__name__ if self.registry is not None else None,
}
@@ -1785,13 +1804,16 @@ def resolve_caching(value: CachingParam) -> Optional[CachingConfig]:
return _resolve(value, CachingConfig)
def resolve_autonomy(value: AutonomyParam) -> Optional[AutonomyConfig]:
def resolve_autonomy(value: AutonomyParam) -> Optional["AutonomyConfig"]:
"""
Resolve autonomy= parameter following precedence ladder.
Delegates to the canonical resolver in param_resolver.py.
Kept for backward compatibility with tests.
"""
# Lazy: agent.autonomy (and, through it, escalation.types) is only needed
# when a caller actually resolves an autonomy= value.
from ..agent.autonomy import AutonomyConfig
from .param_resolver import resolve_autonomy as _resolve
return _resolve(value, AutonomyConfig)
@@ -2052,10 +2074,18 @@ def __getattr__(name):
``ToolSearchConfig`` is resolved (and the tools subsystem imported) only on
first access, so ``from praisonaiagents import Agent`` does not eagerly load
the entire tools package (issue #3191).
the entire tools package (issue #3191). ``AutonomyConfig`` follows the same
pattern (issue #5056).
"""
if name == "ToolSearchConfig":
cfg = _resolve_tool_search_config()
globals()["ToolSearchConfig"] = cfg # cache for subsequent access
return cfg
if name == "AutonomyConfig":
# Same idea for AutonomyConfig: keep the historical import path
# (``from ...feature_configs import AutonomyConfig``) working without
# loading agent.autonomy / escalation.types at module import (#5056).
from ..agent.autonomy import AutonomyConfig
globals()["AutonomyConfig"] = AutonomyConfig
return AutonomyConfig
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -7,9 +7,16 @@ another tool's harness, and results can be diffed/aggregated across tools.
Mapping (1:1 with EvalPort's ``Suite`` / ``ResultSet`` concepts):
EvalPackage <-> EvalPort Suite (name/description/version + cases + thresholds)
EvalCase <-> EvalPort test case (input / expected_output / grader refs)
EvalReport -> EvalPort ResultSet (aggregate + per-case pass/score/latency)
EvalPackage <-> EvalPort Suite (id/name/description + test_cases + graders)
EvalCase <-> EvalPort TestCase (id / input / expected_output / grader refs)
EvalReport -> EvalPort ResultSet (summary + per-case grader_results)
Per-criterion scores map to native EvalPort ``grader_results`` (one per criterion,
matching the criterion graders referenced by the Suite). PraisonAI-only fields
with no EvalPort slot (package version, thresholds, seed, trajectory records, a
nonpositive ``timeout_seconds`` that the spec's ``timeout_ms`` cannot represent,
...) are kept under the ``praisonai`` key of the relevant ``metadata`` object so
round trips stay lossless.
Design: this module is intentionally **dependency-free**. It emits and consumes
plain spec-shaped dicts (JSON), so the core SDK does not take on ``evalport-sdk``
@@ -27,53 +34,90 @@ Example:
>>> pkg2.name == pkg.name
True
"""
from typing import Any, Dict, List
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from .package import EvalCase, EvalPackage, EvalReport
EVALPORT_SPEC_VERSION = "1.0"
# EvalPort spec version the emitted documents conform to (semver, per spec).
EVALPORT_SPEC_VERSION = "1.0.0-rc.5"
# Grader that carries PraisonAI's own per-case verdict (EvalResult.passed/score).
# EvalPort requires every test case to reference at least one grader.
VERDICT_GRADER_ID = "praisonai_verdict"
_HANDLER = "praisonaiagents.eval"
_META_KEY = "praisonai"
def _grader_def(grader_id: str, description: str) -> Dict[str, Any]:
return {
"id": grader_id,
"type": "custom",
"params": {"handler": _HANDLER},
"description": description,
}
def _case_to_evalport(case: EvalCase) -> Dict[str, Any]:
"""Map an ``EvalCase`` to an EvalPort test-case dict.
"""Map an ``EvalCase`` to an EvalPort TestCase dict.
The native ``timeout_seconds`` is emitted as a dedicated top-level field so
it never collides with a user-supplied ``metadata["timeout_seconds"]`` key,
keeping round trips lossless for both.
``criteria`` become grader references (defined at suite level); a case
without criteria references the verdict grader. ``timeout_seconds`` maps to
the spec's ``timeout_ms``; user ``metadata`` is passed through untouched.
"""
# A case's own criteria are its graders; the verdict grader is always
# referenced too so PraisonAI's pass/score verdict has a home in the
# ResultSet (spec requires >= 1 grader per case).
graders = [VERDICT_GRADER_ID] + [c for c in case.criteria if c != VERDICT_GRADER_ID]
item: Dict[str, Any] = {
"id": case.name,
"input": case.input,
"graders": list(dict.fromkeys(graders)),
}
if case.expected is not None:
item["expected_output"] = case.expected
if case.criteria:
item["graders"] = list(case.criteria)
if case.timeout_seconds is not None:
item["timeout_seconds"] = case.timeout_seconds
metadata = dict(case.metadata or {})
if metadata:
item["metadata"] = metadata
if case.metadata:
item["metadata"] = dict(case.metadata)
if case.timeout_seconds is not None and case.timeout_seconds > 0:
item["timeout_ms"] = max(1, int(round(case.timeout_seconds * 1000)))
elif case.timeout_seconds is not None:
# Nonpositive timeout is not spec-representable (timeout_ms >= 1); keep
# the raw value under the reserved namespace so import restores it
# instead of silently applying the 30s default.
meta = item.setdefault("metadata", {})
native = dict(meta.get(_META_KEY) or {})
native["timeout_seconds"] = case.timeout_seconds
meta[_META_KEY] = native
return item
def _case_from_evalport(item: Dict[str, Any]) -> EvalCase:
"""Map an EvalPort test-case dict to an ``EvalCase``.
"""Map an EvalPort TestCase dict to an ``EvalCase``.
Prefers the dedicated top-level ``timeout_seconds`` field; falls back to a
``metadata["timeout_seconds"]`` value for suites emitted by other tools,
without mutating the caller's metadata.
Reads ``timeout_ms`` (spec); falls back to a legacy top-level or
``metadata["timeout_seconds"]`` value, without mutating caller metadata.
"""
metadata = dict(item.get("metadata") or {})
if "timeout_seconds" in item:
native = metadata.get(_META_KEY) or {}
if "timeout_ms" in item:
timeout = item["timeout_ms"] / 1000.0
elif "timeout_seconds" in item:
timeout = item["timeout_seconds"]
elif "timeout_seconds" in native:
timeout = native["timeout_seconds"] # nonpositive timeout preserved on export
else:
timeout = metadata.get("timeout_seconds", 30.0)
criteria = []
for g in item.get("graders") or item.get("criteria") or []:
gid = g.get("id") if isinstance(g, dict) else g
if gid and gid != VERDICT_GRADER_ID:
criteria.append(gid)
return EvalCase(
name=item.get("id") or item.get("name") or "case",
input=item.get("input", ""),
expected=item.get("expected_output", item.get("expected")),
criteria=list(item.get("graders") or item.get("criteria") or []),
criteria=criteria,
metadata=metadata,
timeout_seconds=timeout,
)
@@ -88,16 +132,28 @@ def to_evalport(package: EvalPackage) -> Dict[str, Any]:
Returns:
A spec-shaped dict suitable for ``openeval.validate.validate_suite()``.
"""
return {
"evalport_version": EVALPORT_SPEC_VERSION,
"kind": "suite",
criteria = dict.fromkeys(c for case in package.cases for c in case.criteria)
criteria.pop(VERDICT_GRADER_ID, None)
graders = [_grader_def(VERDICT_GRADER_ID, "PraisonAI per-case verdict (EvalResult.passed/score)")]
graders += [_grader_def(c, f"PraisonAI criterion: {c}") for c in criteria]
suite: Dict[str, Any] = {
"version": EVALPORT_SPEC_VERSION,
"id": package.name,
"name": package.name,
"description": package.description,
"version": package.version,
"cases": [_case_to_evalport(c) for c in package.cases],
"thresholds": dict(package.thresholds or {}),
"seed": package.seed,
"graders": graders,
"test_cases": [_case_to_evalport(c) for c in package.cases],
"metadata": {
_META_KEY: {
"version": package.version,
"thresholds": dict(package.thresholds or {}),
"seed": package.seed,
}
},
}
if package.description:
suite["description"] = package.description
return suite
def from_evalport(suite: Dict[str, Any]) -> EvalPackage:
@@ -110,22 +166,68 @@ def from_evalport(suite: Dict[str, Any]) -> EvalPackage:
An ``EvalPackage`` whose cases can be run via ``EvalSuite`` /
``HarnessEvaluator`` as native ``EvalCase`` objects.
"""
cases: List[EvalCase] = [_case_from_evalport(c) for c in suite.get("cases", [])]
items = suite.get("test_cases", suite.get("cases", []))
cases: List[EvalCase] = [_case_from_evalport(c) for c in items]
meta = (suite.get("metadata") or {}).get(_META_KEY) or {}
legacy = "evalport_version" in suite # documents emitted before spec alignment
return EvalPackage(
name=suite.get("name", "suite"),
name=suite.get("name") or suite.get("id") or "suite",
description=suite.get("description", ""),
version=suite.get("version", "1.0.0"),
version=meta.get("version") or (suite.get("version") if legacy else None) or "1.0.0",
cases=cases,
thresholds=dict(suite.get("thresholds") or {}),
seed=suite.get("seed"),
thresholds=dict(meta.get("thresholds") or suite.get("thresholds") or {}),
seed=meta.get("seed", suite.get("seed")),
)
def report_to_evalport(report: EvalReport) -> Dict[str, Any]:
def _unit_score(value: Any) -> Optional[float]:
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value:
return None
return min(1.0, max(0.0, float(value)))
def _grader_result(grader_id: str, raw_score: Any, passed: bool) -> Dict[str, Any]:
"""Build one spec-shaped ``grader_result`` entry.
Scores are clamped to the spec's [0, 1] range; the raw value is preserved
under ``metadata`` when clamping changes it. A null score means "not
verified", so ``passed`` is forced ``False`` in that case.
"""
score = _unit_score(raw_score)
grader: Dict[str, Any] = {
"grader_id": grader_id,
"type": "custom",
"score": score,
"passed": bool(passed) if score is not None else False,
}
if score is not None and score != raw_score:
grader["metadata"] = {"openeval.raw_score": raw_score}
return grader
def _runner_info() -> Dict[str, str]:
runner = {"name": "praisonaiagents"}
try:
from importlib.metadata import version
runner["version"] = version("praisonaiagents")
except Exception:
pass
return runner
def report_to_evalport(
report: EvalReport,
*,
run_id: Optional[str] = None,
started_at: Optional[str] = None,
) -> Dict[str, Any]:
"""Convert an ``EvalReport`` to an EvalPort ``ResultSet`` dict.
Args:
report: The aggregated report from running an eval package.
run_id: Unique run identifier (defaults to a random UUID).
started_at: RFC 3339 run start time (defaults to now, UTC).
Returns:
A spec-shaped dict suitable for
@@ -133,33 +235,52 @@ def report_to_evalport(report: EvalReport) -> Dict[str, Any]:
"""
results: List[Dict[str, Any]] = []
for r in report.results:
verdict = _grader_result(VERDICT_GRADER_ID, r.score, r.passed)
grader_results = [verdict]
# Surface each per-criterion score as its own grader_result so EvalPort
# consumers can diff criteria across tools (they match the criterion
# grader ids referenced by the exported Suite). ``passed`` is unknown at
# the criterion level, so mirror the case verdict.
for name, score in (r.criteria_scores or {}).items():
if name == VERDICT_GRADER_ID:
continue
grader_results.append(_grader_result(name, score, r.passed))
item: Dict[str, Any] = {
"case_id": r.case_name,
"passed": r.passed,
"score": r.score,
"latency_ms": r.latency_ms,
"test_case_id": r.case_name,
"passed": verdict["passed"],
"grader_results": grader_results,
}
if r.actual_output is not None:
item["actual_output"] = r.actual_output
item["actual_output"] = str(r.actual_output)
if r.latency_ms is not None and r.latency_ms >= 0:
item["duration_ms"] = int(round(r.latency_ms))
if r.error is not None:
item["error"] = r.error
if r.criteria_scores:
item["grader_scores"] = dict(r.criteria_scores)
item["error"] = {"type": "runner_error", "message": str(r.error)}
if r.record is not None:
item["record"] = r.record
item["metadata"] = {_META_KEY: {"record": r.record}}
results.append(item)
summary: Dict[str, Any] = {
"total": report.total_cases,
"passed": report.passed_cases,
"failed": report.failed_cases,
"pass_rate": report.pass_rate,
}
meta: Dict[str, Any] = {"thresholds_met": dict(report.thresholds_met or {})}
avg = _unit_score(report.average_score)
if avg is not None:
summary["avg_score"] = avg
if avg != report.average_score:
meta["average_score"] = report.average_score # clamped
return {
"evalport_version": EVALPORT_SPEC_VERSION,
"kind": "result_set",
"suite_name": report.package_name,
"summary": {
"total": report.total_cases,
"passed": report.passed_cases,
"failed": report.failed_cases,
"pass_rate": report.pass_rate,
"average_score": report.average_score,
"thresholds_met": dict(report.thresholds_met or {}),
},
"version": EVALPORT_SPEC_VERSION,
"suite_id": report.package_name,
"run_id": run_id or uuid.uuid4().hex,
"started_at": started_at or datetime.now(timezone.utc).isoformat(),
"runner": _runner_info(),
"results": results,
"summary": summary,
"metadata": {_META_KEY: meta},
}
@@ -12,265 +12,318 @@ Gap S2: WebSocketGateway is re-exported here for convenience but requires
``praisonai-bot`` (or ``pip install praisonai``) to be installed.
"""
from .protocols import (
GatewayProtocol,
GatewaySessionProtocol,
GatewayClientProtocol,
GatewayEvent,
GatewayMessage,
EventType,
OperatorScope,
GatewayCloseCode,
# Declarative method -> required-scope registry (Issue #3206)
GatewayMethodDescriptor,
GATEWAY_METHODS,
register_gateway_method,
resolve_required_scope,
# Config hot-reload observability (Issue #3049)
ReloadStatus,
compute_config_revision,
# Saturation / back-pressure observability (Issue #4265)
HealthPressure,
evaluate_pressure,
# Push protocols and dataclasses
PushChannelProtocol,
PresenceProtocol,
DeliveryGuaranteeProtocol,
OutboundDeliveryProtocol,
IdempotencyStoreProtocol,
InMemoryIdempotencyStore,
ChannelInfo,
PresenceInfo,
# Home channel and delivery protocols
HomeChannelRegistryProtocol,
DeliveryResolverProtocol,
# Creation-time delivery-target pre-flight (Issue #3800)
DeliveryPreflightProtocol,
DeliveryValidation,
ScheduleTargetError,
# Remote-sandbox outbound media bridge (Issue #4951)
RemoteMediaResolver,
# Agent-facing outbound messaging
OutboundMessengerProtocol,
DeliveryResult,
TargetInfo,
# Agent-callable message reactions (Issue #3917)
ReactionResult,
ReactionStatus,
# Agent/gateway-callable thread creation (Issue #3987)
ThreadResult,
ThreadStatus,
# Agent-callable cross-conversation request/reply (Issue #3689)
ConversationReply,
ConversationReplyStatus,
ConversationRequestProtocol,
# Agent-facing live status/health (Issue #3688)
GatewayStatusProtocol,
GatewayStatus,
# Inbound route binding (Issue #2225)
RouteBinding,
RouteFacts,
RouteMatch,
resolve_route,
# Per-route, trust-tiered toolset scoping (Issue #2298)
ToolPolicy,
UNTRUSTED_DENY_SUBSTRINGS,
TRUST_TIERS,
# Outbound send-policy guard (Issue #2226)
SendDecision,
SendPolicyProtocol,
SendPolicy,
# Gateway idle-dormancy / scale-to-zero (Issue #2332)
IdleDecision,
GatewayIdlePolicyProtocol,
GatewayIdlePolicy, # backward-compat alias
ScaleToZeroPolicy,
# Gateway freeze-thaw (involuntary host-suspend gap) recovery (Issue #4767)
ThawDecision,
ThawPolicyProtocol,
WallClockGapThawPolicy,
# Gateway graceful-drain on shutdown (Issue #2375)
DrainDecision,
GatewayDrainPolicyProtocol,
DrainTimeoutPolicy,
# Gateway inbound admission control (Issue #2454)
AdmissionDecision,
GatewayConcurrencyPolicyProtocol,
GatewayConcurrencyPolicy, # backward-compat alias
ConcurrencyLimitPolicy,
# Gateway resource-pressure admission (Issue #3445)
ResourceSample,
ResourcePressurePolicyProtocol,
MemoryPressurePolicy,
# Gateway memory-pressure cache eviction (Issue #3804)
WarmSession,
MemoryPressureProtocol,
plan_pressure_evictions,
# Gateway rate-limit admission (Issue #2532)
RateLimitDecision,
RateLimitPolicyProtocol,
RateLimitPolicy, # backward-compat alias
SlidingWindowRateLimitPolicy,
# Gateway cost-aware spend-budget admission (Issue #4894)
SpendBudgetPolicyProtocol,
SpendBudgetPolicy, # backward-compat alias
WindowedSpendBudgetPolicy,
# Durable-queue dead-letter decision (Issue #3519)
PERMANENT_ERROR_CLASSES,
DeadLetterDecision,
DeadLetterPolicyProtocol,
AttemptAndAgeDeadLetterPolicy,
# Port-less, restart-safe external drain trigger (Issue #2390)
current_epoch,
DrainMarkerPolicy,
# Crash / shutdown forensics (Issue #2436)
ShutdownForensicsProtocol,
format_forensics_for_log,
is_supervised,
drain_timeout_has_headroom,
# Code-skew guard for hot operations (Issue #2460)
detect_code_skew,
# Restart-intent exit-code protocol (Issue #2437)
GATEWAY_OK_EXIT_CODE,
GATEWAY_RESTART_EXIT_CODE,
GATEWAY_FATAL_CONFIG_EXIT_CODE,
FatalConfigError,
classify_exit_reason,
RestartLoopGuard,
# Durable process-lifecycle record + unclean/OOM classification (Issue #4603)
LifecycleRecord,
classify_unclean_exit,
RestartStoreProtocol,
PersistentRestartLoopGuard,
classify_resource_pressure,
# Fleet-level crash-loop breaker for channel supervision (Issue #3840)
FleetSupervisionPolicy,
# Protocol version negotiation
PROTOCOL_VERSION,
MIN_PROTOCOL_VERSION,
MAX_PROTOCOL_VERSION,
ProtocolHello,
ProtocolHelloOk,
GapInfo,
ResumeSnapshot,
# Out-of-process platform-connector relay (Issue #2485)
CapabilityDescriptor,
RelayTransport,
# Gateway pipeline span-tracing seam (Issue #2716)
GATEWAY_TRACE_STAGES,
GatewayTraceHook,
NullGatewayTraceHook,
NULL_GATEWAY_TRACE_HOOK,
resolve_trace_hook,
# Gateway self-lifecycle command guardrail (Issue #2753)
LifecycleCommandDecision,
LifecycleCommandPolicyProtocol,
LifecycleCommandPolicy, # backward-compat alias
LifecycleCommandGuardPolicy,
# Application-level connection liveness (Issue #2798)
LivenessDecision,
LivenessPolicyProtocol,
LivenessPolicy,
# Cluster-wide per-turn serialisation (Issue #3643)
TurnLeaseToken,
TurnLockProtocol,
LocalTurnLock,
# Per-session turn-execution isolation (Issue #4011)
WorkerWedgedError,
TurnPlacement,
TurnExecutorProtocol,
InProcessTurnExecutor,
# Global operator emergency-stop / pause brake (Issue #4220)
EmergencyStopState,
EmergencyStopProtocol,
NullEmergencyStop,
FileEmergencyStop,
# Schema-validated inbound frame codec (Issue #2831)
HelloParams,
HelloResult,
HelloError,
ConnectErrorCode,
ConnectRecoveryStep,
is_recoverable,
MessageParams,
LeaveParams,
JoinParams,
FrameDecodeError,
ClientFrame,
decode_client_frame,
# Weak / placeholder secret guard (Issue #3259)
KNOWN_WEAK_SECRETS,
WeakGatewaySecretError,
is_weak_secret,
assert_gateway_secret_strong,
# Per-platform identity canonicalization (Issue #3886)
IdentityCanonicalizerProtocol,
)
from .liveness import (
# Event-loop liveness watchdog (Issue #3385)
LoopWatchdogPolicy,
LoopWatchdog,
)
from .degraded_state import (
# Unified degraded-capability registry (Issue #3518)
DegradedOwner,
DegradedCapabilityProtocol,
DegradedCapabilityRegistry,
OWNER_KINDS,
DEGRADED_STATES,
# Fail-closed read of the degraded-owner contract (Issue #3640)
DegradedCapabilityLookupProtocol,
OwnerUnavailable,
assert_owner_available,
)
from .hooks import (
HookAction,
HookConfig,
InboundTriggerProtocol,
render_template,
compute_idempotency_key,
verify_webhook_signature,
)
from .config import (
GatewayConfig,
SessionConfig,
ApiConfig,
EmergencyStopConfig,
ChannelRouteConfig,
MultiChannelGatewayConfig,
# Config version stamp + doctor-driven migration (Issue #3841)
GATEWAY_CONFIG_VERSION,
ConfigVersionError,
LegacyConfigRule,
GATEWAY_CONFIG_RULES,
is_config_current,
migrate_config_with_doctor,
# Push config
PushConfig,
RedisConfig,
PresenceConfig,
DeliveryConfig,
PollingConfig,
LivenessConfig,
TurnLockConfig,
# Hot-reload registry (Issue #3378)
HOT_APPLIABLE_KEYS,
SupportsHotReload,
is_hot_appliable,
# Reload scope classification (Issue #3440)
ReloadScope,
classify_reload,
)
from .._lazy import create_lazy_getattr
# Lazy loading cache
# Lazy import map: symbol -> (submodule, attr). Deferring these keeps the
# 283 KB / 7,201-line ``protocols.py`` (and siblings) out of package-import
# time; each submodule is parsed only when one of its symbols is first
# touched. Mirrors the ``mcp``/``skills`` packages (AGENTS.md §4.2).
_LAZY_IMPORTS = {
# protocols.py (parsed lazily, on first touch)
"GatewayProtocol": ("praisonaiagents.gateway.protocols", "GatewayProtocol"),
"GatewaySessionProtocol": ("praisonaiagents.gateway.protocols", "GatewaySessionProtocol"),
"GatewayClientProtocol": ("praisonaiagents.gateway.protocols", "GatewayClientProtocol"),
"GatewayEvent": ("praisonaiagents.gateway.protocols", "GatewayEvent"),
"GatewayMessage": ("praisonaiagents.gateway.protocols", "GatewayMessage"),
"EventType": ("praisonaiagents.gateway.protocols", "EventType"),
"OperatorScope": ("praisonaiagents.gateway.protocols", "OperatorScope"),
# Multi-observer session sharing (Issue #5192)
"SessionVisibility": ("praisonaiagents.gateway.protocols", "SessionVisibility"),
"SessionSharingRole": ("praisonaiagents.gateway.protocols", "SessionSharingRole"),
"SessionObserverProtocol": ("praisonaiagents.gateway.protocols", "SessionObserverProtocol"),
# Session-projection reducer (Issue #5324)
"SessionProjection": ("praisonaiagents.gateway.session_projection", "SessionProjection"),
"SessionProjectionState": ("praisonaiagents.gateway.session_projection", "SessionProjectionState"),
"RunView": ("praisonaiagents.gateway.session_projection", "RunView"),
# Canonical session-address grammar (Issue #5383)
"SessionRef": ("praisonaiagents.gateway.addressing", "SessionRef"),
"build_session_path": ("praisonaiagents.gateway.addressing", "build_session_path"),
"parse_session_path": ("praisonaiagents.gateway.addressing", "parse_session_path"),
"derive_short_id": ("praisonaiagents.gateway.addressing", "derive_short_id"),
"slugify": ("praisonaiagents.gateway.addressing", "slugify"),
"RESERVED_NAMES": ("praisonaiagents.gateway.addressing", "RESERVED_NAMES"),
"SHORT_ID_LEN": ("praisonaiagents.gateway.addressing", "SHORT_ID_LEN"),
"GatewayCloseCode": ("praisonaiagents.gateway.protocols", "GatewayCloseCode"),
# Declarative method -> required-scope registry (Issue #3206)
"GatewayMethodDescriptor": ("praisonaiagents.gateway.protocols", "GatewayMethodDescriptor"),
"GATEWAY_METHODS": ("praisonaiagents.gateway.protocols", "GATEWAY_METHODS"),
"register_gateway_method": ("praisonaiagents.gateway.protocols", "register_gateway_method"),
"resolve_required_scope": ("praisonaiagents.gateway.protocols", "resolve_required_scope"),
"authorize_method": ("praisonaiagents.gateway.protocols", "authorize_method"),
"GatewayUnauthorized": ("praisonaiagents.gateway.protocols", "GatewayUnauthorized"),
# Config hot-reload observability (Issue #3049)
"ReloadStatus": ("praisonaiagents.gateway.protocols", "ReloadStatus"),
"compute_config_revision": ("praisonaiagents.gateway.protocols", "compute_config_revision"),
# Saturation / back-pressure observability (Issue #4265)
"HealthPressure": ("praisonaiagents.gateway.protocols", "HealthPressure"),
"evaluate_pressure": ("praisonaiagents.gateway.protocols", "evaluate_pressure"),
# Trusted-proxy ingress attribution (Issue #5312)
"IngressTrust": ("praisonaiagents.gateway.protocols", "IngressTrust"),
"IngressAttribution": ("praisonaiagents.gateway.protocols", "IngressAttribution"),
"resolve_ingress_attribution": ("praisonaiagents.gateway.protocols", "resolve_ingress_attribution"),
"parse_forwarded_for": ("praisonaiagents.gateway.protocols", "parse_forwarded_for"),
# Push protocols and dataclasses
"PushChannelProtocol": ("praisonaiagents.gateway.protocols", "PushChannelProtocol"),
"PresenceProtocol": ("praisonaiagents.gateway.protocols", "PresenceProtocol"),
"DeliveryGuaranteeProtocol": ("praisonaiagents.gateway.protocols", "DeliveryGuaranteeProtocol"),
"OutboundDeliveryProtocol": ("praisonaiagents.gateway.protocols", "OutboundDeliveryProtocol"),
"IdempotencyStoreProtocol": ("praisonaiagents.gateway.protocols", "IdempotencyStoreProtocol"),
"InMemoryIdempotencyStore": ("praisonaiagents.gateway.protocols", "InMemoryIdempotencyStore"),
"ChannelInfo": ("praisonaiagents.gateway.protocols", "ChannelInfo"),
"PresenceInfo": ("praisonaiagents.gateway.protocols", "PresenceInfo"),
# Home channel and delivery protocols
"HomeChannelRegistryProtocol": ("praisonaiagents.gateway.protocols", "HomeChannelRegistryProtocol"),
"DeliveryResolverProtocol": ("praisonaiagents.gateway.protocols", "DeliveryResolverProtocol"),
# Creation-time delivery-target pre-flight (Issue #3800)
"DeliveryPreflightProtocol": ("praisonaiagents.gateway.protocols", "DeliveryPreflightProtocol"),
"DeliveryValidation": ("praisonaiagents.gateway.protocols", "DeliveryValidation"),
"ScheduleTargetError": ("praisonaiagents.gateway.protocols", "ScheduleTargetError"),
# Remote-sandbox outbound media bridge (Issue #4951)
"RemoteMediaResolver": ("praisonaiagents.gateway.protocols", "RemoteMediaResolver"),
# Agent-facing outbound messaging
"OutboundMessengerProtocol": ("praisonaiagents.gateway.protocols", "OutboundMessengerProtocol"),
"DeliveryResult": ("praisonaiagents.gateway.protocols", "DeliveryResult"),
"TargetInfo": ("praisonaiagents.gateway.protocols", "TargetInfo"),
# Agent-callable message reactions (Issue #3917)
"ReactionResult": ("praisonaiagents.gateway.protocols", "ReactionResult"),
"ReactionStatus": ("praisonaiagents.gateway.protocols", "ReactionStatus"),
# Agent/gateway-callable thread creation (Issue #3987)
"ThreadResult": ("praisonaiagents.gateway.protocols", "ThreadResult"),
"ThreadStatus": ("praisonaiagents.gateway.protocols", "ThreadStatus"),
# Agent-callable message mutation: edit/delete (Issue #5054)
"MessageActionResult": ("praisonaiagents.gateway.protocols", "MessageActionResult"),
"MessageActionStatus": ("praisonaiagents.gateway.protocols", "MessageActionStatus"),
# Agent-callable cross-conversation request/reply (Issue #3689)
"ConversationReply": ("praisonaiagents.gateway.protocols", "ConversationReply"),
"ConversationReplyStatus": ("praisonaiagents.gateway.protocols", "ConversationReplyStatus"),
"ConversationRequestProtocol": ("praisonaiagents.gateway.protocols", "ConversationRequestProtocol"),
# Server→client interactive request/reply over the transport (Issue #5351)
"GatewayRequestKind": ("praisonaiagents.gateway.protocols", "GatewayRequestKind"),
"GatewayServerRequest": ("praisonaiagents.gateway.protocols", "GatewayServerRequest"),
"GatewayServerReply": ("praisonaiagents.gateway.protocols", "GatewayServerReply"),
"GatewayRequestChannelProtocol": ("praisonaiagents.gateway.protocols", "GatewayRequestChannelProtocol"),
# Agent-facing live status/health (Issue #3688)
"GatewayStatusProtocol": ("praisonaiagents.gateway.protocols", "GatewayStatusProtocol"),
"GatewayStatus": ("praisonaiagents.gateway.protocols", "GatewayStatus"),
# Inbound route binding (Issue #2225)
"RouteBinding": ("praisonaiagents.gateway.protocols", "RouteBinding"),
"RouteFacts": ("praisonaiagents.gateway.protocols", "RouteFacts"),
"RouteMatch": ("praisonaiagents.gateway.protocols", "RouteMatch"),
"resolve_route": ("praisonaiagents.gateway.protocols", "resolve_route"),
# Per-route, trust-tiered toolset scoping (Issue #2298)
"ToolPolicy": ("praisonaiagents.gateway.protocols", "ToolPolicy"),
"UNTRUSTED_DENY_SUBSTRINGS": ("praisonaiagents.gateway.protocols", "UNTRUSTED_DENY_SUBSTRINGS"),
"TRUST_TIERS": ("praisonaiagents.gateway.protocols", "TRUST_TIERS"),
# Outbound send-policy guard (Issue #2226)
"SendDecision": ("praisonaiagents.gateway.protocols", "SendDecision"),
"SendPolicyProtocol": ("praisonaiagents.gateway.protocols", "SendPolicyProtocol"),
"SendPolicy": ("praisonaiagents.gateway.protocols", "SendPolicy"),
# Gateway idle-dormancy / scale-to-zero (Issue #2332)
"IdleDecision": ("praisonaiagents.gateway.protocols", "IdleDecision"),
"GatewayIdlePolicyProtocol": ("praisonaiagents.gateway.protocols", "GatewayIdlePolicyProtocol"),
"GatewayIdlePolicy": ("praisonaiagents.gateway.protocols", "GatewayIdlePolicy"),
"ScaleToZeroPolicy": ("praisonaiagents.gateway.protocols", "ScaleToZeroPolicy"),
# Gateway freeze-thaw (involuntary host-suspend gap) recovery (Issue #4767)
"ThawDecision": ("praisonaiagents.gateway.protocols", "ThawDecision"),
"ThawPolicyProtocol": ("praisonaiagents.gateway.protocols", "ThawPolicyProtocol"),
"WallClockGapThawPolicy": ("praisonaiagents.gateway.protocols", "WallClockGapThawPolicy"),
# Gateway graceful-drain on shutdown (Issue #2375)
"DrainDecision": ("praisonaiagents.gateway.protocols", "DrainDecision"),
"GatewayDrainPolicyProtocol": ("praisonaiagents.gateway.protocols", "GatewayDrainPolicyProtocol"),
"DrainTimeoutPolicy": ("praisonaiagents.gateway.protocols", "DrainTimeoutPolicy"),
# Gateway inbound admission control (Issue #2454)
"AdmissionDecision": ("praisonaiagents.gateway.protocols", "AdmissionDecision"),
"GatewayConcurrencyPolicyProtocol": ("praisonaiagents.gateway.protocols", "GatewayConcurrencyPolicyProtocol"),
"GatewayConcurrencyPolicy": ("praisonaiagents.gateway.protocols", "GatewayConcurrencyPolicy"),
"ConcurrencyLimitPolicy": ("praisonaiagents.gateway.protocols", "ConcurrencyLimitPolicy"),
# Gateway resource-pressure admission (Issue #3445)
"ResourceSample": ("praisonaiagents.gateway.protocols", "ResourceSample"),
"ResourcePressurePolicyProtocol": ("praisonaiagents.gateway.protocols", "ResourcePressurePolicyProtocol"),
"MemoryPressurePolicy": ("praisonaiagents.gateway.protocols", "MemoryPressurePolicy"),
# Gateway memory-pressure cache eviction (Issue #3804)
"WarmSession": ("praisonaiagents.gateway.protocols", "WarmSession"),
"MemoryPressureProtocol": ("praisonaiagents.gateway.protocols", "MemoryPressureProtocol"),
"plan_pressure_evictions": ("praisonaiagents.gateway.protocols", "plan_pressure_evictions"),
# Gateway rate-limit admission (Issue #2532)
"RateLimitDecision": ("praisonaiagents.gateway.protocols", "RateLimitDecision"),
"RateLimitPolicyProtocol": ("praisonaiagents.gateway.protocols", "RateLimitPolicyProtocol"),
"RateLimitPolicy": ("praisonaiagents.gateway.protocols", "RateLimitPolicy"),
"SlidingWindowRateLimitPolicy": ("praisonaiagents.gateway.protocols", "SlidingWindowRateLimitPolicy"),
# Gateway cost-aware spend-budget admission (Issue #4894)
"SpendBudgetPolicyProtocol": ("praisonaiagents.gateway.protocols", "SpendBudgetPolicyProtocol"),
"SpendBudgetPolicy": ("praisonaiagents.gateway.protocols", "SpendBudgetPolicy"),
"WindowedSpendBudgetPolicy": ("praisonaiagents.gateway.protocols", "WindowedSpendBudgetPolicy"),
# Durable-queue dead-letter decision (Issue #3519)
"PERMANENT_ERROR_CLASSES": ("praisonaiagents.gateway.protocols", "PERMANENT_ERROR_CLASSES"),
"DeadLetterDecision": ("praisonaiagents.gateway.protocols", "DeadLetterDecision"),
"DeadLetterPolicyProtocol": ("praisonaiagents.gateway.protocols", "DeadLetterPolicyProtocol"),
"AttemptAndAgeDeadLetterPolicy": ("praisonaiagents.gateway.protocols", "AttemptAndAgeDeadLetterPolicy"),
# Port-less, restart-safe external drain trigger (Issue #2390)
"current_epoch": ("praisonaiagents.gateway.protocols", "current_epoch"),
"DrainMarkerPolicy": ("praisonaiagents.gateway.protocols", "DrainMarkerPolicy"),
# Crash / shutdown forensics (Issue #2436)
"ShutdownForensicsProtocol": ("praisonaiagents.gateway.protocols", "ShutdownForensicsProtocol"),
"format_forensics_for_log": ("praisonaiagents.gateway.protocols", "format_forensics_for_log"),
"is_supervised": ("praisonaiagents.gateway.protocols", "is_supervised"),
"drain_timeout_has_headroom": ("praisonaiagents.gateway.protocols", "drain_timeout_has_headroom"),
# Code-skew guard for hot operations (Issue #2460)
"detect_code_skew": ("praisonaiagents.gateway.protocols", "detect_code_skew"),
# Restart-intent exit-code protocol (Issue #2437)
"GATEWAY_OK_EXIT_CODE": ("praisonaiagents.gateway.protocols", "GATEWAY_OK_EXIT_CODE"),
"GATEWAY_RESTART_EXIT_CODE": ("praisonaiagents.gateway.protocols", "GATEWAY_RESTART_EXIT_CODE"),
"GATEWAY_FATAL_CONFIG_EXIT_CODE": ("praisonaiagents.gateway.protocols", "GATEWAY_FATAL_CONFIG_EXIT_CODE"),
"FatalConfigError": ("praisonaiagents.gateway.protocols", "FatalConfigError"),
"classify_exit_reason": ("praisonaiagents.gateway.protocols", "classify_exit_reason"),
"RestartLoopGuard": ("praisonaiagents.gateway.protocols", "RestartLoopGuard"),
# Durable process-lifecycle record + unclean/OOM classification (Issue #4603)
"LifecycleRecord": ("praisonaiagents.gateway.protocols", "LifecycleRecord"),
"classify_unclean_exit": ("praisonaiagents.gateway.protocols", "classify_unclean_exit"),
"RestartStoreProtocol": ("praisonaiagents.gateway.protocols", "RestartStoreProtocol"),
"PersistentRestartLoopGuard": ("praisonaiagents.gateway.protocols", "PersistentRestartLoopGuard"),
"classify_resource_pressure": ("praisonaiagents.gateway.protocols", "classify_resource_pressure"),
# Fleet-level crash-loop breaker for channel supervision (Issue #3840)
"FleetSupervisionPolicy": ("praisonaiagents.gateway.protocols", "FleetSupervisionPolicy"),
# Protocol version negotiation
"PROTOCOL_VERSION": ("praisonaiagents.gateway.protocols", "PROTOCOL_VERSION"),
"MIN_PROTOCOL_VERSION": ("praisonaiagents.gateway.protocols", "MIN_PROTOCOL_VERSION"),
"MAX_PROTOCOL_VERSION": ("praisonaiagents.gateway.protocols", "MAX_PROTOCOL_VERSION"),
"ProtocolHello": ("praisonaiagents.gateway.protocols", "ProtocolHello"),
"ProtocolHelloOk": ("praisonaiagents.gateway.protocols", "ProtocolHelloOk"),
"GapInfo": ("praisonaiagents.gateway.protocols", "GapInfo"),
"ResumeSnapshot": ("praisonaiagents.gateway.protocols", "ResumeSnapshot"),
# Out-of-process platform-connector relay (Issue #2485)
"CapabilityDescriptor": ("praisonaiagents.gateway.protocols", "CapabilityDescriptor"),
"RelayTransport": ("praisonaiagents.gateway.protocols", "RelayTransport"),
# Gateway pipeline span-tracing seam (Issue #2716)
"GATEWAY_TRACE_STAGES": ("praisonaiagents.gateway.protocols", "GATEWAY_TRACE_STAGES"),
"GatewayTraceHook": ("praisonaiagents.gateway.protocols", "GatewayTraceHook"),
"NullGatewayTraceHook": ("praisonaiagents.gateway.protocols", "NullGatewayTraceHook"),
"NULL_GATEWAY_TRACE_HOOK": ("praisonaiagents.gateway.protocols", "NULL_GATEWAY_TRACE_HOOK"),
"resolve_trace_hook": ("praisonaiagents.gateway.protocols", "resolve_trace_hook"),
# Gateway self-lifecycle command guardrail (Issue #2753)
"LifecycleCommandDecision": ("praisonaiagents.gateway.protocols", "LifecycleCommandDecision"),
"LifecycleCommandPolicyProtocol": ("praisonaiagents.gateway.protocols", "LifecycleCommandPolicyProtocol"),
"LifecycleCommandPolicy": ("praisonaiagents.gateway.protocols", "LifecycleCommandPolicy"),
"LifecycleCommandGuardPolicy": ("praisonaiagents.gateway.protocols", "LifecycleCommandGuardPolicy"),
# Application-level connection liveness (Issue #2798)
"LivenessDecision": ("praisonaiagents.gateway.protocols", "LivenessDecision"),
"LivenessPolicyProtocol": ("praisonaiagents.gateway.protocols", "LivenessPolicyProtocol"),
"LivenessPolicy": ("praisonaiagents.gateway.protocols", "LivenessPolicy"),
# Cluster-wide per-turn serialisation (Issue #3643)
"TurnLeaseToken": ("praisonaiagents.gateway.protocols", "TurnLeaseToken"),
"TurnLockProtocol": ("praisonaiagents.gateway.protocols", "TurnLockProtocol"),
"LocalTurnLock": ("praisonaiagents.gateway.protocols", "LocalTurnLock"),
# Per-session turn-execution isolation (Issue #4011)
"WorkerWedgedError": ("praisonaiagents.gateway.protocols", "WorkerWedgedError"),
"TurnPlacement": ("praisonaiagents.gateway.protocols", "TurnPlacement"),
"TurnExecutorProtocol": ("praisonaiagents.gateway.protocols", "TurnExecutorProtocol"),
"InProcessTurnExecutor": ("praisonaiagents.gateway.protocols", "InProcessTurnExecutor"),
# Global operator emergency-stop / pause brake (Issue #4220)
"EmergencyStopState": ("praisonaiagents.gateway.protocols", "EmergencyStopState"),
"EmergencyStopProtocol": ("praisonaiagents.gateway.protocols", "EmergencyStopProtocol"),
"NullEmergencyStop": ("praisonaiagents.gateway.protocols", "NullEmergencyStop"),
"FileEmergencyStop": ("praisonaiagents.gateway.protocols", "FileEmergencyStop"),
# Per-session stop scope (Issue #5129)
"StopScope": ("praisonaiagents.gateway.protocols", "StopScope"),
"StopResult": ("praisonaiagents.gateway.protocols", "StopResult"),
# Schema-validated inbound frame codec (Issue #2831)
"HelloParams": ("praisonaiagents.gateway.protocols", "HelloParams"),
"HelloResult": ("praisonaiagents.gateway.protocols", "HelloResult"),
"HelloError": ("praisonaiagents.gateway.protocols", "HelloError"),
"ConnectErrorCode": ("praisonaiagents.gateway.protocols", "ConnectErrorCode"),
"ConnectRecoveryStep": ("praisonaiagents.gateway.protocols", "ConnectRecoveryStep"),
"is_recoverable": ("praisonaiagents.gateway.protocols", "is_recoverable"),
"MessageParams": ("praisonaiagents.gateway.protocols", "MessageParams"),
# First-class attachment contract (Issue #5207)
"AttachmentRef": ("praisonaiagents.gateway.protocols", "AttachmentRef"),
"AttachmentStoreProtocol": ("praisonaiagents.gateway.protocols", "AttachmentStoreProtocol"),
"LeaveParams": ("praisonaiagents.gateway.protocols", "LeaveParams"),
"JoinParams": ("praisonaiagents.gateway.protocols", "JoinParams"),
"FrameDecodeError": ("praisonaiagents.gateway.protocols", "FrameDecodeError"),
"ClientFrame": ("praisonaiagents.gateway.protocols", "ClientFrame"),
"decode_client_frame": ("praisonaiagents.gateway.protocols", "decode_client_frame"),
# Weak / placeholder secret guard (Issue #3259)
"KNOWN_WEAK_SECRETS": ("praisonaiagents.gateway.protocols", "KNOWN_WEAK_SECRETS"),
"WeakGatewaySecretError": ("praisonaiagents.gateway.protocols", "WeakGatewaySecretError"),
"is_weak_secret": ("praisonaiagents.gateway.protocols", "is_weak_secret"),
"assert_gateway_secret_strong": ("praisonaiagents.gateway.protocols", "assert_gateway_secret_strong"),
# Per-platform identity canonicalization (Issue #3886)
"IdentityCanonicalizerProtocol": ("praisonaiagents.gateway.protocols", "IdentityCanonicalizerProtocol"),
# liveness.py — Event-loop liveness watchdog (Issue #3385)
"LoopWatchdogPolicy": ("praisonaiagents.gateway.liveness", "LoopWatchdogPolicy"),
"LoopWatchdog": ("praisonaiagents.gateway.liveness", "LoopWatchdog"),
# liveness.py — Startup-phase watchdog (Issue #5265)
"StartupWatchdog": ("praisonaiagents.gateway.liveness", "StartupWatchdog"),
# degraded_state.py — Unified degraded-capability registry (Issue #3518)
"DegradedOwner": ("praisonaiagents.gateway.degraded_state", "DegradedOwner"),
"DegradedCapabilityProtocol": ("praisonaiagents.gateway.degraded_state", "DegradedCapabilityProtocol"),
"DegradedCapabilityRegistry": ("praisonaiagents.gateway.degraded_state", "DegradedCapabilityRegistry"),
"OWNER_KINDS": ("praisonaiagents.gateway.degraded_state", "OWNER_KINDS"),
"DEGRADED_STATES": ("praisonaiagents.gateway.degraded_state", "DEGRADED_STATES"),
# Fail-closed read of the degraded-owner contract (Issue #3640)
"DegradedCapabilityLookupProtocol": ("praisonaiagents.gateway.degraded_state", "DegradedCapabilityLookupProtocol"),
"OwnerUnavailable": ("praisonaiagents.gateway.degraded_state", "OwnerUnavailable"),
"assert_owner_available": ("praisonaiagents.gateway.degraded_state", "assert_owner_available"),
# hooks.py — Inbound trigger / webhook contract (Issue #2281)
"HookAction": ("praisonaiagents.gateway.hooks", "HookAction"),
"HookConfig": ("praisonaiagents.gateway.hooks", "HookConfig"),
"InboundTriggerProtocol": ("praisonaiagents.gateway.hooks", "InboundTriggerProtocol"),
"render_template": ("praisonaiagents.gateway.hooks", "render_template"),
"compute_idempotency_key": ("praisonaiagents.gateway.hooks", "compute_idempotency_key"),
"verify_webhook_signature": ("praisonaiagents.gateway.hooks", "verify_webhook_signature"),
# config.py (always available)
"GatewayConfig": ("praisonaiagents.gateway.config", "GatewayConfig"),
"SessionConfig": ("praisonaiagents.gateway.config", "SessionConfig"),
"ApiConfig": ("praisonaiagents.gateway.config", "ApiConfig"),
"EmergencyStopConfig": ("praisonaiagents.gateway.config", "EmergencyStopConfig"),
# Attachment ceilings config (Issue #5207)
"AttachmentConfig": ("praisonaiagents.gateway.config", "AttachmentConfig"),
"ChannelRouteConfig": ("praisonaiagents.gateway.config", "ChannelRouteConfig"),
"MultiChannelGatewayConfig": ("praisonaiagents.gateway.config", "MultiChannelGatewayConfig"),
# Config version stamp + doctor-driven migration (Issue #3841)
"GATEWAY_CONFIG_VERSION": ("praisonaiagents.gateway.config", "GATEWAY_CONFIG_VERSION"),
"ConfigVersionError": ("praisonaiagents.gateway.config", "ConfigVersionError"),
"LegacyConfigRule": ("praisonaiagents.gateway.config", "LegacyConfigRule"),
"GATEWAY_CONFIG_RULES": ("praisonaiagents.gateway.config", "GATEWAY_CONFIG_RULES"),
"is_config_current": ("praisonaiagents.gateway.config", "is_config_current"),
"migrate_config_with_doctor": ("praisonaiagents.gateway.config", "migrate_config_with_doctor"),
# Push config
"PushConfig": ("praisonaiagents.gateway.config", "PushConfig"),
"RedisConfig": ("praisonaiagents.gateway.config", "RedisConfig"),
"PresenceConfig": ("praisonaiagents.gateway.config", "PresenceConfig"),
"DeliveryConfig": ("praisonaiagents.gateway.config", "DeliveryConfig"),
"PollingConfig": ("praisonaiagents.gateway.config", "PollingConfig"),
"LivenessConfig": ("praisonaiagents.gateway.config", "LivenessConfig"),
"TurnLockConfig": ("praisonaiagents.gateway.config", "TurnLockConfig"),
# Hot-reload registry (Issue #3378)
"HOT_APPLIABLE_KEYS": ("praisonaiagents.gateway.config", "HOT_APPLIABLE_KEYS"),
"SupportsHotReload": ("praisonaiagents.gateway.config", "SupportsHotReload"),
"is_hot_appliable": ("praisonaiagents.gateway.config", "is_hot_appliable"),
# Reload scope classification (Issue #3440)
"ReloadScope": ("praisonaiagents.gateway.config", "ReloadScope"),
"classify_reload": ("praisonaiagents.gateway.config", "classify_reload"),
# Candidate validation + rollback contract (Issue #5144)
"CandidateReport": ("praisonaiagents.gateway.config", "CandidateReport"),
"ReloadValidationProtocol": (
"praisonaiagents.gateway.config",
"ReloadValidationProtocol",
),
}
# Lazy loading cache (shared with wrapper-backed implementations below)
_lazy_cache = {}
_lazy_getattr = create_lazy_getattr(_LAZY_IMPORTS, __name__, cache=_lazy_cache)
def __getattr__(name: str):
"""Lazy load heavy gateway implementations from praisonai-bot (C9).
"""Lazy load gateway symbols and heavy implementations.
Gap S2: Re-export WebSocketGateway for convenience. Prefer
``praisonai_bot.gateway`` or ``pip install praisonai`` for full stack.
Protocols/policies/config are served from their submodules on first
access via the ``_LAZY_IMPORTS`` map, so ``protocols.py`` is parsed only
when actually touched. ``WebSocketGateway``/``GatewaySession`` are
re-exported from ``praisonai-bot`` (C9); prefer ``praisonai_bot.gateway``
or ``pip install praisonai`` for the full stack (Gap S2).
"""
if name in _lazy_cache:
return _lazy_cache[name]
@@ -309,7 +362,25 @@ def __getattr__(name: str):
"Install with: pip install praisonai-bot or pip install praisonai"
)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
# Everything else: lazy-load from the submodule map (protocols.py parsed
# only if a protocol/policy symbol is actually requested).
value = _lazy_getattr(name)
# Bind the resolved symbol into the module globals so it behaves exactly
# like an eager import: it becomes a real attribute (discoverable via
# ``dir``, deletable, and patchable via ``monkeypatch.delattr``/``setattr``),
# and future accesses skip ``__getattr__`` entirely.
globals()[name] = value
return value
def __dir__():
"""Expose the public API to ``dir()`` / IDE autocomplete without importing.
Returns the union of names already resolved into the module globals and the
declared public ``__all__``, so lazy exports remain discoverable before they
are first accessed (matching the eager-import behaviour).
"""
return sorted(set(globals()) | set(__all__))
__all__ = [
@@ -321,17 +392,40 @@ __all__ = [
"GatewayMessage",
"EventType",
"OperatorScope",
# Multi-observer session sharing (Issue #5192)
"SessionVisibility",
"SessionSharingRole",
"SessionObserverProtocol",
# Session-projection reducer (Issue #5324)
"SessionProjection",
"SessionProjectionState",
"RunView",
# Canonical session-address grammar (Issue #5383)
"SessionRef",
"build_session_path",
"parse_session_path",
"derive_short_id",
"slugify",
"RESERVED_NAMES",
"SHORT_ID_LEN",
"GatewayCloseCode",
# Declarative method -> required-scope registry (Issue #3206)
"GatewayMethodDescriptor",
"GATEWAY_METHODS",
"register_gateway_method",
"resolve_required_scope",
"authorize_method",
"GatewayUnauthorized",
# Config hot-reload observability (Issue #3049)
"ReloadStatus",
"compute_config_revision",
"HealthPressure",
"evaluate_pressure",
# Trusted-proxy ingress attribution (Issue #5312)
"IngressTrust",
"IngressAttribution",
"resolve_ingress_attribution",
"parse_forwarded_for",
# Push protocols (always available)
"PushChannelProtocol",
"PresenceProtocol",
@@ -355,11 +449,18 @@ __all__ = [
"ReactionResult",
"ReactionStatus",
"ThreadResult",
"MessageActionResult",
"MessageActionStatus",
"ThreadStatus",
# Agent-callable cross-conversation request/reply (Issue #3689)
"ConversationReply",
"ConversationReplyStatus",
"ConversationRequestProtocol",
# Server→client interactive request/reply over the transport (Issue #5351)
"GatewayRequestKind",
"GatewayServerRequest",
"GatewayServerReply",
"GatewayRequestChannelProtocol",
# Agent-facing live status/health (Issue #3688)
"GatewayStatusProtocol",
"GatewayStatus",
@@ -479,6 +580,8 @@ __all__ = [
"EmergencyStopProtocol",
"NullEmergencyStop",
"FileEmergencyStop",
"StopScope",
"StopResult",
# Schema-validated inbound frame codec (Issue #2831)
"HelloParams",
"HelloResult",
@@ -487,6 +590,9 @@ __all__ = [
"ConnectRecoveryStep",
"is_recoverable",
"MessageParams",
# First-class attachment contract (Issue #5207)
"AttachmentRef",
"AttachmentStoreProtocol",
"LeaveParams",
"JoinParams",
"FrameDecodeError",
@@ -502,6 +608,8 @@ __all__ = [
# Event-loop liveness watchdog (Issue #3385)
"LoopWatchdogPolicy",
"LoopWatchdog",
# Startup-phase watchdog (Issue #5265)
"StartupWatchdog",
# Unified degraded-capability registry (Issue #3518)
"DegradedOwner",
"DegradedCapabilityProtocol",
@@ -524,6 +632,8 @@ __all__ = [
"SessionConfig",
"ApiConfig",
"EmergencyStopConfig",
# Attachment ceilings config (Issue #5207)
"AttachmentConfig",
"ChannelRouteConfig",
"MultiChannelGatewayConfig",
# Config version stamp + doctor-driven migration (Issue #3841)
@@ -546,6 +656,8 @@ __all__ = [
"is_hot_appliable",
"ReloadScope",
"classify_reload",
"CandidateReport",
"ReloadValidationProtocol",
# Implementations (lazy loaded from praisonai wrapper)
"WebSocketGateway",
"GatewaySession",
@@ -0,0 +1,227 @@
"""Canonical session-address grammar for the Gateway (Issue #5383).
A pure, dependency-free grammar that maps between an opaque runtime
``session_key`` (a UUID, or an ``agent:<id>:<uuid>``-style key) and a stable,
human-readable, reconnect-survivable path — e.g. ``chat/<agentId>/<slug>-<shortId>``.
It gives every surface (dashboard deep-links, bot channels, the CLI, the
``praisonai-ts`` mirror, reconnecting clients) a single addressing contract to
agree on, instead of each one carrying the raw UUID or re-inventing a resolver.
This module lives in **core** because the grammar is a set of pure functions
that define a protocol/contract every surface must share. It has **no**
third-party dependencies and does no I/O — the concrete store-backed lookup
(``session.resolve``) is implemented in the wrapper behind this contract.
The grammar is deliberately lossy in one direction: ``build_session_path``
derives a short id from the trailing UUID so the whole opaque instance id never
appears in a URL, and an optional slug from the session label. ``parse_session_path``
recovers a :class:`SessionRef` (agent id + short id + slug hint) that a resolver
maps back to the concrete ``session_id``; it never assumes it can reconstruct the
key byte-for-byte, except via the exact-key escape hatch (a ``!``-prefixed final
segment) for sessions that must keep their literal key.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Optional
from urllib.parse import quote, unquote
__all__ = [
"SessionRef",
"SHORT_ID_LEN",
"RESERVED_NAMES",
"derive_short_id",
"slugify",
"build_session_path",
"parse_session_path",
]
# Length of the short id derived from the trailing hex of a session key. Eight
# hex chars (32 bits) keeps URLs terse while staying collision-resistant enough
# for a per-agent namespace, where a resolver disambiguates the rare clash.
SHORT_ID_LEN = 8
# Reserved final-segment names that denote a well-known / sentinel session
# (e.g. the agent's ``main`` conversation) rather than a ``slug-shortId`` pair.
# A resolver treats these as a friendly sentinel, not a short-id lookup.
RESERVED_NAMES = frozenset({"main", "global", "default", "root"})
# Trailing run of hex digits that terminates the key — the opaque UUID tail we
# shorten from. Anchored at end-of-string so a *hex run followed by non-hex*
# (e.g. ``report-deadbeef.js``) is NOT shortened and instead round-trips via the
# literal-key escape hatch; only a genuine hex terminus (a UUID tail) is lossy.
_HEX_TAIL_RE = re.compile(r"([0-9a-fA-F]{%d,})$" % SHORT_ID_LEN)
# A ``slug-shortId`` final segment: optional slug, then a hex short id.
_SLUG_SHORT_RE = re.compile(r"^(?:(?P<slug>.+)-)?(?P<short>[0-9a-fA-F]{%d})$" % SHORT_ID_LEN)
# Characters allowed verbatim in a slug; everything else collapses to ``-``.
_SLUG_STRIP_RE = re.compile(r"[^a-z0-9]+")
# Exact-key escape hatch: a final segment prefixed with ``!`` carries the
# percent-encoded literal key verbatim (for sessions that must not be shortened).
_LITERAL_PREFIX = "!"
@dataclass(frozen=True)
class SessionRef:
"""A parsed friendly reference to a session, for a resolver to map to an id.
Exactly one of the resolution hints is authoritative depending on how the
path was written:
- ``literal_key`` set -> resolve by exact key (escape hatch).
- ``short_id`` set -> resolve by short id (optionally aided by ``slug_hint``).
- ``slug_hint`` only, matching a reserved name -> resolve the sentinel session.
``agent_id`` scopes the lookup so short ids only need to be unique per agent.
"""
agent_id: str
short_id: Optional[str] = None
slug_hint: Optional[str] = None
literal_key: Optional[str] = None
def derive_short_id(session_key: str) -> Optional[str]:
"""Derive a stable short id from the terminating hex run of ``session_key``.
Returns the last :data:`SHORT_ID_LEN` hex chars of the key's *terminating*
hex run (e.g. a UUID tail), lower-cased. The run must end the key: a key
whose hex is followed by non-hex characters (e.g. ``report-deadbeef.js``)
is deliberately not shortened, so it round-trips via the literal-key escape
hatch instead of silently losing its suffix. Returns ``None`` when the key
has no terminating hex run long enough to shorten — the caller then falls
back to the literal key or reserved-name form.
"""
if not session_key:
return None
match = _HEX_TAIL_RE.search(session_key)
if not match:
return None
hex_tail = match.group(1)
return hex_tail[-SHORT_ID_LEN:].lower()
def slugify(display_name: Optional[str], max_len: int = 48) -> Optional[str]:
"""Turn a human label into a lowercase, dash-separated URL slug.
Non-alphanumeric runs collapse to a single ``-``; leading/trailing dashes are
trimmed. Returns ``None`` for an empty/whitespace-only or unusable name so
the path omits the slug rather than emitting an empty segment.
"""
if not display_name:
return None
slug = _SLUG_STRIP_RE.sub("-", display_name.strip().lower()).strip("-")
if not slug:
return None
if len(slug) > max_len:
slug = slug[:max_len].rstrip("-")
return slug or None
def build_session_path(
agent_id: str,
session_key: str,
display_name: Optional[str] = None,
namespace: str = "chat",
) -> Optional[str]:
"""Build a stable, shareable path for a session.
Produces ``<namespace>/<agentId>/<slug>-<shortId>`` when a short id can be
derived (with the slug omitted when there is no usable ``display_name``), so
the whole opaque UUID never appears in the URL. When the key has no hex tail
to shorten, falls back to an exact-key segment (``!<percent-encoded-key>``)
so the address round-trips losslessly. Returns ``None`` if ``agent_id`` or
``session_key`` is empty.
Path segments are percent-escaped, so a key that looks like a file
(e.g. ``report.js``) is never mistaken for a static asset.
"""
if not agent_id or not session_key:
return None
ns = _seg(namespace) or "chat"
agent_seg = _seg(agent_id)
if not agent_seg:
return None
short_id = derive_short_id(session_key)
if short_id:
slug = slugify(display_name)
tail = f"{slug}-{short_id}" if slug else short_id
else:
# No hex tail to shorten: keep the literal key behind the escape hatch.
tail = _LITERAL_PREFIX + quote(session_key, safe="")
return f"{ns}/{agent_seg}/{tail}"
def parse_session_path(path: str) -> Optional[SessionRef]:
"""Parse a session path back into a :class:`SessionRef`.
Accepts the forms produced by :func:`build_session_path`, with or without a
leading namespace segment:
- ``chat/<agent>/<slug>-<shortId>`` / ``<agent>/<slug>-<shortId>``
- ``chat/<agent>/<shortId>`` / ``<agent>/<shortId>``
- ``chat/<agent>/main`` (reserved sentinel) / ``<agent>/main``
- ``chat/<agent>/!<percent-encoded-key>`` (exact-key escape hatch)
Returns ``None`` when the path has no agent segment or is otherwise
unparseable. A resolver combines the returned hints with its store to reach
the concrete ``session_id``.
"""
if not path:
return None
parts = [p for p in path.strip().strip("/").split("/") if p]
# ``build_session_path`` percent-escapes the namespace and agent into single
# segments, so a canonical address is exactly ``<ns>/<agent>/<ref>`` (3) or
# ``<agent>/<ref>`` (2). Reject anything longer so a malformed link like
# ``chat/x/y/z`` never silently acquires a different meaning by using only
# its last two segments.
if len(parts) == 3:
# Drop the leading namespace segment; agent + final ref remain.
agent_id = unquote(parts[-2])
final = parts[-1]
elif len(parts) == 2:
agent_id = unquote(parts[0])
final = parts[1]
else:
return None
agent_id = agent_id.strip()
if not agent_id or not final:
return None
# Exact-key escape hatch.
if final.startswith(_LITERAL_PREFIX):
literal = unquote(final[len(_LITERAL_PREFIX):])
if not literal:
return None
return SessionRef(agent_id=agent_id, literal_key=literal)
final = unquote(final)
# Reserved sentinel (main/global/...): a friendly named session.
if final.lower() in RESERVED_NAMES:
return SessionRef(agent_id=agent_id, slug_hint=final.lower())
match = _SLUG_SHORT_RE.match(final)
if match:
return SessionRef(
agent_id=agent_id,
short_id=match.group("short").lower(),
slug_hint=match.group("slug") or None,
)
# No short id present: treat the whole segment as a slug hint the resolver
# can match against session labels.
return SessionRef(agent_id=agent_id, slug_hint=final)
def _seg(value: Optional[str]) -> str:
"""Percent-escape a single path segment (``/`` and dots are encoded)."""
if not value:
return ""
return quote(value.strip(), safe="")
@@ -297,6 +297,74 @@ class SupportsHotReload(Protocol):
...
# ---------------------------------------------------------------------------
# Candidate reload validation + rollback contract (Issue #5144)
# ---------------------------------------------------------------------------
#
# ``classify_reload``/``ReloadScope`` decide *what* a config change touches; a
# structural (``FULL``) change makes the runtime tear the live channels down
# and bring the new config up. A config that passes the schema but fails at
# *runtime* (unreachable model, an adapter that throws on load, bad wiring) is
# not caught before that cutover, and there is no rollback — a single bad edit
# can take a healthy always-on gateway offline.
#
# This is the small, canonical contract that makes a reload an *atomic,
# validated cutover*: build/boot the candidate runtime in isolation and run the
# existing readiness/turn pre-flight *before* the live one is withdrawn; only
# swap when the candidate is proven healthy, otherwise keep the old config
# serving. Core owns only the *shape* (a report + a protocol, no heavy
# imports); the wrapper/bot gateway performs the concrete build+pre-flight.
@dataclass
class CandidateReport:
"""Outcome of validating a candidate config *before* cutover.
Produced by :meth:`ReloadValidationProtocol.validate_candidate`: the
gateway builds/boots the candidate runtime in isolation (or a pre-flight)
and reports whether it is healthy enough to swap in. When ``ok`` is False
the live config keeps serving and the failures feed
:class:`~praisonaiagents.gateway.protocols.ReloadStatus.error` / ``health()``.
Attributes:
ok: Whether the candidate built and passed pre-flight — only then may
the live runtime be drained and the candidate swapped in.
failures: Human-readable reasons the candidate was rejected (empty when
``ok``); joined into the reload ``error`` an operator sees.
candidate: Optional opaque handle to the already-built candidate runtime
the wrapper activates on success. Left untyped so core carries no
heavy runtime import; ``None`` when the runtime validates in place.
"""
ok: bool
failures: "List[str]" = field(default_factory=list)
candidate: Optional[Any] = None
@runtime_checkable
class ReloadValidationProtocol(Protocol):
"""Protocol a gateway implements for atomic, validated config cutover.
The invariant: **never drain the live runtime until**
:meth:`validate_candidate` **returns a report with** ``ok=True``. Owning
this contract in core keeps the "never cut over to an unvalidated
candidate" guarantee canonical so every gateway build honours it, rather
than being re-implemented per entry point (SIGHUP, ``gateway reload``,
``gateway restart``).
"""
async def validate_candidate(
self, new_config: Mapping[str, Any]
) -> CandidateReport:
"""Build/boot the candidate from ``new_config`` and pre-flight it.
Must not stop or mutate the live runtime; on any failure return a
report with ``ok=False`` and the reason(s) so the caller keeps the
previous config serving.
"""
...
@dataclass
class SessionConfig:
"""Configuration for gateway sessions.
@@ -685,10 +753,17 @@ class ApiConfig:
backed by the gateway's live agents and sessions.
mcp: Serve an MCP JSON-RPC endpoint (``/mcp``) exposing the gateway's
registered agents as callable tools.
stream: Emit true token-level deltas on the OpenAI-compatible
``/v1/chat/completions`` SSE surface (``stream:true``) by wiring the
agent's existing ``stream_emitter`` through the gateway hot path.
Off by default: streaming responses stay byte-for-byte the buffered
single-chunk path, so enabling it never regresses non-streaming
latency or correctness.
"""
openai: bool = False
mcp: bool = False
stream: bool = False
@property
def enabled(self) -> bool:
@@ -697,7 +772,7 @@ class ApiConfig:
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {"openai": self.openai, "mcp": self.mcp}
return {"openai": self.openai, "mcp": self.mcp, "stream": self.stream}
@classmethod
def from_dict(cls, data: Optional[Dict[str, Any]]) -> "ApiConfig":
@@ -707,6 +782,7 @@ class ApiConfig:
return cls(
openai=bool(data.get("openai", False)),
mcp=bool(data.get("mcp", False)),
stream=bool(data.get("stream", False)),
)
@@ -775,6 +851,163 @@ class EmergencyStopConfig:
)
@dataclass
class AttachmentConfig:
"""Attachment/media ceilings for the gateway wire protocol (Issue #5207).
The gateway is the control plane custom ``/ws`` clients build on, but the
``message`` frame had no attachment contract and no per-attachment size/type
policy — only the whole-frame ``max_payload``. These ceilings are advertised
to clients in the ``hello`` handshake (folded into ``HelloResult.policy``) so
a well-behaved client self-limits — inlining small files, chunking larger
ones at ``chunk_bytes`` — instead of discovering the limit by being
disconnected. All fields are advisory limits; enforcement lives wherever the
gateway materialises attachments (a wrapper/bot concern).
Backward-compatible: attachments are opt-in per turn, and a client that
never sends one is unaffected by these values.
Attributes:
max_attachment_bytes: Largest single attachment (inline or stored) the
gateway accepts, in bytes. Default 10 MiB.
max_attachments: Maximum number of attachments on one ``message`` turn.
chunk_bytes: Advertised chunk size a client should use when streaming a
large file into the attachment store (reserve → put chunk → close).
allowed_types: Optional allow-list of MIME types / prefixes (e.g.
``image/`` or ``application/pdf``). Empty (default) means no type
restriction is advertised.
"""
max_attachment_bytes: int = 10 * 1024 * 1024 # 10 MiB
max_attachments: int = 10
chunk_bytes: int = 256 * 1024 # 256 KiB
allowed_types: List[str] = field(default_factory=list)
def __post_init__(self) -> None:
if self.max_attachment_bytes < 0:
raise ValueError(
"max_attachment_bytes must be >= 0 (use 0 to disable attachments)"
)
if self.max_attachments < 0:
raise ValueError("max_attachments must be >= 0")
if self.chunk_bytes <= 0:
raise ValueError("chunk_bytes must be > 0")
@property
def enabled(self) -> bool:
"""Whether the gateway advertises any attachment capacity."""
return self.max_attachment_bytes > 0 and self.max_attachments > 0
def to_policy(self) -> Dict[str, Any]:
"""Build the policy fragment advertised in ``HelloResult.policy``.
Only advertises the attachment ceilings when enabled, so a gateway with
attachments disabled advertises exactly today's policy shape. The type
allow-list is included only when configured.
"""
if not self.enabled:
return {}
policy: Dict[str, Any] = {
"max_attachment_bytes": self.max_attachment_bytes,
"max_attachments": self.max_attachments,
"chunk_bytes": self.chunk_bytes,
}
if self.allowed_types:
policy["allowed_attachment_types"] = list(self.allowed_types)
return policy
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"max_attachment_bytes": self.max_attachment_bytes,
"max_attachments": self.max_attachments,
"chunk_bytes": self.chunk_bytes,
"allowed_types": list(self.allowed_types),
}
@classmethod
def from_dict(cls, data: Optional[Dict[str, Any]]) -> "AttachmentConfig":
"""Create from a parsed ``gateway.attachments`` mapping (tolerant of None)."""
if not isinstance(data, dict):
return cls()
allowed = data.get("allowed_types")
return cls(
max_attachment_bytes=int(
data.get("max_attachment_bytes", 10 * 1024 * 1024)
),
max_attachments=int(data.get("max_attachments", 10)),
chunk_bytes=int(data.get("chunk_bytes", 256 * 1024)),
allowed_types=[str(t) for t in allowed] if isinstance(allowed, (list, tuple)) else [],
)
def _parse_drain_timeout(value: Any) -> Optional[float]:
"""Coerce a ``gateway.drain_timeout`` YAML value to a float (or None).
Tolerant of env-substituted strings (e.g. ``"20"``); an unparseable /
negative value falls back to None (no drain) rather than raising, matching
the wrapper's fail-open handling of a malformed drain window (#5362).
"""
if value is None:
return None
try:
parsed = float(value)
except (TypeError, ValueError):
return None
return parsed if parsed >= 0 else None
def _parse_watchdog_enabled(value: Any) -> bool:
"""Whether a ``gateway.watchdog`` YAML value enables the loop watchdog.
Accepts both a bare truthy scalar and the documented block form
(``watchdog: { enabled: true, ... }``), mirroring the wrapper's watchdog
wiring so the public config reflects the same on/off decision (#5362).
"""
if isinstance(value, dict):
flag = value.get("enabled", False)
else:
flag = value
if isinstance(flag, str):
return flag.strip().lower() in ("1", "true", "yes", "on")
return bool(flag)
def _parse_watchdog_timeout(value: Any) -> Optional[float]:
"""Derive the loop-stall budget (seconds) from a ``gateway.watchdog`` block.
Reads ``liveness_interval * liveness_strikes`` from the block form when
present so the public config exposes the effective wedge budget the CLI
``--watchdog-timeout`` flag sets; returns None when unset/unparseable.
"""
if not isinstance(value, dict):
return None
try:
interval = float(value.get("liveness_interval", 5.0))
strikes = int(value.get("liveness_strikes", 3)) or 3
except (TypeError, ValueError):
return None
budget = interval * strikes
return budget if budget > 0 else None
def _parse_watchdog_timeout_scalar(value: Any) -> Optional[float]:
"""Coerce an explicit ``gateway.watchdog_timeout`` scalar to a float.
Mirrors the ``watchdog_timeout > 0`` invariant (see ``GatewayConfig``):
tolerant of env-substituted strings (e.g. ``"15"``); an unparseable or
non-positive value falls back to None so a malformed timeout disables the
budget rather than raising (#5362).
"""
if value is None:
return None
try:
parsed = float(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
@dataclass
class GatewayConfig:
"""Configuration for the gateway server.
@@ -807,6 +1040,13 @@ class GatewayConfig:
host: str = "127.0.0.1"
port: int = 8765
bind_host: Optional[str] = None
# Issue #5312: CIDRs/IPs of upstream proxies/tunnels the operator declares
# trusted. Empty (default) means the gateway is directly exposed: forwarded
# headers are never believed and per-IP policy keys on the socket peer.
# When set, the real client IP is resolved by walking the forwarded chain
# across trusted hops (see ``resolve_ingress_attribution``); anything
# proxy-shaped but unattributable fails closed to the socket peer.
trusted_proxies: List[str] = field(default_factory=list)
cors_origins: List[str] = field(default_factory=lambda: [])
allowed_origins: List[str] = field(default_factory=lambda: [])
auth_token: Optional[str] = None
@@ -830,6 +1070,18 @@ class GatewayConfig:
max_concurrent_runs: int = 0 # Aggregate concurrency ceiling (0 = unlimited)
queue_depth: int = 0 # Bounded wait queue when at the ceiling
overflow_policy: str = "reject" # reject | queue | shed_oldest
# Issue #5362: complete the back-pressure/lifecycle posture on the public
# config so a Python embedder / gateway.yaml can pin it declaratively —
# parity with the CLI ``--drain-timeout`` / ``--watchdog`` /
# ``--watchdog-timeout`` flags. ``None``/False keep today's behaviour
# (no drain, watchdog off); a CLI flag still overrides these at start.
drain_timeout: Optional[float] = None # graceful-drain window on shutdown (s)
watchdog: bool = False # event-loop liveness watchdog (opt-in)
watchdog_timeout: Optional[float] = None # loop-stall budget before restart (s)
# Issue #5168: per-tenant/per-scope concurrency sub-limit within the global
# ceiling so one tenant cannot occupy every run slot. 0 disables the
# sub-limit (today's global-only behaviour).
max_concurrent_runs_per_scope: int = 0
# Issue #2620: pre-auth edge protections for internet-exposed deployments.
# Cap concurrent *unauthenticated* WebSocket connections per source IP so a
# hostile client cannot park many half-open sockets up to max_connections
@@ -858,6 +1110,12 @@ class GatewayConfig:
control: "EmergencyStopConfig" = field(
default_factory=lambda: EmergencyStopConfig()
)
# Issue #5207: first-class attachment ceilings for the gateway wire
# protocol, advertised to clients via HelloResult.policy so a custom /ws
# client can self-limit/chunk before sending a file. Defaults keep a
# sensible 10 MiB / 10-file / 256 KiB-chunk policy; attachments remain
# opt-in per turn so a client that never sends one is unaffected.
attachments: "AttachmentConfig" = field(default_factory=lambda: AttachmentConfig())
# Issue #4766: per-session turn-execution seam. Selects *where* a session's
# agent turn runs (see ``TurnExecutorProtocol``). ``None`` (the default)
# resolves to ``InProcessTurnExecutor`` at runtime — today's on-loop
@@ -899,6 +1157,19 @@ class GatewayConfig:
raise ValueError(
"overflow_policy must be one of 'reject', 'queue', 'shed_oldest'"
)
if self.drain_timeout is not None and self.drain_timeout < 0:
raise ValueError(
"drain_timeout must be >= 0 (use 0/None to disable the drain window)"
)
if self.watchdog_timeout is not None and self.watchdog_timeout <= 0:
raise ValueError(
"watchdog_timeout must be > 0 (leave unset to use the default budget)"
)
if self.max_concurrent_runs_per_scope < 0:
raise ValueError(
"max_concurrent_runs_per_scope must be >= 0 "
"(use 0 to disable the per-scope sub-limit)"
)
if self.preauth_max_connections_per_ip < 0:
raise ValueError(
"preauth_max_connections_per_ip must be >= 0 (use 0 to disable "
@@ -966,6 +1237,7 @@ class GatewayConfig:
return {
"host": self.host,
"port": self.port,
"trusted_proxies": list(self.trusted_proxies),
"cors_origins": self.cors_origins,
"allowed_origins": self.allowed_origins,
"auth_token": "***" if self.auth_token else None,
@@ -981,6 +1253,10 @@ class GatewayConfig:
"max_concurrent_runs": self.max_concurrent_runs,
"queue_depth": self.queue_depth,
"overflow_policy": self.overflow_policy,
"drain_timeout": self.drain_timeout,
"watchdog": self.watchdog,
"watchdog_timeout": self.watchdog_timeout,
"max_concurrent_runs_per_scope": self.max_concurrent_runs_per_scope,
"preauth_max_connections_per_ip": self.preauth_max_connections_per_ip,
"max_unauthorized_frames": self.max_unauthorized_frames,
"push": self.push.to_dict(),
@@ -989,6 +1265,7 @@ class GatewayConfig:
"liveness": self.liveness.to_dict(),
"turn_lock": self.turn_lock.to_dict(),
"control": self.control.to_dict(),
"attachments": self.attachments.to_dict(),
# Report the active executor type so ``gateway doctor`` can surface
# it; ``None`` means the in-process default (today's behaviour).
"executor": (
@@ -1158,9 +1435,17 @@ class MultiChannelGatewayConfig:
for tok, scopes in gw_data["auth_scopes"].items()
}
raw_trusted = gw_data.get("trusted_proxies", [])
trusted_proxies = (
[str(p).strip() for p in raw_trusted if str(p).strip()]
if isinstance(raw_trusted, (list, tuple))
else []
)
gateway_config = GatewayConfig(
host=gw_data.get("host", "127.0.0.1"),
port=gw_data.get("port", 8765),
trusted_proxies=trusted_proxies,
cors_origins=gw_data.get("cors_origins", []),
allowed_origins=gw_data.get("allowed_origins", []),
auth_token=gw_data.get("auth_token"),
@@ -1177,6 +1462,20 @@ class MultiChannelGatewayConfig:
max_concurrent_runs=int(gw_data.get("max_concurrent_runs", 0) or 0),
queue_depth=int(gw_data.get("queue_depth", 0) or 0),
overflow_policy=str(gw_data.get("overflow_policy", "reject") or "reject"),
drain_timeout=_parse_drain_timeout(gw_data.get("drain_timeout")),
watchdog=_parse_watchdog_enabled(gw_data.get("watchdog")),
# Prefer an explicit ``gateway.watchdog_timeout`` scalar (as emitted
# by ``to_dict`` and settable directly, matching the CLI
# ``--watchdog-timeout`` flag); otherwise derive the budget from the
# ``watchdog`` block so both round-trip and block forms work (#5362).
watchdog_timeout=(
_parse_watchdog_timeout_scalar(gw_data.get("watchdog_timeout"))
if gw_data.get("watchdog_timeout") is not None
else _parse_watchdog_timeout(gw_data.get("watchdog"))
),
max_concurrent_runs_per_scope=int(
gw_data.get("max_concurrent_runs_per_scope", 0) or 0
),
preauth_max_connections_per_ip=int(
gw_data.get("preauth_max_connections_per_ip", 32)
),
@@ -1188,6 +1487,7 @@ class MultiChannelGatewayConfig:
liveness=LivenessConfig.from_dict(gw_data.get("liveness")),
turn_lock=TurnLockConfig.from_dict(gw_data.get("turn_lock")),
control=EmergencyStopConfig.from_dict(gw_data.get("control")),
attachments=AttachmentConfig.from_dict(gw_data.get("attachments")),
)
# Parse agents section (pass through as dicts)
@@ -52,6 +52,7 @@ from .protocols import GATEWAY_RESTART_EXIT_CODE
__all__ = [
"LoopWatchdogPolicy",
"LoopWatchdog",
"StartupWatchdog",
]
@@ -437,3 +438,271 @@ class LoopWatchdog:
faulthandler.dump_traceback(file=fh, all_threads=True)
except Exception: # pragma: no cover - fail open
pass
class StartupWatchdog:
"""Front-rung liveness guard for the pre-loop startup window (Issue #5265).
:class:`LoopWatchdog` only becomes meaningful once the asyncio event loop
is running — it probes the loop via ``loop.call_soon_threadsafe``, so it can
do nothing before a loop exists. That leaves the window between process
entry and "loop is live" (heavy imports, config/secret loading, the initial
adapter ``connect()``\\ s) uncovered. A wedge there — a hanging SDK import, a
blocking synchronous DNS/network call while minting credentials, a deadlock
inside ``connect()`` — leaves the process *alive but making no progress*: it
holds no serving loop, answers no probes, and never exits, so an external
supervisor never restarts it. It becomes a silent zombie with no diagnostics.
This primitive closes that gap. It arms a wall-clock **deadline** on a
dedicated daemon OS thread *before* the heavy work begins, so it keeps
working precisely when the main thread is wedged. If the deadline elapses
without :meth:`confirm_loop_live`, it dumps all-thread stacks via
:mod:`faulthandler` (so the hang is diagnosable) and hands the process back
to the supervisor via ``os._exit`` with a restart-intent code. A legitimately
slow-but-alive boot extends the deadline via :meth:`report_startup_progress`
(bounded, so a truly wedged phase still trips). :meth:`confirm_loop_live`
disarms it the instant the serving loop begins — at which point
:class:`LoopWatchdog` takes over.
Design mirrors :class:`LoopWatchdog`: **opt-in** (nothing arms it unless the
embedder calls :meth:`arm`), **fail-open** (any error inside the watchdog
must never kill a healthy startup), and **stdlib-only** (it must run when the
rest of the process cannot, so its correctness cannot depend on the code it
guards). Typical use at the gateway entry point::
sw = StartupWatchdog()
sw.arm(deadline_s=config.startup_watchdog_timeout)
load_config(config); sw.report_startup_progress("config loaded")
connect_adapters(); sw.report_startup_progress("adapters connected")
loop = asyncio.new_event_loop()
LoopWatchdog().arm(loop)
sw.confirm_loop_live() # disarm; loop watchdog now owns liveness
loop.run_forever()
"""
def __init__(
self,
*,
on_expire: Literal["dump_and_exit", "dump_only"] = "dump_and_exit",
exit_code: int = GATEWAY_RESTART_EXIT_CODE,
dump_file: Optional[str] = None,
max_extensions: int = 10,
) -> None:
if on_expire not in ("dump_and_exit", "dump_only"):
raise ValueError(
"on_expire must be 'dump_and_exit' or 'dump_only'"
)
if max_extensions < 0:
raise ValueError("max_extensions must be >= 0")
self.on_expire = on_expire
self.exit_code = exit_code
self.dump_file = dump_file
self.max_extensions = max_extensions
self._thread: Optional[threading.Thread] = None
# Each armed generation gets its own stop event handed straight to the
# thread, so a confirm/re-arm race can never let a stale generation
# observe a fresh generation's cleared flag and self-exit (mirrors the
# per-generation discipline of LoopWatchdog's deadline mode). The stop
# event, once set, stays set: a healthy confirm/disarm is monotonic.
self._stop = threading.Event()
# A separate wake event carries progress reports: it is briefly set to
# nudge the waiting thread to re-read the (extended) deadline, then
# cleared. Keeping it distinct from _stop means a progress nudge can
# never be mistaken for a disarm (and vice versa).
self._wake = threading.Event()
self._deadline_s = 0.0
self._extensions_used = 0
# Progress reports enqueue here; the watchdog thread drains this under
# the lock so budget is spent exactly once per report even when several
# reports batch between two wakes.
self._pending_progress = 0
self._expired = False
self._confirmed = False
self._lock = threading.Lock()
@property
def armed(self) -> bool:
return self._thread is not None and self._thread.is_alive()
@property
def expired(self) -> bool:
"""True once the startup deadline expired (mainly for ``dump_only``)."""
return self._expired
@property
def confirmed(self) -> bool:
"""True once :meth:`confirm_loop_live` has disarmed the watchdog."""
return self._confirmed
def arm(self, *, deadline_s: float, dump_stacks: bool = True) -> None:
"""Arm a startup deadline on a dedicated daemon thread. Fail-open.
Idempotent: arming while already armed is a no-op. A non-finite or
non-positive ``deadline_s`` is ignored (nothing to guard). ``dump_stacks``
toggles the all-thread stack dump on expiry.
"""
if not math.isfinite(deadline_s) or deadline_s <= 0:
return
with self._lock:
if self.armed:
return
stop = threading.Event()
wake = threading.Event()
self._stop = stop
self._wake = wake
self._deadline_s = deadline_s
self._extensions_used = 0
self._pending_progress = 0
self._expired = False
self._confirmed = False
self._dump_stacks_on_expire = dump_stacks
try:
thread = threading.Thread(
target=self._run,
args=(stop, wake),
name="praisonai-startup-watchdog",
daemon=True,
)
thread.start()
except Exception: # pragma: no cover - fail open
self._thread = None
return
self._thread = thread
def report_startup_progress(self, phase: str) -> None:
"""Signal forward progress, extending the deadline by one interval.
A legitimately slow boot (e.g. many adapters, cold DNS) calls this at
each phase so it is not killed. Extensions are bounded by
``max_extensions`` so a phase that itself wedges still trips the
deadline. Fail-open and a no-op when not armed.
Budget accounting is done by the watchdog thread, not here: each report
merely enqueues a pending nudge. If several reports land before the
thread wakes, each still costs exactly one extension *and* each still
earns one fresh deadline window when the thread drains them — so a later
slow-but-progressing phase can never find the budget spent on resets it
never received (Issue #5265 review: "progress reports lose extensions").
"""
if not self.armed:
return
with self._lock:
self._pending_progress += 1
wake = self._wake
# Wake the waiting thread so it drains the pending nudges, spends budget,
# and resets its window once per still-affordable report. The thread
# keeps waiting against its own (still-unset) stop event.
wake.set()
def confirm_loop_live(self) -> None:
"""Disarm the startup watchdog; the event loop is now live.
Called immediately before :class:`LoopWatchdog` is armed. Safe to call
multiple times / when not armed. Sets the *current* generation's stop
event so that exact thread suppresses its self-exit even if it is
mid-dump, then drops the reference.
"""
with self._lock:
self._confirmed = True
self._stop.set()
# Nudge the thread out of its wait immediately so the join is prompt.
self._wake.set()
thread = self._thread
self._thread = None
if thread is not None and thread is not threading.current_thread():
try:
thread.join(timeout=1.0)
except Exception: # pragma: no cover - fail open
pass
def _run(self, stop: "threading.Event", wake: "threading.Event") -> None:
# Wait out the deadline against *this generation's* events. ``stop`` is
# set (and stays set) by a confirm/disarm — that must break us out and
# suppress the self-exit. ``wake`` is a transient progress nudge — on
# each wake we drain the queued progress reports, spending one extension
# of budget per report and resetting the window each time we can still
# afford it. A plain timeout with neither event set (and no affordable
# pending progress) means the startup genuinely wedged.
start = time.monotonic()
while not stop.is_set():
remaining = (start + self._deadline_s) - time.monotonic()
if remaining <= 0:
break
if wake.wait(remaining):
wake.clear()
if stop.is_set():
return
# Drain queued progress reports. Each report costs one extension
# and, while budget remains, grants one fresh deadline window —
# so batched reports never lose the resets they paid for.
extended = False
with self._lock:
pending = self._pending_progress
self._pending_progress = 0
for _ in range(pending):
if self._extensions_used >= self.max_extensions:
break
self._extensions_used += 1
extended = True
if extended:
start = time.monotonic()
# Whether or not budget remained, loop back and re-check the
# (possibly unchanged) deadline against this generation's stop.
continue
self._on_expire(stop)
def _on_expire(self, stop: "threading.Event") -> None:
# Only fire if this generation was not disarmed. A confirm_loop_live()
# that raced in has set this exact event, so a stale generation stays
# inert and never self-exits a healthy startup.
if stop.is_set():
return
self._expired = True
if getattr(self, "_dump_stacks_on_expire", True):
try:
self._dump_stacks()
except Exception: # pragma: no cover - fail open
pass
if self.on_expire == "dump_and_exit":
# If confirm_loop_live() raced in while we were dumping, honour it.
if stop.is_set():
return
try:
sys.stderr.flush()
except Exception: # pragma: no cover - fail open
pass
# Final re-check the instant before the irreversible os._exit: the
# flush above can block (e.g. a full pipe) long enough for a
# concurrent confirm_loop_live() to land. A startup that has just
# confirmed a live loop must never be terminated (Issue #5265
# review: "confirmation cannot prevent late exit").
if stop.is_set():
return
os._exit(self.exit_code)
def _dump_stacks(self) -> None:
reason = (
f"gateway startup did not confirm a live event loop within "
f"~{self._deadline_s:.0f}s"
)
header = (
f"praisonai-startup-watchdog: {reason}; "
f"dumping all-thread stacks\n"
)
try:
sys.stderr.write(header)
except Exception: # pragma: no cover - fail open
pass
try:
faulthandler.dump_traceback(file=sys.stderr, all_threads=True)
except Exception: # pragma: no cover - fail open
pass
dump_file = self.dump_file
if dump_file:
try:
with open(dump_file, "a", encoding="utf-8") as fh:
fh.write(header)
fh.flush()
faulthandler.dump_traceback(file=fh, all_threads=True)
except Exception: # pragma: no cover - fail open
pass
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,365 @@
"""Session-projection reducer for Gateway clients (Issue #5324).
A pure, dependency-free state machine that folds an initial snapshot plus a
stream of :class:`~praisonaiagents.gateway.protocols.GatewayEvent`\\ s into a
consistent, de-duplicated, memory-bounded view of a session. It turns *a
stream of frames* into *a correct, resumable view* so every gateway client
(the bundled Python client, the ``praisonai-ts`` mirror, the realtime
dashboard, third-party integrations) stops re-implementing the same fiddly
reconciliation logic:
- **Snapshot + fold**: apply a snapshot, then fold live events into ``entries``.
- **Identity de-duplication**: a final assistant message that arrives both as a
streamed delta sequence *and* as a persisted transcript row renders once.
- **Optimistic reconciliation**: a locally-echoed outbound message is replaced
(not duplicated) by its durable counterpart, keyed on ``request_id``.
- **Gap handling**: a sequence gap flips ``has_transport_gap``; after a resync
the reducer converges rather than appending duplicates.
- **Bounded retention**: tracked runs are capped (LRU) so long-lived sessions
stay memory-bounded; a run with an active (unfinished) stream is never
evicted.
This module lives in **core** because it is a pure reducer built on types core
already owns (``GatewayEvent``/``GatewayMessage``/``EventType``); it must not be
trapped in the transport client so all clients can share it. It has **no**
third-party dependencies and does no I/O.
"""
from __future__ import annotations
from collections import OrderedDict
from dataclasses import dataclass, field, replace
from typing import Any, Dict, List, Optional, Tuple, Union
from .protocols import EventType, GatewayEvent, GatewayMessage
__all__ = [
"RunView",
"SessionProjectionState",
"SessionProjection",
]
def _event_type_value(event: GatewayEvent) -> str:
"""Return the wire string for an event type (enum or raw str)."""
etype = event.type
return etype.value if isinstance(etype, EventType) else str(etype)
def _run_id_of(event: GatewayEvent) -> Optional[str]:
"""Best-effort run/turn identifier for a streaming event."""
data = event.data or {}
for key in ("run_id", "turn_id", "message_id", "response_id"):
value = data.get(key)
if value:
return str(value)
return None
@dataclass(frozen=True)
class RunView:
"""Immutable view of a single agent run/turn within a session.
Attributes:
run_id: The run/turn identifier the streamed deltas belong to.
text: The accumulated streamed text so far.
done: Whether the stream has ended (``STREAM_END``) for this run.
final_message_id: ``message_id`` of the persisted final row, once the
durable transcript row for this run has been reconciled in.
"""
run_id: str
text: str = ""
done: bool = False
final_message_id: Optional[str] = None
@dataclass(frozen=True)
class SessionProjectionState:
"""Immutable, de-duplicated, bounded view of a session.
Attributes:
entries: Ordered transcript messages, de-duplicated by identity.
runs: Per-run streaming views, keyed by ``run_id``.
has_transport_gap: ``True`` when a sequence gap was observed and a
resync has not yet reconciled the state.
"""
entries: Tuple[GatewayMessage, ...] = ()
runs: Dict[str, RunView] = field(default_factory=dict)
has_transport_gap: bool = False
class SessionProjection:
"""Fold a snapshot + :class:`GatewayEvent`\\ s into a consistent view.
The reducer is idempotent: applying the same persisted message twice (e.g.
once from the live stream's ``STREAM_END`` and once as a durable transcript
row on reconnect) yields a single entry. Feed it with
:meth:`apply_snapshot` then :meth:`apply`; read :attr:`state` at any time.
Args:
max_tracked_runs: Cap on how many finished runs are tracked before the
oldest are evicted (LRU). Runs with an active/unfinished stream are
never evicted. Must be positive.
"""
def __init__(self, *, max_tracked_runs: int = 200) -> None:
if max_tracked_runs <= 0:
raise ValueError("max_tracked_runs must be positive")
self._max_tracked_runs = max_tracked_runs
self._entries: List[GatewayMessage] = []
# message_id -> index into self._entries, for identity de-dup.
self._by_message_id: Dict[str, int] = {}
# request_id -> index into self._entries, for optimistic reconciliation.
self._by_request_id: Dict[str, int] = {}
# run_id -> RunView, ordered for LRU eviction.
self._runs: "OrderedDict[str, RunView]" = OrderedDict()
self._expected_sequence: Optional[int] = None
self._has_gap: bool = False
# -- public API -------------------------------------------------------
@property
def state(self) -> SessionProjectionState:
"""Return the current immutable projection state."""
return SessionProjectionState(
entries=tuple(self._entries),
runs=dict(self._runs),
has_transport_gap=self._has_gap,
)
def apply_snapshot(
self, snapshot: Union[Dict[str, Any], Any]
) -> SessionProjectionState:
"""Replace all tracked state from a full snapshot.
A snapshot resets gap tracking and rebuilds the transcript from the
persisted rows it carries, so a resync converges rather than appending
duplicates. Accepts a mapping (``{"messages"/"entries": [...],
"cursor"/"sequence": N}``) or any object exposing ``messages``/
``entries`` and an optional ``sequence``/``cursor`` attribute.
"""
self._entries = []
self._by_message_id = {}
self._by_request_id = {}
self._runs = OrderedDict()
self._has_gap = False
self._expected_sequence = None
rows = _snapshot_rows(snapshot)
for row in rows:
message = _coerce_message(row)
if message is not None:
self._upsert_message(message)
seq = _snapshot_sequence(snapshot)
if seq is not None:
self._expected_sequence = seq + 1
return self.state
def apply(self, event: GatewayEvent) -> SessionProjectionState:
"""Fold a single event into the state and return the new state.
Idempotent for persisted messages (de-duped by ``message_id``) and for
optimistic echoes (reconciled by ``request_id``). Streaming deltas are
accumulated per run; ``STREAM_END`` closes the run. Sequence gaps set
:attr:`SessionProjectionState.has_transport_gap`.
"""
self._track_sequence(event)
etype = _event_type_value(event)
if etype in _STREAM_DELTA_TYPES:
self._apply_delta(event)
elif etype == EventType.STREAM_END.value:
self._apply_stream_end(event)
elif etype in _MESSAGE_TYPES:
self._apply_message(event)
# Other event types (health, presence, typing, ...) carry no transcript
# state; they are intentionally ignored by the projection.
return self.state
# -- sequence / gap tracking -----------------------------------------
def _track_sequence(self, event: GatewayEvent) -> None:
seq = event.sequence
if seq is None:
return
if self._expected_sequence is not None and seq > self._expected_sequence:
# A hole in the monotonic sequence: mark the view as gapped until a
# snapshot/resync reconciles it.
self._has_gap = True
# Advance the watermark to the highest sequence seen.
if self._expected_sequence is None or seq >= self._expected_sequence:
self._expected_sequence = seq + 1
# -- message handling -------------------------------------------------
def _apply_message(self, event: GatewayEvent) -> None:
message = _coerce_message(event.data)
if message is None:
return
request_id = _request_id_of(event, message)
self._upsert_message(message, request_id=request_id)
def _upsert_message(
self, message: GatewayMessage, *, request_id: Optional[str] = None
) -> None:
# Identity de-dup: same persisted row seen twice renders once.
existing = self._by_message_id.get(message.message_id)
if existing is not None:
self._entries[existing] = message
self._reindex(existing, message, request_id)
return
# Optimistic reconciliation: a durable row replaces its local echo.
if request_id is not None:
echoed = self._by_request_id.get(request_id)
if echoed is not None:
stale_id = self._entries[echoed].message_id
self._entries[echoed] = message
# Drop the echo's provisional message_id so a re-delivered echo
# cannot later overwrite the durable row via identity lookup.
if stale_id != message.message_id:
self._by_message_id.pop(stale_id, None)
self._reindex(echoed, message, request_id)
return
self._entries.append(message)
idx = len(self._entries) - 1
self._by_message_id[message.message_id] = idx
if request_id is not None:
self._by_request_id[request_id] = idx
def _reindex(
self,
idx: int,
message: GatewayMessage,
request_id: Optional[str],
) -> None:
self._by_message_id[message.message_id] = idx
if request_id is not None:
self._by_request_id[request_id] = idx
# -- streaming handling ----------------------------------------------
def _apply_delta(self, event: GatewayEvent) -> None:
run_id = _run_id_of(event)
if run_id is None:
return
chunk = _delta_text_of(event)
current = self._runs.get(run_id)
if current is None:
current = RunView(run_id=run_id, text=chunk)
else:
current = replace(current, text=current.text + chunk)
self._runs[run_id] = current
self._runs.move_to_end(run_id)
self._evict_runs()
def _apply_stream_end(self, event: GatewayEvent) -> None:
run_id = _run_id_of(event)
if run_id is None:
return
current = self._runs.get(run_id) or RunView(run_id=run_id)
final_id = None
data = event.data or {}
final_id = data.get("message_id") or data.get("final_message_id")
current = replace(
current,
done=True,
final_message_id=str(final_id) if final_id else current.final_message_id,
)
self._runs[run_id] = current
self._runs.move_to_end(run_id)
self._evict_runs()
def _evict_runs(self) -> None:
if len(self._runs) <= self._max_tracked_runs:
return
# Evict oldest finished runs first; never evict an active stream.
for run_id in list(self._runs.keys()):
if len(self._runs) <= self._max_tracked_runs:
break
if self._runs[run_id].done:
del self._runs[run_id]
# Streaming delta event types whose text is accumulated per run.
_STREAM_DELTA_TYPES = frozenset(
{
EventType.TOKEN_STREAM.value,
"delta_text",
}
)
# Event types carrying a persisted transcript message.
_MESSAGE_TYPES = frozenset(
{
EventType.MESSAGE.value,
"message_persisted",
}
)
def _delta_text_of(event: GatewayEvent) -> str:
data = event.data or {}
for key in ("delta", "text", "content", "token", "chunk"):
value = data.get(key)
if isinstance(value, str):
return value
return ""
def _request_id_of(
event: GatewayEvent, message: GatewayMessage
) -> Optional[str]:
data = event.data or {}
rid = data.get("request_id") or message.metadata.get("request_id")
return str(rid) if rid else None
def _coerce_message(row: Any) -> Optional[GatewayMessage]:
if isinstance(row, GatewayMessage):
return row
if isinstance(row, dict):
payload = row
# A "message" event wraps the row under a "message" key in some frames.
if "message" in row and isinstance(row["message"], (dict, GatewayMessage)):
inner = row["message"]
return inner if isinstance(inner, GatewayMessage) else _coerce_message(inner)
if "content" not in payload and "message_id" not in payload:
return None
try:
return GatewayMessage.from_dict(payload)
except Exception:
return None
return None
def _snapshot_rows(snapshot: Any) -> List[Any]:
if isinstance(snapshot, dict):
for key in ("messages", "entries", "transcript"):
rows = snapshot.get(key)
if isinstance(rows, list):
return rows
return []
for attr in ("messages", "entries", "transcript"):
rows = getattr(snapshot, attr, None)
if isinstance(rows, list):
return rows
return []
def _snapshot_sequence(snapshot: Any) -> Optional[int]:
if isinstance(snapshot, dict):
for key in ("sequence", "cursor", "seq"):
value = snapshot.get(key)
if isinstance(value, int):
return value
return None
for attr in ("sequence", "cursor", "seq"):
value = getattr(snapshot, attr, None)
if isinstance(value, int):
return value
return None
@@ -193,6 +193,41 @@ class GoalLoopMixin:
self._persist_goal_state()
return "continue", reason
async def _goal_gate_async(self, response: str) -> Optional[Tuple[str, str]]:
"""Async counterpart of :meth:`_goal_gate`.
Both the configured verification hooks (which may shell out) and the
completion judge's ``litellm.completion(...)`` call are blocking I/O, so
the whole gate is offloaded to a worker thread. Mirrors
``_verification_gate_async``'s existing pattern for the same reason: a
slow judge/hook must never stall the event loop shared by other
concurrent agents.
Cancellation safety: the gate mutates shared goal state
(``_goal_state``, verdict counters, journal, persisted metadata). A bare
``asyncio.to_thread`` worker keeps running after the awaiting coroutine
is cancelled, so its late writes could race with ``run_goal_async``'s
teardown (which nulls ``_goal_state``) or a subsequent run reusing the
same agent. We therefore join the worker before propagating the
cancellation, guaranteeing the gate finishes against the goal state it
started on and no orphaned thread mutates a newer run.
"""
state = getattr(self, "_goal_state", None)
if state is None or state.status != "active":
return None
import asyncio
task = asyncio.ensure_future(asyncio.to_thread(self._goal_gate, response))
try:
return await task
except asyncio.CancelledError:
# Let the in-flight gate finish so its state mutations complete
# before run_goal_async's finally clears _goal_state, then re-raise.
try:
await asyncio.shield(task)
except Exception:
pass
raise
# -- public API ------------------------------------------------------------
def _resume_or_new_goal_state(
@@ -80,6 +80,7 @@ __all__ = [
"MessageSendingInput",
"MessageSentInput",
"MessageUndeliveredInput",
"PlatformEventInput",
# Gateway & schedule lifecycle event inputs
"GatewayStartInput",
"GatewayStopInput",
@@ -161,6 +162,7 @@ _LAZY_GROUPS = {
'MessageSendingInput': ('praisonaiagents.hooks.events', 'MessageSendingInput'),
'MessageSentInput': ('praisonaiagents.hooks.events', 'MessageSentInput'),
'MessageUndeliveredInput': ('praisonaiagents.hooks.events', 'MessageUndeliveredInput'),
'PlatformEventInput': ('praisonaiagents.hooks.events', 'PlatformEventInput'),
'GatewayStartInput': ('praisonaiagents.hooks.events', 'GatewayStartInput'),
'GatewayStopInput': ('praisonaiagents.hooks.events', 'GatewayStopInput'),
'ScheduleTriggerInput': ('praisonaiagents.hooks.events', 'ScheduleTriggerInput'),
@@ -388,6 +388,42 @@ class MessageUndeliveredInput(HookInput):
return base
@dataclass
class PlatformEventInput(HookInput):
"""Input for inbound platform-event hooks (Issue #5161).
Carries a normalised, non-message platform event — a reaction, edit,
deletion, membership change or thread creation — to
``REACTION_RECEIVED`` / ``MESSAGE_EDITED`` / ``MESSAGE_DELETED`` /
``MEMBER_JOINED`` / ``MEMBER_LEFT`` / ``THREAD_CREATED`` hooks. Fields mirror
:class:`~praisonaiagents.bots.protocols.PlatformEvent`; the ``kind`` field
disambiguates reaction add vs remove within ``REACTION_RECEIVED``. Emitted
by ``MessageHookMixin.fire_platform_event`` (praisonai-bot).
"""
kind: str = ""
platform: str = ""
chat_id: str = ""
user_id: str = ""
message_id: Optional[str] = None
emoji: Optional[str] = None
new_text: Optional[str] = None
thread_id: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"kind": self.kind,
"platform": self.platform,
"chat_id": self.chat_id,
"user_id": self.user_id,
"message_id": self.message_id,
"emoji": self.emoji,
"new_text": self.new_text[:500] if self.new_text else None,
"thread_id": self.thread_id,
})
return base
@dataclass
class GatewayStartInput(HookInput):
"""Input for GATEWAY_START hooks (gateway/BotOS started)."""
@@ -53,6 +53,9 @@ class HookRegistry:
}
self._enabled = enabled
self._global_timeout = 60.0
# Monotonic counter giving each registered hook a stable secondary sort
# key so equal-priority hooks keep deterministic registration order.
self._seq_counter = 0
# Guard mutation/iteration of the per-event hook lists. The default
# registry is a cross-agent process-wide singleton, so a plugin
# (un)registering a hook on one thread can race a get_hooks() iteration
@@ -80,6 +83,8 @@ class HookRegistry:
The hook ID
"""
with self._lock:
hook._seq = self._seq_counter
self._seq_counter += 1
self._hooks[hook.event].append(hook)
logger.debug(f"Registered hook '{hook.name}' for event '{hook.event.value}'")
return hook.id
@@ -93,7 +98,8 @@ class HookRegistry:
description: Optional[str] = None,
sequential: bool = False,
timeout: float = 60.0,
is_async: bool = False
is_async: bool = False,
priority: int = 100
) -> str:
"""
Register a Python function as a hook.
@@ -107,6 +113,7 @@ class HookRegistry:
sequential: Whether to run sequentially
timeout: Timeout in seconds
is_async: Whether the function is async
priority: Execution order (lower runs earlier). Default 100
Returns:
The hook ID
@@ -119,7 +126,8 @@ class HookRegistry:
description=description,
sequential=sequential,
timeout=timeout,
is_async=is_async
is_async=is_async,
priority=priority
)
return self.register(hook)
@@ -133,7 +141,8 @@ class HookRegistry:
sequential: bool = False,
timeout: float = 60.0,
env: Optional[Dict[str, str]] = None,
shell: bool = True
shell: bool = True,
priority: int = 100
) -> str:
"""
Register a shell command as a hook.
@@ -148,6 +157,7 @@ class HookRegistry:
timeout: Timeout in seconds
env: Additional environment variables
shell: Whether to run in shell mode
priority: Execution order (lower runs earlier). Default 100
Returns:
The hook ID
@@ -161,7 +171,8 @@ class HookRegistry:
sequential=sequential,
timeout=timeout,
env=env or {},
shell=shell
shell=shell,
priority=priority
)
return self.register(hook)
@@ -170,7 +181,8 @@ class HookRegistry:
event: HookEvent,
matcher: Optional[str] = None,
sequential: bool = False,
timeout: float = 60.0
timeout: float = 60.0,
priority: int = 100
) -> Callable:
"""
Decorator to register a function as a hook.
@@ -180,6 +192,7 @@ class HookRegistry:
matcher: Optional regex pattern to match targets
sequential: Whether to run sequentially
timeout: Timeout in seconds
priority: Execution order (lower runs earlier). Default 100
Returns:
Decorator function
@@ -199,7 +212,8 @@ class HookRegistry:
matcher=matcher,
sequential=sequential,
timeout=timeout,
is_async=is_async
is_async=is_async,
priority=priority
)
@wraps(func)
@@ -253,6 +267,10 @@ class HookRegistry:
with self._lock:
hooks = list(self._hooks.get(event, []))
# Sort by priority (lower runs earlier); ties fall back to registration
# order via the monotonic _seq for deterministic, reproducible ordering.
hooks.sort(key=lambda h: (h.priority, h._seq))
if target is None:
return [h for h in hooks if h.enabled]
@@ -284,20 +302,25 @@ class HookRegistry:
Dictionary mapping event names to hook info
"""
result = {}
for event in HookEvent:
hooks = self._hooks[event]
if hooks:
result[event.value] = [
{
"id": h.id,
"name": h.name,
"type": "command" if isinstance(h, CommandHook) else "function",
"matcher": h.matcher,
"enabled": h.enabled,
"sequential": h.sequential
}
for h in hooks
]
with self._lock:
for event in HookEvent:
hooks = list(self._hooks[event])
if hooks:
# Present hooks in effective execution order so an operator
# can see the sequence get_hooks() will run them in.
hooks.sort(key=lambda h: (h.priority, h._seq))
result[event.value] = [
{
"id": h.id,
"name": h.name,
"type": "command" if isinstance(h, CommandHook) else "function",
"matcher": h.matcher,
"enabled": h.enabled,
"sequential": h.sequential,
"priority": h.priority
}
for h in hooks
]
return result
def enable_hook(self, hook_id: str) -> bool:
@@ -359,7 +382,7 @@ def set_default_registry(registry: HookRegistry):
def add_hook(
event: Union[str, HookEvent],
callback: Optional[Callable[[HookInput], HookResult]] = None,
priority: int = 10,
priority: int = 100,
matcher: Optional[str] = None
) -> Union[str, Callable]:
"""Register a hook callback. Simplified API.
@@ -376,7 +399,9 @@ def add_hook(
Args:
event: Hook event name ('before_tool', 'after_llm', etc.) or HookEvent enum
callback: Function to call when hook fires (optional when using as decorator)
priority: Execution order (lower = earlier). Default 10. (Reserved for future use)
priority: Execution order (lower = earlier). Default 100 — same default
bucket as ``register_function``/``on``, so hooks added through either
API keep registration order unless an explicit priority is given.
matcher: Optional regex pattern to match specific targets (e.g., tool names)
Returns:
@@ -402,7 +427,8 @@ def add_hook(
get_default_registry().register_function(
event=event,
func=func,
matcher=matcher
matcher=matcher,
priority=priority
)
return func
return decorator
@@ -410,7 +436,8 @@ def add_hook(
return get_default_registry().register_function(
event=event,
func=callback,
matcher=matcher
matcher=matcher,
priority=priority
)
def remove_hook(hook_id: str) -> bool:
@@ -102,25 +102,41 @@ class HookRunner:
if not hooks:
return []
# Separate sequential and parallel hooks
sequential_hooks = [h for h in hooks if h.sequential]
parallel_hooks = [h for h in hooks if not h.sequential]
results = []
# Execute parallel hooks first
if parallel_hooks:
parallel_results = await self._execute_parallel(
parallel_hooks, event, input_data
)
results.extend(parallel_results)
# Execute sequential hooks
if sequential_hooks:
sequential_results = await self._execute_sequential(
sequential_hooks, event, input_data
)
results.extend(sequential_results)
# The registry hands us hooks already sorted by (priority, _seq), so the
# list is in the exact order the operator asked them to run. Preserve
# that global order across parallel/sequential boundaries: walk the list
# and run each *contiguous* run of same-mode hooks together. A redact
# (sequential, priority 10) that must precede a trace (parallel,
# priority 20) therefore still runs first — splitting all parallel hooks
# to the front would have leaked the unredacted input to the tracer.
# Adjacent parallel hooks are still gathered concurrently; only their
# position relative to a lower-priority sequential hook is respected.
results: List[HookExecutionResult] = []
current_input = input_data
i = 0
n = len(hooks)
while i < n:
j = i + 1
is_seq = hooks[i].sequential
while j < n and hooks[j].sequential == is_seq:
j += 1
run = hooks[i:j]
if is_seq:
run_results = await self._execute_sequential(run, event, current_input)
# A sequential run may have blocked; stop the whole chain so a
# later parallel group never runs after a denial (matches the
# sequential-hook break semantics).
results.extend(run_results)
if any(
r.output and r.output.is_denied() for r in run_results
):
break
else:
run_results = await self._execute_parallel(
run, event, current_input
)
results.extend(run_results)
i = j
return results
@@ -81,7 +81,22 @@ class HookEvent(str, Enum):
# the event that is actually emitted.
BEFORE_MESSAGE = "message_received"
AFTER_MESSAGE = "message_sending"
# Inbound platform events (non-message): the gateway hearing more than text.
# A user reacting/editing/deleting a message, joining/leaving a channel, or a
# thread being created are delivered by the platform SDK but previously
# dropped before reaching the agent or a hook. Adapters normalise the native
# event into a ``PlatformEvent`` and emit it via
# ``MessageHookMixin.fire_platform_event`` (praisonai-bot); platforms that
# cannot deliver a given event simply never emit it (capability-gated,
# opt-in). Payload: ``PlatformEventInput`` (Issue #5161).
REACTION_RECEIVED = "reaction_received"
MESSAGE_EDITED = "message_edited"
MESSAGE_DELETED = "message_deleted"
MEMBER_JOINED = "member_joined"
MEMBER_LEFT = "member_left"
THREAD_CREATED = "thread_created"
# Gateway lifecycle
GATEWAY_START = "gateway_start"
GATEWAY_STOP = "gateway_stop"
@@ -301,6 +316,8 @@ class HookDefinition:
name: Optional[str] = None
description: Optional[str] = None
timeout: float = 60.0 # Timeout in seconds
priority: int = 100 # Execution order (lower runs earlier)
_seq: int = field(default=0, repr=False) # Registration order for stable ties
def matches(self, target: str) -> bool:
"""Check if this hook matches the target (tool name, etc.)."""
@@ -182,7 +182,11 @@ class ChromaKnowledgeAdapter:
carrying api_base when the embedder points at a local server, so the
request cannot escape to a remote provider.
"""
block = self._embedder or {}
# getattr, not attribute access: the adapter is legitimately built
# without __init__ in places (and in tests), and an embedding call must
# not die on a missing attribute -- it should fall back to the
# environment default exactly as it did before the embedder was wired.
block = getattr(self, "_embedder", None) or {}
provider = (block.get("provider") or "").strip().lower()
cfg = block.get("config") or {}
model = cfg.get("model")
@@ -207,6 +211,8 @@ class ChromaKnowledgeAdapter:
**kwargs: Any):
"""Search for relevant content in ChromaDB."""
from ..models import SearchResult, SearchResultItem
from ..protocols import require_scope
require_scope(user_id, agent_id, run_id, "search", backend="chroma")
# Get embedding for query
embedding_model, embedding_kwargs = self._embedding_call()
@@ -232,9 +238,15 @@ class ChromaKnowledgeAdapter:
if run_id is not None:
where_filter["run_id"] = run_id
# Merge with additional filters
# Merge with additional filters, but never let a caller-supplied filter
# overwrite the tenant-scope constraint: filters={"user_id": "bob"} must
# not widen an already-scoped query back onto another tenant's data
# (issue #5319).
if filters:
where_filter.update(filters)
for key, value in filters.items():
if key in where_filter:
continue
where_filter[key] = value
# Search ChromaDB
try:
@@ -285,6 +297,8 @@ class ChromaKnowledgeAdapter:
**kwargs: Any):
"""Add content to ChromaDB."""
from ..models import AddResult
from ..protocols import require_scope
require_scope(user_id, agent_id, run_id, "add", backend="chroma")
# Convert content to string
content_str = str(content)
@@ -371,6 +385,8 @@ class ChromaKnowledgeAdapter:
run_id: Optional[str] = None, limit: int = 100, **kwargs: Any):
"""Get all items from ChromaDB."""
from ..models import SearchResult, SearchResultItem
from ..protocols import require_scope
require_scope(user_id, agent_id, run_id, "get_all", backend="chroma")
try:
# ChromaDB doesn't support get_all with filters easily, so we'll use peek
@@ -506,8 +522,11 @@ class SQLiteKnowledgeAdapter:
**kwargs: Any):
"""Search for relevant content in SQLite."""
from ..models import SearchResult, SearchResultItem
from ..protocols import require_scope
import json
require_scope(user_id, agent_id, run_id, "search", backend="sqlite")
conn = self._get_conn()
# Build query with filters
@@ -524,6 +543,19 @@ class SQLiteKnowledgeAdapter:
sql += " AND run_id = ?"
params.append(run_id)
# Apply metadata filters against the stored JSON metadata column so a
# filtered search returns the same subset chroma/mem0 already return
# instead of silently discarding the filters (issue #5319).
if filters:
for key, value in filters.items():
# Quote the key in the JSON path so a top-level metadata key that
# itself contains dots (e.g. "source.type") is matched literally
# instead of being interpreted as a nested path (issue #5319).
escaped_key = key.replace('"', '""')
sql += " AND json_extract(metadata, ?) = ?"
params.append(f'$."{escaped_key}"')
params.append(value)
sql += " LIMIT ?"
params.append(limit)
@@ -547,9 +579,12 @@ class SQLiteKnowledgeAdapter:
**kwargs: Any):
"""Add content to SQLite."""
from ..models import AddResult
from ..protocols import require_scope
import json
import time
require_scope(user_id, agent_id, run_id, "add", backend="sqlite")
content_str = str(content)
doc_id = str(time.time_ns())
metadata_json = json.dumps(metadata or {})
@@ -592,8 +627,11 @@ class SQLiteKnowledgeAdapter:
run_id: Optional[str] = None, limit: int = 100, **kwargs: Any):
"""Get all items from SQLite."""
from ..models import SearchResult, SearchResultItem
from ..protocols import require_scope
import json
require_scope(user_id, agent_id, run_id, "get_all", backend="sqlite")
conn = self._get_conn()
# Build query with filters
@@ -137,26 +137,15 @@ class Mem0Adapter:
def _safe_mem0_search(self, **kwargs):
"""
Defensive wrapper for mem0.search() to handle MongoDB vector store compatibility.
Catches TypeError about unexpected 'vectors' kwarg and returns safe fallback.
This addresses the upstream mem0 bug: https://github.com/mem0ai/mem0/issues/3185
Delegates to the shared ``safe_mem0_search`` helper so the workaround for
the upstream mem0 bug (https://github.com/mem0ai/mem0/issues/3185) lives in
a single place and cannot drift from the memory-layer copy. The shared
helper returns an empty ``list`` on the fallback branch, which
``_normalize_mem0_results`` already handles alongside the dict shape.
"""
try:
return self.memory.search(**kwargs)
except TypeError as e:
error_msg = str(e).lower()
if "unexpected keyword argument" in error_msg and "vectors" in error_msg:
logger.warning(
"Detected mem0 MongoDB vector store compatibility issue. "
"This is a known upstream bug: https://github.com/mem0ai/mem0/issues/3185. "
"The MongoDB vector store requires Atlas and has signature mismatches. "
"Returning empty results. Consider using Qdrant or Chroma as vector store backends."
)
# Return empty results structure that matches expected mem0 format
return {"results": []}
else:
# Re-raise if it's a different TypeError
raise
from ...memory.adapters.factories import safe_mem0_search
return safe_mem0_search(self.memory, **kwargs)
def _normalize_mem0_results(self, raw: Any) -> SearchResult:
"""
@@ -169,7 +169,12 @@ class MongoDBKnowledgeAdapter:
Returns:
Document ID of inserted content
Raises:
ScopeRequiredError: If no scope identifier is provided
"""
from ..protocols import require_scope
require_scope(user_id, agent_id, run_id, "add", backend="mongodb")
try:
# Generate embedding if model is available
embedding = None
@@ -210,6 +215,7 @@ class MongoDBKnowledgeAdapter:
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
run_id: Optional[str] = None,
filters: Optional[Dict[str, Any]] = None,
**kwargs
) -> List[Dict[str, Any]]:
"""
@@ -221,11 +227,17 @@ class MongoDBKnowledgeAdapter:
user_id: Optional user scope for tenant isolation
agent_id: Optional agent scope for tenant isolation
run_id: Optional run scope for tenant isolation
filters: Optional metadata filters applied to the query
**kwargs: Additional search parameters
Returns:
List of matching documents
Raises:
ScopeRequiredError: If no scope identifier is provided
"""
from ..protocols import require_scope
require_scope(user_id, agent_id, run_id, "search", backend="mongodb")
try:
results = []
@@ -237,6 +249,13 @@ class MongoDBKnowledgeAdapter:
"run_id": run_id,
}.items() if v is not None
}
# Fold metadata filters in alongside the scope so a filtered search
# returns the same subset chroma/mem0 already return instead of
# silently discarding the filters (issue #5319).
if filters:
for key, value in filters.items():
scope_filter[f"metadata.{key}"] = value
# Try vector search first if available
if self.use_vector_search and self.embedding_model and self._is_atlas_connection():
@@ -332,6 +351,127 @@ class MongoDBKnowledgeAdapter:
logger.error(f"Failed to delete document {document_id}: {e}")
return False
def get(self, item_id: str, **kwargs):
"""Get a specific document by ID from the knowledge store.
Returns a ``SearchResultItem`` (or ``None``) so the result shape matches
the shared KnowledgeStoreProtocol and the other adapters — a caller that
reads ``.text``/``.metadata`` or calls ``.to_dict()`` must not break when
it switches to the MongoDB backend (issue #5319).
"""
from ..models import SearchResultItem
try:
from bson import ObjectId
doc = self.collection.find_one({"_id": ObjectId(item_id)})
if not doc:
return None
return SearchResultItem(
id=str(doc["_id"]),
text=doc.get("content", ""),
metadata=doc.get("metadata", {}) or {},
score=1.0,
)
except Exception as e:
logger.error(f"Failed to get document {item_id}: {e}")
return None
def get_all(
self,
*,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
run_id: Optional[str] = None,
limit: int = 100,
**kwargs
):
"""Get all documents matching the given scope.
Returns a ``SearchResult`` to match the shared KnowledgeStoreProtocol
and the other adapters.
Raises:
ScopeRequiredError: If no scope identifier is provided
"""
from ..models import SearchResult, SearchResultItem
from ..protocols import require_scope
require_scope(user_id, agent_id, run_id, "get_all", backend="mongodb")
try:
scope_filter = {
k: v for k, v in {
"user_id": user_id,
"agent_id": agent_id,
"run_id": run_id,
}.items() if v is not None
}
items = []
for doc in self.collection.find(scope_filter).limit(limit):
items.append(SearchResultItem(
id=str(doc["_id"]),
text=doc.get("content", ""),
metadata=doc.get("metadata", {}) or {},
score=1.0,
))
return SearchResult(results=items)
except Exception as e:
logger.error(f"Failed to get_all from MongoDB: {e}")
return SearchResult(results=[])
def update(self, item_id: str, content: Any, **kwargs):
"""Update an existing document's content/metadata by ID.
Returns an ``AddResult`` to match the shared KnowledgeStoreProtocol and
the other adapters (issue #5319).
"""
from ..models import AddResult
try:
from bson import ObjectId
update_fields: Dict[str, Any] = {"content": str(content)}
if "metadata" in kwargs and kwargs["metadata"] is not None:
update_fields["metadata"] = kwargs["metadata"]
result = self.collection.update_one(
{"_id": ObjectId(item_id)},
{"$set": update_fields}
)
if result.matched_count > 0:
return AddResult(success=True, id=item_id)
return AddResult(success=False, message=f"No document matched id {item_id}")
except Exception as e:
logger.error(f"Failed to update document {item_id}: {e}")
return AddResult(success=False, message=str(e))
def delete_all(
self,
*,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
run_id: Optional[str] = None,
**kwargs
) -> bool:
"""Delete all documents matching the given scope.
Enforces the same tenant-scope contract as the mem0 backend: an
unscoped call raises ``ScopeRequiredError`` instead of silently wiping
every tenant's documents from the (typically shared) collection.
Raises:
ScopeRequiredError: If no scope identifier is provided
"""
from ..protocols import require_scope
require_scope(user_id, agent_id, run_id, "delete_all", backend="mongodb")
try:
scope_filter = {
k: v for k, v in {
"user_id": user_id,
"agent_id": agent_id,
"run_id": run_id,
}.items() if v is not None
}
self.collection.delete_many(scope_filter)
return True
except Exception as e:
logger.error(f"Failed to delete_all from MongoDB: {e}")
return False
def close(self):
"""Close MongoDB connection."""
if hasattr(self, 'client'):
@@ -1,6 +1,7 @@
from typing import List, Union, Optional, Dict, Any
from functools import cached_property
import importlib
import importlib.util
#: Names accepted for a chunking strategy that are not chunker ids themselves.
#: ``ChunkingStrategy.FIXED`` and ``.PARAGRAPH`` had no chunker at all, so they
@@ -125,15 +126,32 @@ class Chunking:
self._chunker = None
self._embeddings = None
@staticmethod
def _import_auto_embeddings():
"""Import chonkie's AutoEmbeddings with an actionable error message.
Only a genuinely missing ``chonkie`` package is translated into the
install hint. If ``chonkie`` is installed but its import fails for a
different reason (e.g. a missing transitive dependency), the original
error is re-raised unchanged so the real cause is not masked.
"""
try:
from chonkie.embeddings import AutoEmbeddings
except ImportError as e:
if importlib.util.find_spec("chonkie") is None:
raise ImportError(
"chonkie package not found. Please install it using: pip install 'praisonaiagents[knowledge]'"
) from e
raise
return AutoEmbeddings
@cached_property
def embedding_model(self):
"""Lazy load the embedding model."""
if self._embedding_model is None and self.chunker_type in ['semantic', 'sdpm', 'late']:
from chonkie.embeddings import AutoEmbeddings
return AutoEmbeddings.get_embeddings("all-MiniLM-L6-v2")
return self._import_auto_embeddings().get_embeddings("all-MiniLM-L6-v2")
elif isinstance(self._embedding_model, str):
from chonkie.embeddings import AutoEmbeddings
return AutoEmbeddings.get_embeddings(self._embedding_model)
return self._import_auto_embeddings().get_embeddings(self._embedding_model)
return self._embedding_model
def _get_chunker_params(self) -> Dict[str, Any]:
@@ -1034,7 +1034,12 @@ class Knowledge:
# Store corpus stats for later retrieval
self._corpus_stats = result.corpus_stats
# success defaults to True on the dataclass and was never assigned, so
# index() reported success even when every file failed to embed and
# result.errors was full. Derive it from the errors already collected.
result.success = not result.errors
return result
def get_corpus_stats(self):
@@ -7,6 +7,7 @@ NO heavy imports - only stdlib and typing.
Implementations are provided by praisonai-tools or wrapper layer.
"""
import threading
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
from praisonaiagents._logging import get_logger
@@ -81,11 +82,16 @@ class RerankerRegistry:
"""
_instance: Optional["RerankerRegistry"] = None
_lock = threading.Lock()
def __new__(cls) -> "RerankerRegistry":
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._rerankers: Dict[str, Callable[..., RerankerProtocol]] = {}
with cls._lock:
# Double-checked locking for multi-agent safety
if cls._instance is None:
instance = super().__new__(cls)
instance._rerankers: Dict[str, Callable[..., RerankerProtocol]] = {}
cls._instance = instance
return cls._instance
def register(
@@ -93,30 +99,35 @@ class RerankerRegistry:
name: str,
factory: Callable[..., RerankerProtocol]
) -> None:
"""Register a reranker factory."""
self._rerankers[name] = factory
"""Register a reranker factory. Thread-safe."""
with self._lock:
self._rerankers[name] = factory
def get(
self,
name: str,
**kwargs
) -> Optional[RerankerProtocol]:
"""Get a reranker by name."""
if name in self._rerankers:
try:
return self._rerankers[name](**kwargs)
except Exception as e:
logger.warning(f"Failed to initialize reranker '{name}': {e}")
return None
return None
"""Get a reranker by name. Thread-safe."""
with self._lock:
factory = self._rerankers.get(name)
if factory is None:
return None
try:
return factory(**kwargs)
except Exception as e:
logger.warning(f"Failed to initialize reranker '{name}': {e}")
return None
def list_rerankers(self) -> List[str]:
"""List all registered reranker names."""
return list(self._rerankers.keys())
"""List all registered reranker names. Thread-safe."""
with self._lock:
return list(self._rerankers.keys())
def clear(self) -> None:
"""Clear all registered rerankers."""
self._rerankers.clear()
"""Clear all registered rerankers. Thread-safe."""
with self._lock:
self._rerankers.clear()
def get_reranker_registry() -> RerankerRegistry:
"""Get the global reranker registry instance."""
@@ -7,6 +7,7 @@ NO heavy imports - only stdlib and typing.
Implementations are provided by the wrapper layer.
"""
import threading
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
from enum import Enum
@@ -99,11 +100,16 @@ class RetrieverRegistry:
"""
_instance: Optional["RetrieverRegistry"] = None
_lock = threading.Lock()
def __new__(cls) -> "RetrieverRegistry":
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._retrievers: Dict[str, Callable[..., RetrieverProtocol]] = {}
with cls._lock:
# Double-checked locking for multi-agent safety
if cls._instance is None:
instance = super().__new__(cls)
instance._retrievers: Dict[str, Callable[..., RetrieverProtocol]] = {}
cls._instance = instance
return cls._instance
def register(
@@ -111,30 +117,35 @@ class RetrieverRegistry:
name: str,
factory: Callable[..., RetrieverProtocol]
) -> None:
"""Register a retriever factory."""
self._retrievers[name] = factory
"""Register a retriever factory. Thread-safe."""
with self._lock:
self._retrievers[name] = factory
def get(
self,
name: str,
**kwargs
) -> Optional[RetrieverProtocol]:
"""Get a retriever by name."""
if name in self._retrievers:
try:
return self._retrievers[name](**kwargs)
except Exception as e:
logger.warning(f"Failed to initialize retriever '{name}': {e}")
return None
return None
"""Get a retriever by name. Thread-safe."""
with self._lock:
factory = self._retrievers.get(name)
if factory is None:
return None
try:
return factory(**kwargs)
except Exception as e:
logger.warning(f"Failed to initialize retriever '{name}': {e}")
return None
def list_retrievers(self) -> List[str]:
"""List all registered retriever names."""
return list(self._retrievers.keys())
"""List all registered retriever names. Thread-safe."""
with self._lock:
return list(self._retrievers.keys())
def clear(self) -> None:
"""Clear all registered retrievers."""
self._retrievers.clear()
"""Clear all registered retrievers. Thread-safe."""
with self._lock:
self._retrievers.clear()
def get_retriever_registry() -> RetrieverRegistry:
"""Get the global retriever registry instance."""
@@ -87,6 +87,10 @@ def __getattr__(name):
from .model_capabilities import supports_streaming_with_tools
_lazy_cache[name] = supports_streaming_with_tools
return supports_streaming_with_tools
elif name == "max_output_tokens":
from .model_capabilities import max_output_tokens
_lazy_cache[name] = max_output_tokens
return max_output_tokens
elif name == "ModelRouter":
from .model_router import ModelRouter
_lazy_cache[name] = ModelRouter
@@ -243,6 +247,7 @@ __all__ = [
"process_stream_chunks",
"supports_structured_outputs",
"supports_streaming_with_tools",
"max_output_tokens",
"ModelRouter",
"ModelProfile",
"TaskComplexity",
@@ -10,8 +10,70 @@ and integrates with Gap 2 (parallel tool execution).
from ..protocols import LLMProviderAdapterProtocol
import json
import logging
import re
from typing import Dict, Any, List, Optional
_logger = logging.getLogger(__name__)
def resolve_ollama_chained_arguments(
arguments: Dict[str, Any],
tool_result_mapping: Dict[str, Any],
*,
correlation_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Resolve exact Ollama function-name references without coercing values.
Weak local models sometimes emit the name of an earlier function as a
later argument. Keep the complete result (including mappings, lists,
negative numbers, and decimals) so the destination tool receives exactly
what the first tool returned.
"""
if not tool_result_mapping:
return arguments
resolved = dict(arguments)
for arg_name, arg_value in resolved.items():
if isinstance(arg_value, str) and arg_value in tool_result_mapping:
replacement = tool_result_mapping[arg_value]
resolved[arg_name] = replacement
extra = {"correlation_id": correlation_id} if correlation_id else {}
_logger.debug(
"[OLLAMA_FIX] Replaced %s with a prior result in %s arguments",
arg_value,
arg_name,
extra=extra,
)
return resolved
def record_ollama_tool_result(
tool_result_mapping: Dict[str, Any],
function_name: str,
tool_result: Any,
) -> None:
"""Record an Ollama tool result exactly as returned by the callback."""
if not function_name:
return
# Some execution paths return a ToolResult-like object rather than the
# callback's raw payload. Never expose its failure diagnostic as a
# successful same-turn value.
if getattr(tool_result, "error", None) is not None:
return
# The agent executor represents failures as an ``error`` mapping (or a
# one-item list containing one). Never feed that diagnostic back as a
# successful value to a dependent call in the same turn.
if isinstance(tool_result, dict) and tool_result.get("error"):
return
if (
isinstance(tool_result, list)
and tool_result
and isinstance(tool_result[0], dict)
and tool_result[0].get("error")
):
return
tool_result_mapping[function_name] = tool_result
def collapse_union_param_types(tools):
"""Rewrite tool schemas a local server cannot parse.
@@ -55,42 +117,147 @@ def collapse_union_param_types(tools):
return [fix(tool) for tool in tools]
def _advertised_tool_names(tools: List[Dict[str, Any]]) -> set:
"""The set of tool names the model was actually offered.
Salvage must never invent a tool: a text-format call is only promoted when
its name matches something advertised, so a model hallucinating a plausible
name in prose cannot trigger an unexpected execution.
"""
names = set()
for tool in tools or []:
if isinstance(tool, dict):
name = tool.get("function", {}).get("name") or tool.get("name")
if name:
names.add(name)
return names
_FENCE_RE = re.compile(
r"(?P<fence>`{3,}|~{3,})" # opening fence: >=3 backticks or tildes
r"[\s\S]*?" # body (lazy)
r"(?:(?P=fence)|\Z)", # matching closing fence OR end-of-string
re.MULTILINE,
)
def _mask_protected_ranges(text: str) -> str:
"""Blank out fenced code blocks so a call *shown* as an example is not run.
Only the span content is replaced (with spaces of equal length) -- offsets
are preserved, so nothing outside a fence shifts. Legitimate prose that
merely contains angle brackets is untouched because it is not a tool-call
dialect; the fence guard specifically protects documentation of a call.
Both Markdown fence styles are recognised (``` and ~~~), and an *unclosed*
fence is treated as running to end-of-text: a truncated example that opens
a fence and names a real tool must not slip through as an executable call.
"""
def blank(match):
return " " * len(match.group(0))
return _FENCE_RE.sub(blank, text)
def _make_tool_call(name: str, arguments: Any, seed: str, idx: int) -> Dict[str, Any]:
return {
"id": f"call_{name}_{idx}_{hash(seed) % 10000}",
"type": "function",
"function": {
"name": name,
"arguments": json.dumps(arguments) if isinstance(arguments, (dict, list)) else str(arguments),
},
}
# <tool_call>{"name": ..., "arguments": ...}</tool_call>
_TOOL_CALL_TAG_RE = re.compile(r"<tool_call>\s*(\{[\s\S]*?\})\s*</tool_call>", re.IGNORECASE)
# <function=name>{...}</function>
_FUNCTION_TAG_RE = re.compile(r"<function=([a-zA-Z0-9_\-]+)>\s*(\{[\s\S]*?\})\s*</function>", re.IGNORECASE)
def _recover_json_tool_calls(response_text: str, tools: List[Dict[str, Any]]) -> Optional[List[Dict[str, Any]]]:
"""Recover a tool call a local model emitted as JSON text.
"""Salvage a tool call a local model emitted as text instead of natively.
Shared by every locally-served engine: small models routinely answer with
the call as content instead of using the tool_calls field. Deliberately NOT
on DefaultAdapter -- a hosted model returning JSON prose must never have it
parsed as a tool call.
Three dialects are recovered, in-process, with no extra LLM round trip:
1. the whole content is a JSON object/array of ``{"name", "arguments"}``;
2. one or more ``<tool_call>{...}</tool_call>`` blocks embedded in prose;
3. one or more ``<function=name>{...}</function>`` blocks embedded in prose.
Every recovered name is checked against the advertised tool allowlist so a
call is never invented, and fenced code blocks are masked first so a call
shown as a documentation example is never executed.
"""
if not response_text or not tools:
return None
allowed = _advertised_tool_names(tools)
if not allowed:
return None
# Fast path: a clean full-content JSON payload (the common case, unchanged).
try:
import json
response_json = json.loads(response_text.strip())
# Normalize to list so both single and multi-tool payloads are supported
if isinstance(response_json, dict):
response_json = [response_json]
if isinstance(response_json, list):
tool_calls: List[Dict[str, Any]] = []
for idx, tool_json in enumerate(response_json):
if isinstance(tool_json, dict) and "name" in tool_json:
tool_calls.append({
"id": f"call_{tool_json['name']}_{idx}_{hash(response_text) % 10000}",
"type": "function",
"function": {
"name": tool_json["name"],
"arguments": json.dumps(tool_json.get("arguments", {}))
}
})
return tool_calls if tool_calls else None
name = tool_json.get("name") if isinstance(tool_json, dict) else None
if name:
tool_calls.append(_make_tool_call(
name, tool_json.get("arguments", {}), response_text, idx))
if tool_calls:
return tool_calls
except (json.JSONDecodeError, TypeError, KeyError):
pass
return None
# Embedded dialects: only scan when a marker is present, and only outside
# fenced code so an example call in documentation is never run. The marker
# probe is case-insensitive to match the tag regexes -- a model that emits
# <TOOL_CALL> or <FUNCTION=...> must recover just like the lowercase form.
lowered = response_text.lower()
if not any(marker in lowered for marker in ("<tool_call>", "<function=")):
return None
scan_text = _mask_protected_ranges(response_text)
# Scan both dialects in a single pass ordered by position in the text, so a
# model that interleaves <tool_call> and <function=> blocks yields calls in
# the order it actually wrote them -- a dependent or side-effecting call is
# never reordered ahead of one it relies on.
matches = []
for match in _TOOL_CALL_TAG_RE.finditer(scan_text):
try:
data = json.loads(match.group(1))
except (json.JSONDecodeError, TypeError):
continue
if isinstance(data, dict):
name = data.get("name") or data.get("tool")
if name in allowed:
args = data.get("arguments", data.get("parameters", {}))
matches.append((match.start(), name, args))
for match in _FUNCTION_TAG_RE.finditer(scan_text):
name = match.group(1)
if name in allowed:
try:
args = json.loads(match.group(2))
except (json.JSONDecodeError, TypeError):
continue
matches.append((match.start(), name, args))
matches.sort(key=lambda m: m[0])
salvaged = [
_make_tool_call(name, args, response_text, idx)
for idx, (_, name, args) in enumerate(matches)
]
return salvaged if salvaged else None
class DefaultAdapter:
@@ -100,6 +267,25 @@ class DefaultAdapter:
"""Hosted providers get the schema untouched -- they support all of it."""
return tools
def resolve_chained_arguments(
self,
arguments: Dict[str, Any],
tool_result_mapping: Dict[str, Any],
*,
correlation_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Provider hook for same-turn tool-result references."""
return arguments
def record_tool_result(
self,
tool_result_mapping: Dict[str, Any],
function_name: str,
tool_result: Any,
) -> None:
"""Provider hook for recording results used by later tool calls."""
return None
def supports_prompt_caching(self) -> bool:
return False
@@ -178,6 +364,25 @@ class OllamaAdapter(DefaultAdapter):
def supports_streaming_with_tools(self) -> bool:
# Ollama doesn't reliably support streaming with tools
return False
def resolve_chained_arguments(
self,
arguments: Dict[str, Any],
tool_result_mapping: Dict[str, Any],
*,
correlation_id: Optional[str] = None,
) -> Dict[str, Any]:
return resolve_ollama_chained_arguments(
arguments, tool_result_mapping, correlation_id=correlation_id
)
def record_tool_result(
self,
tool_result_mapping: Dict[str, Any],
function_name: str,
tool_result: Any,
) -> None:
record_ollama_tool_result(tool_result_mapping, function_name, tool_result)
@@ -234,9 +439,19 @@ Now provide your final answer using this result. Summarize the information natur
# Ollama 400s the whole request on a union-typed parameter.
return collapse_union_param_types(tools)
# A JSON grammar and tools cannot coexist here: the grammar makes the
# tool-call tag unemittable and the model fabricates instead.
format_and_tools_conflict = True
# Read from the quirk catalogue rather than restating it. Hard-coding this
# created a second source of truth: correcting the quirk note would have
# left the behaviour unchanged, which is the drift the catalogue exists to
# prevent.
@property
def format_and_tools_conflict(self) -> bool:
try:
from ...local.quirktable import Quirk, quirks_for
from ...local.capabilities import ApiStyle, LocalEngine
return Quirk.FORMAT_AND_TOOLS_MUTUALLY_DESTRUCTIVE in quirks_for(
LocalEngine.OLLAMA, ApiStyle.OPENAI_CHAT)
except Exception: # noqa: BLE001 -- the catalogue must never break a request
return True
def get_default_settings(self) -> Dict[str, Any]:
@@ -588,12 +588,48 @@ def get_retry_delay(category: ErrorCategory, attempt: int = 1, base_delay: float
return 0
def extract_retry_after(error: Exception) -> Optional[float]:
def _retry_after_http_date_delay(value: str) -> Optional[float]:
"""Return seconds until an HTTP-date ``Retry-After`` value, else ``None``.
RFC 7231 allows ``Retry-After`` to be either delta-seconds or an absolute
HTTP-date. Delta-seconds is handled by the caller; this parses the date
form using the stdlib (no new dependency) and returns the non-negative
delay from now. A value in the past or unparseable yields ``None`` so the
caller falls through to its other signals.
"""
try:
from email.utils import parsedate_to_datetime
except Exception: # pragma: no cover - stdlib always present
return None
try:
when = parsedate_to_datetime(value.strip())
except (TypeError, ValueError):
return None
if when is None:
return None
import time as _time
from datetime import timezone
# An HTTP-date is UTC; a naive parse result is treated as UTC per the RFC.
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
delay = when.timestamp() - _time.time()
return delay if delay > 0 else None
def extract_retry_after(
error: Exception, cap_seconds: float = 300.0,
) -> Optional[float]:
"""Extract Retry-After header value from rate limit errors.
Args:
error: Exception potentially containing Retry-After info
cap_seconds: Upper bound applied to the parsed value. Defaults to 300s
(5 minutes) for in-tick backoff callers. A scheduler *hold* passes a
larger cap so a job can be parked past a long provider reset window
(e.g. an hourly quota) instead of re-firing every tick — capping at
5 minutes there would defeat the park.
Returns:
Delay in seconds if found, None otherwise
"""
@@ -608,14 +644,21 @@ def extract_retry_after(error: Exception) -> Optional[float]:
retry_after = None
if retry_after is not None:
try:
return min(float(retry_after), 300.0) # Cap at 5 minutes
return min(float(retry_after), cap_seconds)
except (ValueError, TypeError):
pass # Not a plain number (could be an HTTP-date); fall through
# Not a plain delta-seconds value — RFC 7231 also permits an
# HTTP-date. Parse it so a provider that sends an absolute reset
# instant still yields a usable delay (else a long quota window
# would leave the job un-parked). A past/unparseable date yields
# no delay and falls through to the other signals.
delay = _retry_after_http_date_delay(str(retry_after))
if delay is not None:
return min(delay, cap_seconds)
# 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), 300.0)
return min(float(retry_after_attr), cap_seconds)
error_str = str(error)
@@ -632,7 +675,7 @@ def extract_retry_after(error: Exception) -> Optional[float]:
if match:
try:
delay = float(match.group(1))
return min(delay, 300.0) # Cap at 5 minutes
return min(delay, cap_seconds)
except (ValueError, IndexError):
continue
@@ -359,10 +359,17 @@ class FailoverManager:
if profile.cooldown_until and current_time >= profile.cooldown_until:
profile.reset()
# Find first available profile
for profile in self._profiles:
if profile.is_available:
return profile
# Find available profiles (already priority-sorted by add_profile).
available = [p for p in self._profiles if p.is_available]
if available:
if self.config.rotate_on_success:
# Round-robin among the current highest-priority tier only,
# so equal-priority credentials configured to spread load
# actually share traffic instead of one absorbing all of it.
top_priority = available[0].priority
tier = [p for p in available if p.priority == top_priority]
return tier[self._current_index % len(tier)]
return available[0]
# If none available, return the one with shortest remaining cooldown
available_profiles = [p for p in self._profiles if p.status != ProviderStatus.DISABLED]
@@ -443,6 +450,10 @@ class FailoverManager:
profile: The profile that succeeded
"""
with self._lock:
# Advance the round-robin cursor so the next get_next_profile()
# hands work to a different equal-priority credential.
if self.config.rotate_on_success:
self._current_index += 1
if profile.status != ProviderStatus.AVAILABLE:
# Snapshot the cooldown this success is recovering from BEFORE
# resetting, so we can avoid clobbering a newer fleet-wide bench.
File diff suppressed because it is too large. Load diff
@@ -38,6 +38,27 @@ def _base_model_name(model_name: str) -> str:
return name
def _coerce_output_token_limit(value) -> int | None:
"""Return a positive integer output ceiling, or ``None`` when invalid."""
if isinstance(value, bool) or value is None:
return None
try:
coerced = int(value)
except (TypeError, ValueError, OverflowError):
return None
# ``int`` truncates fractional metadata (for example, ``8192.5``), which
# would turn an invalid ceiling into an apparently authoritative limit.
# Keep numeric strings such as ``"8192"`` compatible with LiteLLM's
# metadata while rejecting non-integral numeric values before conversion.
if not isinstance(value, str):
try:
if value != coerced:
return None
except Exception:
return None
return coerced if coerced > 0 else None
def _fallback_supports_structured_outputs(model_name: str) -> bool:
"""Static heuristic used only when litellm is unavailable.
@@ -577,8 +598,8 @@ def max_output_tokens(model_name: str):
try:
info = litellm.get_model_info(model=model_name)
if info:
out = info.get("max_output_tokens")
if out:
out = _coerce_output_token_limit(info.get("max_output_tokens"))
if out is not None:
return out
except Exception:
pass
@@ -594,7 +615,7 @@ def max_output_tokens(model_name: str):
if info is None and "/" in model_name:
info = model_cost.get(model_name.split("/")[-1].lower())
if info:
return info.get("max_output_tokens")
return _coerce_output_token_limit(info.get("max_output_tokens"))
except Exception:
pass
@@ -617,4 +638,4 @@ def is_gemini_internal_tool(tool) -> bool:
if isinstance(tool, dict) and len(tool) == 1:
tool_name = next(iter(tool.keys()))
return tool_name in GEMINI_INTERNAL_TOOLS
return False
return False
Loaded 100 of 587 files, more files were not shown because too many files have changed in this diff. Show more