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
133 changed files with 8653 additions and 617 deletions

No files matched your search

+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
@@ -42,7 +42,7 @@ jobs:
# ============================================
smoke:
runs-on: ubuntu-latest
timeout-minutes: 12
timeout-minutes: 16
steps:
- uses: actions/checkout@v4
with:
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.11" \
"praisonai>=4.7.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.11" \
"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.11" \
"praisonai>=4.7.12" \
"praisonai[ui]" \
"praisonai[crewai]"
+18 -18
View File
@@ -21,45 +21,45 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Agent | 22 | 59 | 19076 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 701 |
| ✅ Approval | 1 | 8 | 3969 |
| ✅ Approval | 1 | 8 | 4028 |
| ✅ Audio | 2 | 12 | 1000 |
| ✅ Auto Generation | 5 | 12 | 3941 |
| ✅ Autonomy | 3 | 7 | 2805 |
| ✅ Bots | 7 | 38 | 15546 |
| ✅ Bots | 7 | 38 | 15809 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 124 | 39107 |
| ✅ 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 | 14331 |
| ✅ Context Management | 16 | 36 | 14384 |
| ✅ Deep Research | 8 | 2 | 591 |
| ✅ Display | 6 | 3 | 844 |
| ✅ Embeddings | 6 | 23 | 2051 |
| ✅ Evaluation | 1 | 9 | 3537 |
| ✅ Evaluation | 1 | 9 | 3637 |
| ✅ Events | 1 | 2 | 782 |
| ✅ Execution | 3 | 4 | 1693 |
| ✅ Execution | 3 | 4 | 1694 |
| ✅ Failover | 2 | 1 | 537 |
| ✅ Files | 2 | 7 | 2376 |
| ✅ Flow | 1 | 3 | 811 |
| ✅ Gateway | 7 | 93 | 36047 |
| ✅ Guardrails | 5 | 4 | 2638 |
| ✅ Gateway | 7 | 97 | 37652 |
| ✅ Guardrails | 5 | 4 | 2642 |
| ✅ Handoffs | 11 | 6 | 2748 |
| ✅ Hooks | 2 | 9 | 5230 |
| ✅ Image | 1 | 11 | 1295 |
| ✅ Knowledge | 4 | 20 | 6188 |
| ✅ Knowledge | 4 | 20 | 6260 |
| ✅ LLM | 3 | 15 | 5958 |
| ✅ Loops | 4 | 5 | 1475 |
| ✅ MCP | 1 | 64 | 15068 |
| ✅ Memory | 6 | 21 | 8194 |
| ✅ Loops | 4 | 5 | 1478 |
| ✅ MCP | 1 | 64 | 15116 |
| ✅ Memory | 6 | 21 | 8198 |
| ✅ OCR | 2 | 1 | 237 |
| ✅ Observability | 2 | 23 | 2859 |
| ✅ Optimizer | 1 | 2 | 792 |
| ✅ Output | 3 | 5 | 1289 |
| ✅ Parallel Execution | 3 | 2 | 528 |
| ✅ Planning | 6 | 7 | 1825 |
| ✅ Plugins | 8 | 5 | 3785 |
| ✅ Planning | 6 | 7 | 1827 |
| ✅ Plugins | 8 | 5 | 4112 |
| ✅ Prompts | 2 | 9 | 2043 |
| ✅ Providers | 1 | 58 | 8751 |
| ✅ Query | 1 | 2 | 736 |
@@ -68,14 +68,14 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Reflection | 3 | 3 | 732 |
| ✅ Retrieval | 2 | 5 | 1046 |
| ✅ Routing | 1 | 2 | 430 |
| ✅ Sandbox | 5 | 7 | 3712 |
| ✅ Sandbox | 5 | 7 | 3720 |
| ✅ Security | 1 | 3 | 3642 |
| ✅ Sessions | 4 | 21 | 8386 |
| ✅ Sessions | 4 | 21 | 8388 |
| ✅ Skills | 6 | 16 | 6045 |
| ✅ Tasks | 2 | 8 | 3266 |
| ✅ Telemetry | 1 | 2 | 660 |
| ✅ Templates | 1 | 9 | 1859 |
| ✅ Tools | 12 | 145 | 44877 |
| ✅ Tools | 12 | 147 | 45378 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Vector Store | 1 | 12 | 1219 |
| ✅ Video | 2 | 10 | 1303 |
@@ -87,7 +87,7 @@ This report compares **Python SDK feature categories** against **Python document
These docs exist but don't match any implemented feature category:
- ℹ️ Database (44 docs, 8056 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
}
@@ -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
@@ -7126,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.
@@ -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.
@@ -3562,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)
@@ -3569,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:
@@ -3693,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)
@@ -3700,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
@@ -3721,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)
@@ -3732,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}'.
@@ -3800,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:
@@ -3818,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")
@@ -4228,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)
@@ -4235,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:
@@ -4373,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)
@@ -4380,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:
@@ -4403,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:
@@ -4415,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:
@@ -4455,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:
@@ -4489,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:
@@ -4508,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 + [
@@ -4558,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
@@ -4608,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)
@@ -4722,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:
@@ -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.
@@ -547,6 +547,15 @@ class ToolExecutionMixin:
so disabling or unregistering a plugin revokes its tools from this
agent's discovery and execution paths.
"""
# Hot-path fast exit: when no plugins are registered (the common case)
# skip constructing/scanning the manager entirely on every Agent init.
try:
from ..plugins.manager import has_registered_plugins
if not has_registered_plugins():
return
except Exception: # pragma: no cover - defensive plugin boundary
pass
try:
from ..plugins import get_plugin_manager
@@ -673,6 +682,15 @@ class ToolExecutionMixin:
prevent the Agent from constructing. Zero overhead when no such plugin
is enabled (each collector returns an empty list).
"""
# Hot-path fast exit: when no plugins are registered (the common case)
# skip constructing/scanning the manager entirely on every Agent init.
try:
from ..plugins.manager import has_registered_plugins
if not has_registered_plugins():
return
except Exception: # pragma: no cover - defensive plugin boundary
pass
try:
from ..plugins import get_plugin_manager
@@ -1904,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(),
@@ -1911,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
@@ -1928,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()
@@ -125,6 +125,13 @@ class BotConfig:
# Edit interval for streaming responses in milliseconds (default: 700ms)
stream_edit_interval_ms: int = 700
# Opt-in privacy-safe runtime footer on the final reply (default: False).
# When True, a short trailing line (e.g. ``— gpt-4o · 4.2s``) reporting the
# model and turn latency is appended to the final reply only. Privacy-safe:
# never surfaces tool args, URLs, paths or message content. Unavailable
# fields are omitted rather than shown as placeholders.
footer: bool = False
# Intentional silence support
allow_silence: bool = False
@@ -185,6 +192,7 @@ class BotConfig:
"owner_user_id": "***" if self.owner_user_id else None,
"streaming": self.streaming,
"stream_edit_interval_ms": self.stream_edit_interval_ms,
"footer": self.footer,
"allow_silence": self.allow_silence,
"silence_token": self.silence_token,
"allow_bots": self.allow_bots,
+224 -251
View File
@@ -1002,14 +1002,6 @@ Respond with ONLY a valid JSON tool call in this format:
]):
return "idle_timeout"
# Format errors
if any(indicator in error_str for indicator in [
"validation error", "invalid format", "parse error",
"parsing error", "decode error", "malformed",
"invalid json", "schema error"
]):
return "format_error"
# Default fallback
return "unknown"
@@ -2039,98 +2031,6 @@ Respond with ONLY a valid JSON tool call in this format:
schemas.append(f"- {name}({', '.join(param_strs)})")
return '\n'.join(schemas) if schemas else "None"
def _param_accepts_string(self, function_name, param_name, tools) -> bool:
"""True when a tool declares this parameter as accepting a string.
Used to decide whether an argument whose value matches a previous tool's
name is a legitimate string or a weak model's way of referring to that
tool's result. A declared string parameter is left alone.
"""
try:
for tool in tools or []:
fn = tool.get("function") if isinstance(tool, dict) else None
if not fn or fn.get("name") != function_name:
continue
spec = (fn.get("parameters") or {}).get("properties", {}).get(param_name)
if not isinstance(spec, dict):
return False
return self._schema_accepts_string(spec)
except Exception: # noqa: BLE001 -- never break a tool call on a schema read
return False
return False
def _schema_accepts_string(self, spec) -> bool:
"""True when a JSON-Schema fragment can accept a string value.
Handles the shapes this repo's own generator emits: a bare
``{"type": "string"}``, a list type ``{"type": ["string", "null"]}``,
and the ``anyOf``/``oneOf``/``allOf`` unions produced for ``Optional[str]``
and ``Union`` parameters (``tools/schema.py``). Without the union case an
``Optional[str]`` argument was still treated as a result reference and
silently overwritten.
"""
if not isinstance(spec, dict):
return False
declared = spec.get("type")
if isinstance(declared, list):
if "string" in declared:
return True
elif declared == "string":
return True
# enum without an explicit type is string-typed in JSON Schema practice
if declared is None and isinstance(spec.get("enum"), list):
if any(isinstance(v, str) for v in spec["enum"]):
return True
for key in ("anyOf", "oneOf", "allOf"):
members = spec.get(key)
if isinstance(members, list) and any(
self._schema_accepts_string(m) for m in members):
return True
return False
def _force_tool_usage_message(self, response_text, tool_calls, formatted_tools,
iteration_count):
"""The nudge to send when a model ignored tools it should have used.
Returns the message content, or None when no nudge is warranted. Shared
so every response path applies the same policy -- this lived inline in
get_response only, so an agent that was awaited instead of called
silently lost a setting it had accepted.
"""
if not self._should_force_tool_usage(
response_text, tool_calls, formatted_tools, iteration_count):
return None
tool_names = self._get_tool_names_for_prompt(formatted_tools)
logging.debug(
f"[OLLAMA_RELIABILITY] Force tool usage triggered. Adding prompt for tools: {tool_names}")
return self.FORCE_TOOL_USAGE_PROMPT.format(tool_names=tool_names)
def _tool_repair_message(self, tool_calls, formatted_tools):
"""The correction to send when a model produced an invalid tool call.
Returns the message content and charges the repair budget, or None when
the calls are valid or the budget is spent. Shared for the same reason
as _force_tool_usage_message.
"""
repair_attempt_count = getattr(self, '_current_repair_count', 0)
max_tool_repairs = getattr(self, 'max_tool_repairs', 0)
if not (tool_calls and max_tool_repairs > 0
and repair_attempt_count < max_tool_repairs):
return None
validation_errors = [e for e in
(self._validate_tool_call(tc, formatted_tools) for tc in tool_calls)
if e]
if not validation_errors:
return None
error_msg = "; ".join(validation_errors)
tool_schemas = self._get_tool_schemas_for_prompt(formatted_tools)
logging.debug(
f"[OLLAMA_RELIABILITY] Tool call repair attempt "
f"{repair_attempt_count + 1}/{max_tool_repairs}: {error_msg}")
self._current_repair_count = repair_attempt_count + 1
return self.TOOL_CALL_REPAIR_PROMPT.format(
error=error_msg, tool_schemas=tool_schemas)
def _should_force_tool_usage(self, response_text: str, tool_calls: Optional[List], formatted_tools: Optional[List], iteration_count: int) -> bool:
"""
Determine if we should force tool usage based on settings and context.
@@ -6565,6 +6465,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
finish_reason = getattr(choice, "finish_reason", None)
msg = getattr(choice, "message", None)
refusal = getattr(msg, "refusal", None) if msg is not None else None
if not choices:
from .openai_client import OpenAIClient
finish_reason = OpenAIClient._responses_incomplete_finish_reason(response)
if finish_reason is None and not refusal:
return
from ..agent.run_outcome import classify_finish_reason
@@ -7128,7 +7031,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# (input_text / input_image); local image paths become
# data URLs. Plain-string content is passed through.
from .openai_client import OpenAIClient
item = dict(msg)
# Do not forward persisted metadata/provider extensions as
# Responses message properties.
item = {"role": role}
item["content"] = OpenAIClient._build_responses_content(
msg.get("content", "")
)
@@ -7256,6 +7161,15 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._track_token_usage(response, self._response_model_for_tracking(response))
return response
def _raise_responses_failure(self, error) -> None:
"""Surface a failed Responses request without accepting partial output."""
code = error.get("code") if isinstance(error, dict) else getattr(error, "code", None)
message = error.get("message") if isinstance(error, dict) else getattr(error, "message", None)
if self._last_stop_reason == "completed":
self._last_stop_reason = "error"
label = f"Responses API failed ({code})" if code else "Responses API failed"
raise LLMResponseError(f"{label}: {message or 'Provider returned a failed response.'}")
def _extract_from_responses_output(self, response) -> tuple:
"""
Parse a ``ResponsesAPIResponse`` into the same (text, tool_calls,
@@ -7267,11 +7181,16 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
Unlike Chat Completions, text and tool calls are *always* separate
items, so ``content`` is never null when text is present.
"""
status = response.get("status") if isinstance(response, dict) else getattr(response, "status", None)
if status == "failed":
error = response.get("error") if isinstance(response, dict) else getattr(response, "error", None)
self._raise_responses_failure(error)
self._record_finish_reason(response)
response_text = ""
tool_calls: List[Dict[str, Any]] = []
reasoning_content = None
output_items = getattr(response, 'output', None) or []
output_items = (response.get('output') if isinstance(response, dict) else getattr(response, 'output', None)) or []
for item in output_items:
# Handle both dict and object access
if isinstance(item, dict):
@@ -7329,6 +7248,34 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
return response_text, tool_calls if tool_calls else None, reasoning_content
@staticmethod
def _close_responses_stream(stream) -> None:
"""Close a stream or its LiteLLM transport without masking its error."""
try:
close = getattr(stream, "close", None)
if not callable(close):
close = getattr(getattr(stream, "response", None), "close", None)
if callable(close):
close()
except Exception:
logging.debug("Responses stream cleanup failed", exc_info=True)
@staticmethod
async def _aclose_responses_stream(stream) -> None:
"""Await transport cleanup on completion, failure or cancellation."""
try:
target = stream
close = getattr(target, "aclose", None) or getattr(target, "close", None)
if not callable(close):
target = getattr(stream, "response", None)
close = getattr(target, "aclose", None) or getattr(target, "close", None)
if callable(close):
result = close()
if inspect.isawaitable(result):
await result
except Exception:
logging.debug("Responses stream cleanup failed", exc_info=True)
def _stream_responses_api(
self,
params: Dict[str, Any],
@@ -7360,95 +7307,108 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if _emit:
from ..streaming.events import StreamEvent, StreamEventType
for event in litellm.responses(**params):
# Access event type — handle both object and dict
if isinstance(event, dict):
evt_type = event.get("type", "")
else:
evt_type = getattr(event, "type", "")
# ── Completed — capture the final response for usage accounting ──
if evt_type == "response.completed":
_final_response = (event.get("response") if isinstance(event, dict)
else getattr(event, "response", None))
# ── Text delta ──────────────────────────────────────────
if evt_type == "response.output_text.delta":
delta_text = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
response_text += delta_text
if _emit and delta_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=delta_text,
is_reasoning=False,
))
# ── Reasoning delta ─────────────────────────────────────
elif evt_type == "response.reasoning_summary_text.delta":
delta_text = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
if _emit and delta_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=delta_text,
is_reasoning=True,
))
# ── Function call arguments delta ───────────────────────
elif evt_type == "response.function_call_arguments.delta":
idx = (event.get("output_index", 0) if isinstance(event, dict)
else getattr(event, "output_index", 0))
delta_args = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
if idx not in _pending_tools:
item_id = (event.get("item_id", f"tool_{idx}") if isinstance(event, dict)
else getattr(event, "item_id", f"tool_{idx}"))
_pending_tools[idx] = {
"id": item_id,
"name": "",
"arguments": "",
}
_pending_tools[idx]["arguments"] += delta_args
# ── Output item done — finalise tool call metadata ──────
elif evt_type == "response.output_item.done":
response_stream = litellm.responses(**params)
try:
for event in response_stream:
# Access event type — handle both object and dict
if isinstance(event, dict):
item = event.get("item", {})
idx = event.get("output_index", 0)
evt_type = event.get("type", "")
else:
item = getattr(event, "item", None) or {}
idx = getattr(event, "output_index", 0)
evt_type = getattr(event, "type", "")
if isinstance(item, dict):
i_type = item.get("type", "")
else:
i_type = getattr(item, "type", "")
if evt_type == "response.failed":
failed_response = event.get("response") if isinstance(event, dict) else getattr(event, "response", None)
error = failed_response.get("error") if isinstance(failed_response, dict) else getattr(failed_response, "error", None)
self._raise_responses_failure(error)
if evt_type == "error":
self._raise_responses_failure(event)
if i_type == "function_call":
if isinstance(item, dict):
call_id = item.get("call_id", item.get("id", f"tool_{idx}"))
fn_name = item.get("name", "")
fn_args = item.get("arguments", "")
else:
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{idx}")
fn_name = getattr(item, "name", "")
fn_args = getattr(item, "arguments", "")
# ── Completed — capture the final response for usage accounting ──
if evt_type in ("response.completed", "response.incomplete"):
_final_response = (event.get("response") if isinstance(event, dict)
else getattr(event, "response", None))
self._record_finish_reason(_final_response)
if idx in _pending_tools:
_pending_tools[idx]["id"] = call_id
_pending_tools[idx]["name"] = fn_name
# Prefer the accumulated delta args if available, else use final
if not _pending_tools[idx]["arguments"]:
_pending_tools[idx]["arguments"] = fn_args
else:
# ── Text delta ──────────────────────────────────────────
if evt_type == "response.output_text.delta":
delta_text = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
response_text += delta_text
if _emit and delta_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=delta_text,
is_reasoning=False,
))
# ── Reasoning delta ─────────────────────────────────────
elif evt_type == "response.reasoning_summary_text.delta":
delta_text = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
if _emit and delta_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=delta_text,
is_reasoning=True,
))
# ── Function call arguments delta ───────────────────────
elif evt_type == "response.function_call_arguments.delta":
idx = (event.get("output_index", 0) if isinstance(event, dict)
else getattr(event, "output_index", 0))
delta_args = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
if idx not in _pending_tools:
item_id = (event.get("item_id", f"tool_{idx}") if isinstance(event, dict)
else getattr(event, "item_id", f"tool_{idx}"))
_pending_tools[idx] = {
"id": call_id,
"name": fn_name,
"arguments": fn_args,
"id": item_id,
"name": "",
"arguments": "",
}
_pending_tools[idx]["arguments"] += delta_args
# ── Output item done — finalise tool call metadata ──────
elif evt_type == "response.output_item.done":
if isinstance(event, dict):
item = event.get("item", {})
idx = event.get("output_index", 0)
else:
item = getattr(event, "item", None) or {}
idx = getattr(event, "output_index", 0)
if isinstance(item, dict):
i_type = item.get("type", "")
else:
i_type = getattr(item, "type", "")
if i_type == "function_call":
if isinstance(item, dict):
call_id = item.get("call_id", item.get("id", f"tool_{idx}"))
fn_name = item.get("name", "")
fn_args = item.get("arguments", "")
else:
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{idx}")
fn_name = getattr(item, "name", "")
fn_args = getattr(item, "arguments", "")
if idx in _pending_tools:
_pending_tools[idx]["id"] = call_id
_pending_tools[idx]["name"] = fn_name
# Prefer the accumulated delta args if available, else use final
if not _pending_tools[idx]["arguments"]:
_pending_tools[idx]["arguments"] = fn_args
else:
_pending_tools[idx] = {
"id": call_id,
"name": fn_name,
"arguments": fn_args,
}
finally:
self._close_responses_stream(response_stream)
# ── Build tool_calls list in output_index order ─────────────
tool_calls: List[Dict[str, Any]] = []
@@ -7491,80 +7451,93 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if _emit:
from ..streaming.events import StreamEvent, StreamEventType
async for event in await litellm.aresponses(**params):
if isinstance(event, dict):
evt_type = event.get("type", "")
else:
evt_type = getattr(event, "type", "")
if evt_type == "response.completed":
_final_response = (event.get("response") if isinstance(event, dict)
else getattr(event, "response", None))
if evt_type == "response.output_text.delta":
delta_text = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
response_text += delta_text
if _emit and delta_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=delta_text,
is_reasoning=False,
))
elif evt_type == "response.reasoning_summary_text.delta":
delta_text = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
if _emit and delta_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=delta_text,
is_reasoning=True,
))
elif evt_type == "response.function_call_arguments.delta":
idx = (event.get("output_index", 0) if isinstance(event, dict)
else getattr(event, "output_index", 0))
delta_args = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
if idx not in _pending_tools:
item_id = (event.get("item_id", f"tool_{idx}") if isinstance(event, dict)
else getattr(event, "item_id", f"tool_{idx}"))
_pending_tools[idx] = {"id": item_id, "name": "", "arguments": ""}
_pending_tools[idx]["arguments"] += delta_args
elif evt_type == "response.output_item.done":
response_stream = await litellm.aresponses(**params)
try:
async for event in response_stream:
if isinstance(event, dict):
item = event.get("item", {})
idx = event.get("output_index", 0)
evt_type = event.get("type", "")
else:
item = getattr(event, "item", None) or {}
idx = getattr(event, "output_index", 0)
evt_type = getattr(event, "type", "")
if isinstance(item, dict):
i_type = item.get("type", "")
else:
i_type = getattr(item, "type", "")
if evt_type == "response.failed":
failed_response = event.get("response") if isinstance(event, dict) else getattr(event, "response", None)
error = failed_response.get("error") if isinstance(failed_response, dict) else getattr(failed_response, "error", None)
self._raise_responses_failure(error)
if evt_type == "error":
self._raise_responses_failure(event)
if evt_type in ("response.completed", "response.incomplete"):
_final_response = (event.get("response") if isinstance(event, dict)
else getattr(event, "response", None))
self._record_finish_reason(_final_response)
if evt_type == "response.output_text.delta":
delta_text = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
response_text += delta_text
if _emit and delta_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=delta_text,
is_reasoning=False,
))
elif evt_type == "response.reasoning_summary_text.delta":
delta_text = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
if _emit and delta_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=delta_text,
is_reasoning=True,
))
elif evt_type == "response.function_call_arguments.delta":
idx = (event.get("output_index", 0) if isinstance(event, dict)
else getattr(event, "output_index", 0))
delta_args = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
if idx not in _pending_tools:
item_id = (event.get("item_id", f"tool_{idx}") if isinstance(event, dict)
else getattr(event, "item_id", f"tool_{idx}"))
_pending_tools[idx] = {"id": item_id, "name": "", "arguments": ""}
_pending_tools[idx]["arguments"] += delta_args
elif evt_type == "response.output_item.done":
if isinstance(event, dict):
item = event.get("item", {})
idx = event.get("output_index", 0)
else:
item = getattr(event, "item", None) or {}
idx = getattr(event, "output_index", 0)
if i_type == "function_call":
if isinstance(item, dict):
call_id = item.get("call_id", item.get("id", f"tool_{idx}"))
fn_name = item.get("name", "")
fn_args = item.get("arguments", "")
i_type = item.get("type", "")
else:
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{idx}")
fn_name = getattr(item, "name", "")
fn_args = getattr(item, "arguments", "")
i_type = getattr(item, "type", "")
if idx in _pending_tools:
_pending_tools[idx]["id"] = call_id
_pending_tools[idx]["name"] = fn_name
if not _pending_tools[idx]["arguments"]:
_pending_tools[idx]["arguments"] = fn_args
else:
_pending_tools[idx] = {"id": call_id, "name": fn_name, "arguments": fn_args}
if i_type == "function_call":
if isinstance(item, dict):
call_id = item.get("call_id", item.get("id", f"tool_{idx}"))
fn_name = item.get("name", "")
fn_args = item.get("arguments", "")
else:
call_id = getattr(item, "call_id", None) or getattr(item, "id", f"tool_{idx}")
fn_name = getattr(item, "name", "")
fn_args = getattr(item, "arguments", "")
if idx in _pending_tools:
_pending_tools[idx]["id"] = call_id
_pending_tools[idx]["name"] = fn_name
if not _pending_tools[idx]["arguments"]:
_pending_tools[idx]["arguments"] = fn_args
else:
_pending_tools[idx] = {"id": call_id, "name": fn_name, "arguments": fn_args}
finally:
await self._aclose_responses_stream(response_stream)
tool_calls: List[Dict[str, Any]] = []
for idx in sorted(_pending_tools.keys()):
@@ -781,7 +781,9 @@ class OpenAIClient:
"output": msg.get("content", ""),
})
else:
item = dict(msg)
# Persisted history may carry local metadata/provider fields.
# Responses message items accept role/content, not that record.
item = {"role": role}
item["content"] = self._build_responses_content(
msg.get("content", "")
)
@@ -896,16 +898,34 @@ class OpenAIClient:
responses_content.append(part)
return responses_content
@staticmethod
def _responses_incomplete_finish_reason(response) -> Optional[str]:
"""Map known incomplete reasons to existing Chat Completions outcomes."""
status = response.get("status") if isinstance(response, dict) else getattr(response, "status", None)
if status != "incomplete":
return None
details = response.get("incomplete_details") if isinstance(response, dict) else getattr(response, "incomplete_details", None)
reason = details.get("reason") if isinstance(details, dict) else getattr(details, "reason", None)
return {"max_output_tokens": "length", "content_filter": "content_filter"}.get(reason)
def _responses_to_chat_completion(self, response) -> ChatCompletion:
"""
Wrap a Responses API response into a ChatCompletion dataclass
so that all downstream code (tool loop, display, callbacks)
works without modification.
"""
status = response.get("status") if isinstance(response, dict) else getattr(response, "status", None)
if status == "failed":
from .llm import LLMResponseError
error = response.get("error") if isinstance(response, dict) else getattr(response, "error", None)
code = error.get("code") if isinstance(error, dict) else getattr(error, "code", None)
message = error.get("message") if isinstance(error, dict) else getattr(error, "message", None)
label = f"Responses API failed ({code})" if code else "Responses API failed"
raise LLMResponseError(f"{label}: {message or 'Provider returned a failed response.'}")
response_text = ""
tool_calls_list: List[ToolCall] = []
output_items = getattr(response, 'output', None) or []
output_items = (response.get('output') if isinstance(response, dict) else getattr(response, 'output', None)) or []
for item in output_items:
item_type = getattr(item, "type", "") if not isinstance(item, dict) else item.get("type", "")
@@ -940,13 +960,16 @@ class OpenAIClient:
))
# Build usage
raw_usage = getattr(response, 'usage', None)
raw_usage = response.get('usage') if isinstance(response, dict) else getattr(response, 'usage', None)
usage = None
if raw_usage:
def usage_value(name):
return raw_usage.get(name, 0) if isinstance(raw_usage, dict) else getattr(raw_usage, name, 0)
usage = CompletionUsage(
prompt_tokens=getattr(raw_usage, 'input_tokens', 0),
completion_tokens=getattr(raw_usage, 'output_tokens', 0),
total_tokens=getattr(raw_usage, 'total_tokens', 0),
prompt_tokens=usage_value('input_tokens'),
completion_tokens=usage_value('output_tokens'),
total_tokens=usage_value('total_tokens'),
)
message = ChatCompletionMessage(
@@ -955,7 +978,7 @@ class OpenAIClient:
tool_calls=tool_calls_list if tool_calls_list else None,
)
choice = Choice(
finish_reason="tool_calls" if tool_calls_list else "stop",
finish_reason=self._responses_incomplete_finish_reason(response) or ("tool_calls" if tool_calls_list else "stop"),
index=0,
message=message,
)
@@ -1053,6 +1076,7 @@ class OpenAIClient:
Returns:
ChatCompletion object or None if error
"""
raw = None
# Lazy import StreamEvent types only when needed
_emit = emit_events and stream_callback is not None
if _emit:
@@ -1112,6 +1136,9 @@ class OpenAIClient:
except (FileNotFoundError, ValueError):
raise
except Exception as e:
status = raw.get("status") if isinstance(raw, dict) else getattr(raw, "status", None)
if status == "failed":
raise
self.logger.warning(f"Responses API streaming failed, falling back: {e}")
# Fall through to Chat Completions streaming
@@ -1282,6 +1309,9 @@ class OpenAIClient:
return final_response
except Exception as e:
status = raw.get("status") if isinstance(raw, dict) else getattr(raw, "status", None)
if status == "failed":
raise
self.logger.error(f"Error in stream processing: {e}")
return None
@@ -1318,6 +1348,7 @@ class OpenAIClient:
Returns:
ChatCompletion object or None if error
"""
raw = None
# Lazy import StreamEvent types only when needed
_emit = emit_events and stream_callback is not None
if _emit:
@@ -1386,6 +1417,9 @@ class OpenAIClient:
except (FileNotFoundError, ValueError):
raise
except Exception as e:
status = raw.get("status") if isinstance(raw, dict) else getattr(raw, "status", None)
if status == "failed":
raise
self.logger.warning(f"Responses API async streaming failed, falling back: {e}")
# Fall through to Chat Completions streaming
@@ -1548,6 +1582,9 @@ class OpenAIClient:
return final_response
except Exception as e:
status = raw.get("status") if isinstance(raw, dict) else getattr(raw, "status", None)
if status == "failed":
raise
self.logger.error(f"Error in async stream processing: {e}")
return None
@@ -1652,6 +1689,7 @@ class OpenAIClient:
"""
# ── Responses API path (non-streaming only) ────────────────────
if not stream and self._use_responses_api(model):
raw = None
try:
resp_params = self._build_responses_input(
messages, model, temperature, tools,
@@ -1662,6 +1700,9 @@ class OpenAIClient:
except (FileNotFoundError, ValueError):
raise
except Exception as e:
status = raw.get("status") if isinstance(raw, dict) else getattr(raw, "status", None)
if status == "failed":
raise
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
# Fall through to Chat Completions
@@ -1716,6 +1757,7 @@ class OpenAIClient:
"""
# ── Responses API path (non-streaming only) ────────────────────
if not stream and self._use_responses_api(model):
raw = None
try:
resp_params = self._build_responses_input(
messages, model, temperature, tools,
@@ -1726,6 +1768,9 @@ class OpenAIClient:
except (FileNotFoundError, ValueError):
raise
except Exception as e:
status = raw.get("status") if isinstance(raw, dict) else getattr(raw, "status", None)
if status == "failed":
raise
self.logger.warning(f"Responses API failed, falling back to Chat Completions: {e}")
# Fall through to Chat Completions
@@ -6,6 +6,7 @@ No dependencies required - stores data as plain Python lists.
"""
import threading
from copy import deepcopy
from typing import Any, Dict, List, Optional
@@ -15,6 +16,8 @@ class InMemoryAdapter:
Stores short-term and long-term memories as plain Python lists.
Data is not persisted across process restarts.
Metadata must support deepcopy; writes and reads own independent copies so
caller edits cannot change stored records outside the adapter's lock.
Usage:
adapter = InMemoryAdapter()
@@ -48,7 +51,7 @@ class InMemoryAdapter:
"id": str(self._next_id),
"text": text,
"type": "short",
"metadata": metadata,
"metadata": deepcopy(metadata),
}
self._next_id += 1
self._data.append(entry)
@@ -64,7 +67,7 @@ class InMemoryAdapter:
for e in self._data
if e["type"] == "short" and query.lower() in e["text"].lower()
]
return results[:limit]
return deepcopy(results[:limit])
def store_long_term(
self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs
@@ -74,7 +77,7 @@ class InMemoryAdapter:
"id": str(self._next_id),
"text": text,
"type": "long",
"metadata": metadata,
"metadata": deepcopy(metadata),
}
self._next_id += 1
self._data.append(entry)
@@ -90,7 +93,7 @@ class InMemoryAdapter:
for e in self._data
if e["type"] == "long" and query.lower() in e["text"].lower()
]
return results[:limit]
return deepcopy(results[:limit])
def delete_memory(self, memory_id: str, tier: Optional[str] = None, **kwargs) -> bool:
"""Delete a memory by ID. Returns True if an entry was removed.
@@ -123,5 +126,8 @@ class InMemoryAdapter:
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
with self._lock:
# Return defensive copy to prevent external mutation of internal state
return [dict(entry) for entry in self._data]
# Entries are owned on insertion and never mutated afterward.
# Keep their membership snapshot under lock, then copy nested data
# without blocking stores, deletes, or eviction.
snapshot = list(self._data)
return deepcopy(snapshot)
@@ -19,6 +19,16 @@ from ..protocols import MemoryProtocol
logger = get_logger(__name__)
def _metadata_user_id(raw):
"""Match the existing Python JSON reader, including non-finite values."""
try:
metadata = json.loads(raw) if raw else {}
return str(metadata["user_id"]) if isinstance(metadata, dict) and metadata.get("user_id") is not None else None
except (TypeError, ValueError):
return None
class SqliteMemoryAdapter:
"""
SQLite-based memory adapter implementing MemoryProtocol.
@@ -73,12 +83,13 @@ class SqliteMemoryAdapter:
def _get_stm_conn(self):
"""Get thread-local short-term memory connection."""
if not hasattr(self._local, 'stm_conn'):
if getattr(self._local, 'stm_conn', None) is None:
self._local.stm_conn = sqlite3.connect(
self.short_db,
check_same_thread=False,
timeout=10.0
)
self._local.stm_conn.create_function("memory_user_id", 1, _metadata_user_id, deterministic=True)
self._local.stm_conn.execute("""
CREATE TABLE IF NOT EXISTS short_term_memory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -97,12 +108,13 @@ class SqliteMemoryAdapter:
def _get_ltm_conn(self):
"""Get thread-local long-term memory connection."""
if not hasattr(self._local, 'ltm_conn'):
if getattr(self._local, 'ltm_conn', None) is None:
self._local.ltm_conn = sqlite3.connect(
self.long_db,
check_same_thread=False,
timeout=10.0
)
self._local.ltm_conn.create_function("memory_user_id", 1, _metadata_user_id, deterministic=True)
self._local.ltm_conn.execute("""
CREATE TABLE IF NOT EXISTS long_term_memory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -143,10 +155,13 @@ class SqliteMemoryAdapter:
) -> List[Dict[str, Any]]:
"""Search short-term memory."""
conn = self._get_stm_conn()
user_id = kwargs.get("user_id")
user_filter = " AND memory_user_id(metadata) = ?" if user_id is not None else ""
params = (f"%{query}%", str(user_id), limit) if user_id is not None else (f"%{query}%", limit)
cursor = conn.execute(
"SELECT id, content, metadata, timestamp FROM short_term_memory "
"WHERE content LIKE ? ORDER BY timestamp DESC LIMIT ?",
(f"%{query}%", limit)
"WHERE content LIKE ?" + user_filter + " ORDER BY timestamp DESC LIMIT ?",
params
)
results = []
@@ -184,10 +199,13 @@ class SqliteMemoryAdapter:
) -> List[Dict[str, Any]]:
"""Search long-term memory."""
conn = self._get_ltm_conn()
user_id = kwargs.get("user_id")
user_filter = " AND memory_user_id(metadata) = ?" if user_id is not None else ""
params = (f"%{query}%", str(user_id), limit) if user_id is not None else (f"%{query}%", limit)
cursor = conn.execute(
"SELECT id, content, metadata, timestamp FROM long_term_memory "
"WHERE content LIKE ? ORDER BY timestamp DESC LIMIT ?",
(f"%{query}%", limit)
"WHERE content LIKE ?" + user_filter + " ORDER BY timestamp DESC LIMIT ?",
params
)
results = []
@@ -252,8 +270,10 @@ class SqliteMemoryAdapter:
def get_all_memories(self, **kwargs) -> List[Dict[str, Any]]:
"""Get all memories from both short-term and long-term."""
short_memories = self.search_short_term("", limit=1000)
long_memories = self.search_long_term("", limit=1000)
# SQLite's negative LIMIT means no upper bound. Enumeration must return
# every row; ordinary searches retain their default/explicit limits.
short_memories = self.search_short_term("", limit=-1)
long_memories = self.search_long_term("", limit=-1)
# Mark memory types
for memory in short_memories:
@@ -272,4 +292,30 @@ class SqliteMemoryAdapter:
conn.close()
except Exception:
pass
self._all_connections.clear()
self._all_connections.clear()
def close_thread_connections(self):
"""Close only the calling thread's connections.
Leaves the shared adapter and other threads' connections intact so the
adapter can be reused; the calling thread lazily reopens its own
connections on next access.
A connection is only removed from the registry and cleared from
thread-local storage after it closes successfully. If ``close()`` raises,
the connection stays tracked so a later ``close_connections()`` /
``close_thread_connections()`` can retry it, and the failure is logged
rather than silently swallowed.
"""
for attr in ("stm_conn", "ltm_conn"):
conn = getattr(self._local, attr, None)
if conn is None:
continue
try:
conn.close()
except Exception as e:
logger.warning(f"Failed to close {attr}; keeping it tracked for retry: {e}")
continue
with self._connection_lock:
self._all_connections.discard(conn)
setattr(self._local, attr, None)
@@ -179,6 +179,11 @@ class DocsManager:
self._docs[key] = doc
self._log(f"Loaded doc: {key}")
@staticmethod
def _effective_priority(priority: int, scope: str) -> int:
"""Apply the same global-document offset on creation and reload."""
return priority - 1000 if scope == "global" else priority
def _load_all_docs(self):
"""Load all docs from global and workspace directories."""
self._docs.clear()
@@ -188,7 +193,7 @@ class DocsManager:
self._load_docs_from_dir(self.global_docs_path, "global")
for key, doc in self._docs.items():
if key.startswith("global:"):
doc.priority = doc.priority - 1000 # Lower priority for global
doc.priority = self._effective_priority(doc.priority, "global")
# 2. Load workspace docs
workspace_docs_dir = self.workspace_path / self.DOCS_DIR_NAME.replace("/", os.sep)
@@ -355,8 +360,8 @@ class DocsManager:
frontmatter_lines = ["---"]
if description:
frontmatter_lines.append(f'description: "{description}"')
if priority != 0:
frontmatter_lines.append(f"priority: {priority}")
# Persist explicit zero instead of reloading the workspace default (100).
frontmatter_lines.append(f"priority: {priority}")
if tags:
tags_str = ", ".join(f'"{t}"' for t in tags)
frontmatter_lines.append(f"tags: [{tags_str}]")
@@ -372,7 +377,7 @@ class DocsManager:
name=name,
content=content,
description=description,
priority=priority,
priority=self._effective_priority(priority, scope),
tags=tags or [],
file_path=str(file_path)
)
@@ -266,11 +266,12 @@ class FileMemory:
return True
except (IOError, OSError) as e:
self._log(f"Error writing {filepath}: {e}", logging.ERROR)
return False
finally:
try:
tmp_path.unlink()
except OSError:
pass
return False
def commit_memory_batch(
self,
@@ -2532,16 +2532,31 @@ class Memory(SearchMixin, MemoryCoreMixin):
# Other active threads should manage their own connections
# Only clear full registry in __del__ or explicit shutdown
# Close memory adapter if it exists (protocol-driven path)
# Close memory adapter if it exists (protocol-driven path).
#
# SQLite is thread-scoped by contract (#1562/#1563): close only the
# calling thread's adapter connections and KEEP the shared adapter so
# other threads and subsequent same-thread searches keep using the
# adapter's tables. Tearing it down here would make searches fall back
# to legacy SQL against short_mem/long_mem (#5486).
if hasattr(self, 'memory_adapter') and self.memory_adapter:
try:
if hasattr(self.memory_adapter, 'close') and callable(self.memory_adapter.close):
self.memory_adapter.close()
logger.debug("Memory adapter closed successfully")
except Exception as e:
logger.warning(f"Error closing memory adapter: {e}")
finally:
self.memory_adapter = None
if isinstance(self.memory_adapter, SqliteMemoryAdapter):
try:
if hasattr(self.memory_adapter, 'close_thread_connections'):
self.memory_adapter.close_thread_connections()
logger.debug("SQLite adapter thread connections closed")
except Exception as e:
logger.warning(f"Error closing SQLite adapter thread connections: {e}")
# Intentionally preserve self.memory_adapter for reuse.
else:
try:
if hasattr(self.memory_adapter, 'close') and callable(self.memory_adapter.close):
self.memory_adapter.close()
logger.debug("Memory adapter closed successfully")
except Exception as e:
logger.warning(f"Error closing memory adapter: {e}")
finally:
self.memory_adapter = None
# Close MongoDB client if it exists (legacy path)
if hasattr(self, 'mongo_client') and self.mongo_client:
@@ -1361,3 +1361,15 @@ def get_plugin_manager() -> PluginManager:
if _default_manager is None:
_default_manager = PluginManager()
return _default_manager
def has_registered_plugins() -> bool:
"""Return whether the global manager has any plugins registered.
Cheap, allocation-free fast path for the hot Agent-init merge calls: when
the singleton has not been created or holds no plugins (the common case),
callers can skip constructing/scanning the manager entirely. Does NOT
create the singleton as a side effect.
"""
manager = _default_manager
return manager is not None and bool(manager._plugins)
@@ -13,6 +13,7 @@ import uuid
import json
import time
import logging
from copy import deepcopy
from typing import Any, Dict, List, Optional, TYPE_CHECKING
logger = logging.getLogger(__name__)
@@ -502,6 +503,10 @@ class Session:
) -> bool:
"""Persist one sub-agent transcript onto this session's own record.
Built-in stores merge the entry under their session file lock. Older
custom stores retain the bounded read/write verification fallback below;
that fallback cannot guarantee atomic preservation of concurrent entries.
The store replaces the whole ``AGENT_HISTORY_KEY`` map per write, so a
naive read-then-replace would let a concurrent writer (another agent
under the same parent) clobber this agent's entry, or vice versa. We
@@ -509,6 +514,14 @@ class Session:
*other* key changed underneath us, retrying a bounded number of times.
Legacy per-agent records are always merged in (they stay on disk).
"""
merge_map = getattr(session_store, "merge_session_metadata_map", None)
if callable(merge_map):
# Built-in stores merge inside their locked read/modify/write path.
# A post-write comparison cannot detect entries lost to a stale map.
return merge_map(
self.session_id, AGENT_HISTORY_KEY, {agent_key: messages},
defaults=self._legacy_agent_histories(session_store),
)
update_meta = getattr(session_store, "update_session_metadata", None)
if not callable(update_meta):
return False
@@ -564,6 +577,7 @@ class Session:
{
"role": msg.get("role", "user"),
"content": msg.get("content", ""),
**deepcopy(msg),
}
for msg in chat_history
if isinstance(msg, dict)
@@ -59,17 +59,22 @@ def _progress_section(checkpoint: Optional[Dict[str, Any]]) -> List[str]:
if not isinstance(checkpoint, dict):
return []
completed = checkpoint.get("completed_steps")
total = checkpoint.get("total_steps") or checkpoint.get("step_count")
last = checkpoint.get("last_step") or checkpoint.get("current_step")
if completed is None and last is None:
return []
if total:
head = f"PROGRESS: {completed or 0} of {total} workflow steps done"
else:
head = f"PROGRESS: {completed or 0} workflow steps done"
if last:
head += f"; last completed: {last}"
return [head]
total = checkpoint.get("total_steps")
if total is None:
total = checkpoint.get("step_count")
last = checkpoint.get("last_step")
current = checkpoint.get("current_step")
parts: List[str] = []
if completed is not None:
if total is not None:
parts.append(f"{completed} of {total} workflow steps done")
else:
parts.append(f"{completed} workflow steps done")
if last is not None and last != "":
parts.append(f"last completed: {last}")
if current is not None and current != "":
parts.append(f"current step: {current}")
return ["PROGRESS: " + "; ".join(parts)] if parts else []
def _recent_actions(recap_text: str) -> List[str]:
@@ -34,7 +34,7 @@ from typing import Any, Dict, List, Optional
from praisonaiagents._logging import get_logger
from .store import DefaultSessionStore, SessionData
from .store import DefaultSessionStore, FileLock, SessionData
logger = get_logger(__name__)
@@ -370,6 +370,27 @@ class SqliteSessionStore(DefaultSessionStore):
self._index_session(session)
return ok
def _save_imported_session(self, session: SessionData, *, overwrite: bool = True) -> bool:
"""Refresh recall and routing after a verbatim portable restore."""
ok = super()._save_imported_session(session, overwrite=overwrite)
if ok:
try:
filepath = self._get_session_path(session.session_id)
with FileLock(filepath, self.lock_timeout):
# Read-only cache fallback is unsafe after an overwrite:
# it can restore routing fields removed by the import.
fresh = self._load_session_from_disk(session.session_id, filepath)
self._reingest_spill(session.session_id, fresh)
with self._lock:
self._cache[session.session_id] = fresh
self._index_session(fresh)
except Exception as exc:
# The JSON write succeeded, but the old index is no longer a
# trustworthy view. Fail closed until a later refresh/rebuild.
self._deindex_session(session.session_id)
logger.debug("Post-import index refresh failed for %s: %s", session.session_id, exc)
return ok
def _modify_session_locked(self, session_id, mutator, **kwargs) -> bool:
"""Refresh the index after any locked read-modify-write.
@@ -158,7 +158,9 @@ class SqliteTranscriptStore(DefaultSessionStore):
if not session_dir or not os.path.isdir(session_dir):
return
try:
filenames = [f for f in os.listdir(session_dir) if f.endswith(".json")]
filenames = sorted(
f for f in os.listdir(session_dir) if f.endswith(".json")
)
except OSError:
return
if not filenames:
@@ -175,7 +177,7 @@ class SqliteTranscriptStore(DefaultSessionStore):
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError, TypeError):
except (OSError, UnicodeDecodeError, json.JSONDecodeError, TypeError):
continue
if not isinstance(data, dict):
continue
@@ -1622,6 +1622,27 @@ class DefaultSessionStore:
session_id, _apply, error_label="update session metadata"
)
def merge_session_metadata_map(
self, session_id: str, key: str, updates: Dict[str, Any],
*, defaults: Optional[Dict[str, Any]] = None,
) -> bool:
"""Merge nested metadata entries using the freshest map under FileLock.
Defaults fill missing entries; persisted entries win over defaults,
and the supplied updates win over both.
"""
def _apply(session: SessionData) -> None:
merged = dict(defaults or {})
current = session.metadata.get(key)
if isinstance(current, dict):
merged.update(current)
merged.update(updates)
session.metadata[key] = merged
return self._modify_session_locked(
session_id, _apply, error_label="merge session metadata map"
)
def rename_session(self, session_id: str, title: str) -> bool:
"""Give a session a human-readable title (Issue #3737).
@@ -2306,7 +2327,7 @@ class DefaultSessionStore:
out.append(data)
return out
def _save_imported_session(self, session: SessionData) -> bool:
def _save_imported_session(self, session: SessionData, *, overwrite: bool = True) -> bool:
"""Persist a restored session verbatim (no retention/window applied).
Mirrors ``_save_session`` (timestamp + atomic, file-locked write) but
@@ -2317,6 +2338,10 @@ class DefaultSessionStore:
filepath = self._get_session_path(session.session_id)
session.updated_at = datetime.now(timezone.utc).isoformat()
with FileLock(filepath, self.lock_timeout):
# The initial import check can race with a peer creating this file.
# Check again inside the same lock that protects the replacement.
if not overwrite and os.path.exists(filepath):
raise FileExistsError(filepath)
if not self._atomic_write_json(filepath, session.to_dict()):
logger.error(f"Failed to save imported session {session.session_id}")
return False
@@ -2416,7 +2441,7 @@ class DefaultSessionStore:
# Persist the imported record verbatim: an import is a restore,
# so the destination's retention/active_window must not truncate
# or compact a valid larger export before it lands on disk.
if not self._save_imported_session(session):
if not self._save_imported_session(session, overwrite=overwrite):
report.skipped.append(
{"session_id": session_id, "reason": "write failed"}
)
@@ -2424,6 +2449,10 @@ class DefaultSessionStore:
with self._lock:
self._cache[session_id] = session
report.imported += 1
except FileExistsError:
report.skipped.append(
{"session_id": session_id, "reason": "already exists (use overwrite)"}
)
except Exception as e: # pragma: no cover - defensive; one bad record
report.skipped.append(
{"session_id": session_id, "reason": f"import error: {e}"}
@@ -66,6 +66,9 @@ def generate_title(
... )
>>> # Returns something like: "Python Code Debugging Help"
"""
if max_length <= 0:
return ""
# Fallback title from user message if generation fails
fallback_title = _create_fallback_title(user_msg, max_length)
@@ -119,6 +122,9 @@ async def generate_title_async(
Returns:
Generated title string, or fallback based on user message
"""
if max_length <= 0:
return ""
fallback_title = _create_fallback_title(user_msg, max_length)
try:
@@ -152,8 +158,7 @@ Return ONLY the title text, no quotes, no explanation."""
title = title.replace('\n', ' ').replace('\r', '')
# Truncate if too long
if len(title) > max_length:
title = title[:max_length-3] + "..."
title = _truncate_title(title, max_length)
# Return if non-empty
if title and len(title.strip()) > 0:
@@ -176,6 +181,17 @@ Return ONLY the title text, no quotes, no explanation."""
return fallback_title
def _truncate_title(title: str, max_length: int) -> str:
"""Apply the character cap without using negative ellipsis slices."""
if max_length <= 0:
return ""
if len(title) <= max_length:
return title
if max_length <= 3:
return title[:max_length]
return title[:max_length - 3] + "..."
def _create_fallback_title(user_msg: str, max_length: int) -> str:
"""Create a fallback title from the user message.
@@ -187,7 +203,7 @@ def _create_fallback_title(user_msg: str, max_length: int) -> str:
Simple title based on user message
"""
if not user_msg or not user_msg.strip():
return "Chat Session"
return _truncate_title("Chat Session", max_length)
# Clean up the message
clean_msg = user_msg.strip()
@@ -200,8 +216,4 @@ def _create_fallback_title(user_msg: str, max_length: int) -> str:
if '.' in clean_msg:
clean_msg = clean_msg.split('.')[0]
# Truncate if too long
if len(clean_msg) > max_length:
clean_msg = clean_msg[:max_length-3] + "..."
return clean_msg if clean_msg else "Chat Session"
return _truncate_title(clean_msg or "Chat Session", max_length)
@@ -69,6 +69,17 @@ class FileBackend:
# Sanitize key for filesystem
safe_key = "".join(c if c.isalnum() or c in "-_" else "_" for c in key)
return self.storage_dir / f"{safe_key}{self.suffix}"
def _path_to_key(self, file_path: Path) -> Optional[str]:
"""Recognize records using the full configured suffix."""
if not file_path.is_file():
return None
if self.suffix:
if file_path.name.endswith(self.suffix):
return file_path.name[:-len(self.suffix)]
return None
# Preserve the existing no-extension selection for an empty suffix.
return file_path.name if not file_path.suffix else None
def save(self, key: str, data: Dict[str, Any]) -> None:
"""Save data with the given key."""
@@ -84,11 +95,11 @@ class FileBackend:
delete=False,
suffix=".tmp"
) as f:
temp_path = f.name
if self.pretty:
json.dump(data, f, indent=2, default=str, ensure_ascii=False)
else:
json.dump(data, f, default=str, ensure_ascii=False)
temp_path = f.name
os.replace(temp_path, file_path)
except Exception as e:
@@ -110,7 +121,7 @@ class FileBackend:
try:
with open(file_path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError) as e:
except (json.JSONDecodeError, UnicodeDecodeError, IOError) as e:
logger.warning(f"Failed to load {key}: {e}")
return None
@@ -133,8 +144,8 @@ class FileBackend:
keys = []
for file_path in self.storage_dir.iterdir():
if file_path.is_file() and file_path.suffix == self.suffix:
key = file_path.stem
key = self._path_to_key(file_path)
if key is not None:
if not prefix or key.startswith(prefix):
keys.append(key)
@@ -149,7 +160,7 @@ class FileBackend:
count = 0
with self._lock:
for file_path in self.storage_dir.iterdir():
if file_path.is_file() and file_path.suffix == self.suffix:
if self._path_to_key(file_path) is not None:
try:
file_path.unlink()
count += 1
@@ -191,13 +202,21 @@ class SQLiteBackend:
if not isinstance(table_name, str) or not _re.match(r'^[a-zA-Z0-9_]+$', table_name):
raise ValueError("table_name must contain only alphanumeric characters and underscores")
self.table_name = table_name
# Quote identifiers so validated names that are SQL keywords or start
# with a digit (e.g. "select", "9table") are valid in all statements.
self._quoted_table = f'"{table_name}"'
self._quoted_index = f'"idx_{table_name}_key"'
self._local = threading.local()
# Ensure directory exists
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
if auto_create:
self._create_table()
try:
self._create_table()
except Exception:
self.close()
raise
def _get_conn(self):
"""Get thread-local connection."""
@@ -220,7 +239,7 @@ class SQLiteBackend:
cur = conn.cursor()
cur.execute(f"""
CREATE TABLE IF NOT EXISTS {self.table_name} (
CREATE TABLE IF NOT EXISTS {self._quoted_table} (
key TEXT PRIMARY KEY,
data TEXT NOT NULL,
created_at REAL DEFAULT (strftime('%s', 'now')),
@@ -230,8 +249,8 @@ class SQLiteBackend:
# Index for prefix queries
cur.execute(f"""
CREATE INDEX IF NOT EXISTS idx_{self.table_name}_key
ON {self.table_name}(key)
CREATE INDEX IF NOT EXISTS {self._quoted_index}
ON {self._quoted_table}(key)
""")
conn.commit()
@@ -244,7 +263,7 @@ class SQLiteBackend:
json_data = json.dumps(data, default=str, ensure_ascii=False)
cur.execute(f"""
INSERT INTO {self.table_name} (key, data, updated_at)
INSERT INTO {self._quoted_table} (key, data, updated_at)
VALUES (?, ?, strftime('%s', 'now'))
ON CONFLICT(key) DO UPDATE SET
data = excluded.data,
@@ -259,7 +278,7 @@ class SQLiteBackend:
cur = conn.cursor()
cur.execute(f"""
SELECT data FROM {self.table_name} WHERE key = ?
SELECT data FROM {self._quoted_table} WHERE key = ?
""", (key,))
row = cur.fetchone()
@@ -276,7 +295,7 @@ class SQLiteBackend:
cur = conn.cursor()
cur.execute(f"""
DELETE FROM {self.table_name} WHERE key = ?
DELETE FROM {self._quoted_table} WHERE key = ?
""", (key,))
deleted = cur.rowcount > 0
@@ -290,13 +309,13 @@ class SQLiteBackend:
if prefix:
cur.execute(f"""
SELECT key FROM {self.table_name}
SELECT key FROM {self._quoted_table}
WHERE key LIKE ?
ORDER BY key
""", (f"{prefix}%",))
else:
cur.execute(f"""
SELECT key FROM {self.table_name}
SELECT key FROM {self._quoted_table}
ORDER BY key
""")
@@ -308,7 +327,7 @@ class SQLiteBackend:
cur = conn.cursor()
cur.execute(f"""
SELECT 1 FROM {self.table_name} WHERE key = ? LIMIT 1
SELECT 1 FROM {self._quoted_table} WHERE key = ? LIMIT 1
""", (key,))
return cur.fetchone() is not None
@@ -318,10 +337,10 @@ class SQLiteBackend:
conn = self._get_conn()
cur = conn.cursor()
cur.execute(f"SELECT COUNT(*) as count FROM {self.table_name}")
cur.execute(f"SELECT COUNT(*) as count FROM {self._quoted_table}")
count = cur.fetchone()["count"]
cur.execute(f"DELETE FROM {self.table_name}")
cur.execute(f"DELETE FROM {self._quoted_table}")
conn.commit()
return count
@@ -262,8 +262,8 @@ class BaseJSONStore:
delete=False,
suffix=".tmp"
) as f:
json.dump(self._data, f, indent=2, default=str, ensure_ascii=False)
temp_path = f.name
json.dump(self._data, f, indent=2, default=str, ensure_ascii=False)
# Atomic rename
if self.use_file_lock:
@@ -343,7 +343,7 @@ class BaseJSONStore:
def list_json_sessions(
storage_dir: Path,
suffix: str = ".json",
limit: int = 50,
limit: Optional[int] = 50,
) -> List[BaseSessionInfo]:
"""
List all sessions in a directory.
@@ -353,7 +353,7 @@ def list_json_sessions(
Args:
storage_dir: Directory to search
suffix: File suffix to filter by
limit: Maximum number of sessions to return
limit: Maximum number of sessions to return, or None for all sessions
Returns:
List of BaseSessionInfo, sorted by modification time (newest first)
@@ -370,7 +370,7 @@ def list_json_sessions(
# Try to get item count from file
if suffix == ".json":
try:
with open(file_path, "r") as f:
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Common patterns for item count
if "iterations" in data:
@@ -383,7 +383,7 @@ def list_json_sessions(
pass
elif suffix == ".jsonl":
try:
with open(file_path, "r") as f:
with open(file_path, "r", encoding="utf-8") as f:
info.item_count = sum(1 for _ in f)
except Exception:
pass
@@ -424,7 +424,7 @@ def cleanup_old_sessions(
now = datetime.now()
# Get all sessions sorted by age (oldest first)
sessions = list_json_sessions(storage_dir, suffix, limit=10000)
sessions = list_json_sessions(storage_dir, suffix, limit=None)
sessions.sort(key=lambda s: s.modified_at)
# Delete old sessions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.7.9"
version = "1.7.10"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1,52 @@
"""Opt-in real LLM regression for documentation priority across reload.
Run with RUN_REAL_KEY_TESTS=1 and a configured provider key.
"""
import os
import pytest
@pytest.mark.skipif(
os.environ.get("RUN_REAL_KEY_TESTS") != "1",
reason="requires an explicitly enabled real provider",
)
def test_real_agent_uses_same_document_context_after_reload(tmp_path):
"""Effective priority must select the same source for real agent answers."""
from praisonaiagents import Agent
from praisonaiagents.memory.docs_manager import DocsManager
options = dict(
workspace_path=str(tmp_path),
global_docs_path=str(tmp_path / "global"),
)
manager = DocsManager(**options)
manager.create_doc("local", "The project codename is NEBULA-7.", priority=200)
manager.create_doc(
"shared", "The project codename is ORBIT-9.", priority=300, scope="global"
)
manager.create_doc("zero", "Supplementary reference.", priority=0)
original_context = manager.format_docs_for_prompt()
def verify(current):
"""Check effective priorities and the real answer for one manager lifecycle."""
assert current.get_doc("zero").priority == 0
assert current.get_doc("shared").priority == -700
context = current.format_docs_for_prompt()
assert context == original_context
assert "NEBULA-7" in context
assert "ORBIT-9" not in context
reader = Agent(
name="documentation-reader",
instructions="Answer only from this documentation:\n" + context,
)
result = reader.start("What is the project codename? Return only the codename.")
print(result)
assert "NEBULA-7" in str(result)
assert "ORBIT-9" not in str(result)
verify(manager)
manager.reload()
verify(manager)
verify(DocsManager(**options))
@@ -119,6 +119,54 @@ class TestShortTermMemory:
assert items[0].content == "Persistent memory"
class TestAtomicWriteCleanup:
"""Tests for atomic write staging-file cleanup (regression for #5491)."""
def test_serialization_error_leaves_no_temp_file(self):
"""A non-serializable value must not leave a .*.tmp staging file behind."""
from pathlib import Path
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
target = memory.short_term_file
with pytest.raises(TypeError):
memory._write_json(target, {"bad": object()})
leftovers = list(Path(target.parent).glob(f".{target.name}.*.tmp"))
assert leftovers == [], f"leftover temp files: {leftovers}"
def test_serialization_error_preserves_existing_file(self):
"""A failed write must not clobber the previously persisted file."""
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
target = memory.short_term_file
assert memory._write_json(target, [{"content": "keep me"}]) is True
original = target.read_text(encoding="utf-8")
with pytest.raises(TypeError):
memory._write_json(target, {"bad": object()})
assert target.read_text(encoding="utf-8") == original
def test_successful_write_removes_temp_file(self):
"""A successful atomic write must leave no staging file behind."""
from pathlib import Path
from praisonaiagents.memory.file_memory import FileMemory
with tempfile.TemporaryDirectory() as tmpdir:
memory = FileMemory(base_path=tmpdir)
target = memory.short_term_file
assert memory._write_json(target, [{"content": "ok"}]) is True
leftovers = list(Path(target.parent).glob(f".{target.name}.*.tmp"))
assert leftovers == [], f"leftover temp files: {leftovers}"
class TestLongTermMemory:
"""Tests for long-term memory functionality."""
@@ -0,0 +1,140 @@
"""Regression tests for issue #5596.
A guardrail that blocks model output must never make Agent.chat/achat return
``None`` (callers expect a ``str`` and crash on ``.strip()``). On a block the
agent returns a safe, non-empty fallback string instead of leaking the rejected
text.
"""
from unittest.mock import MagicMock
import pytest
from praisonaiagents import Agent, TaskOutput
def _blocking_guardrail(task_output: TaskOutput):
"""Reject every output, mimicking a forbidden-token guardrail."""
return False, "contains forbidden token"
def test_guardrail_blocked_message_is_safe_non_empty_string():
agent = Agent(
name="GuardAgent",
instructions="You are a test agent",
llm="gpt-4o-mini",
guardrails=_blocking_guardrail,
)
message = agent._guardrail_blocked_message("contains forbidden token")
assert isinstance(message, str)
assert message.strip()
# Fixed safe string (FR-001): the guardrail reason must NOT leak to the
# caller, since an LLM validator's reason can echo the rejected output.
assert "contains forbidden token" not in message
def test_guardrail_blocked_message_without_reason():
agent = Agent(
name="GuardAgent",
instructions="You are a test agent",
llm="gpt-4o-mini",
guardrails=_blocking_guardrail,
)
message = agent._guardrail_blocked_message()
assert isinstance(message, str)
assert message.strip()
def test_chat_returns_safe_message_not_none_on_block():
agent = Agent(
name="GuardAgent",
instructions="You are a test agent",
llm="gpt-4o-mini",
guardrails=_blocking_guardrail,
)
# retries=0 so the retry loop raises immediately without a network call.
agent.max_guardrail_retries = 0
# Force the custom-LLM branch and feed it forbidden output offline.
agent._using_custom_llm = True
agent.llm_instance = MagicMock()
agent.llm_instance.get_response.return_value = "LEAKME secret"
result = agent.chat("say something")
assert result is not None
assert isinstance(result, str)
assert result.strip()
assert "LEAKME" not in result
@pytest.mark.asyncio
async def test_achat_returns_safe_message_not_none_on_block():
agent = Agent(
name="GuardAgent",
instructions="You are a test agent",
llm="gpt-4o-mini",
guardrails=_blocking_guardrail,
)
agent.max_guardrail_retries = 0
async def _fake_get_response_async(**kwargs):
return "LEAKME secret"
agent._using_custom_llm = True
agent.llm_instance = MagicMock()
agent.llm_instance.get_response_async = _fake_get_response_async
result = await agent.achat("say something")
assert result is not None
assert isinstance(result, str)
assert result.strip()
assert "LEAKME" not in result
def test_chat_does_not_leak_guardrail_reason():
"""The fixed fallback must not echo the guardrail's rejection reason.
An LLM validator's reason can quote the blocked output, so the reason is
logged (and exposed via ``last_guardrail_error``) but never returned.
"""
agent = Agent(
name="GuardAgent",
instructions="You are a test agent",
llm="gpt-4o-mini",
guardrails=_blocking_guardrail,
)
agent.max_guardrail_retries = 0
agent._using_custom_llm = True
agent.llm_instance = MagicMock()
agent.llm_instance.get_response.return_value = "LEAKME secret"
result = agent.chat("say something")
assert isinstance(result, str) and result.strip()
assert "LEAKME" not in result
assert "contains forbidden token" not in result
def test_chat_returns_safe_message_with_reflection_enabled():
"""A guardrail block with self-reflection on still yields a safe string."""
from praisonaiagents import ReflectionConfig
agent = Agent(
name="GuardAgent",
instructions="You are a test agent",
llm="gpt-4o-mini",
guardrails=_blocking_guardrail,
reflection=ReflectionConfig(min_iterations=1, max_iterations=1),
)
agent.max_guardrail_retries = 0
agent._using_custom_llm = True
agent.llm_instance = MagicMock()
agent.llm_instance.get_response.return_value = "LEAKME secret"
result = agent.chat("say something")
assert result is not None
assert isinstance(result, str)
assert result.strip()
assert "LEAKME" not in result
@@ -0,0 +1,136 @@
"""
Regression tests for cross-session memory recall (issue #5595).
Two Agent instances sharing the same memory store (e.g. the same user_id)
must be able to recall facts written during an earlier turn. Previously the
synchronous chat/run/start path never wrote the interaction to the memory
store, so the second agent recalled nothing.
These tests drive the after-agent side-effect pipeline directly so they do
not require an LLM or OPENAI_API_KEY.
"""
import asyncio
import os
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from praisonaiagents import Agent
from praisonaiagents.memory import FileMemory
class TestCrossSessionMemoryRecall(unittest.TestCase):
"""MEM-001/002/003 from issue #5595."""
def _agent(self, user_id, base_path):
memory = FileMemory(user_id=user_id, base_path=base_path)
return Agent(
name="Assistant",
instructions="You are a helpful assistant.",
memory=memory,
)
def test_turn_persisted_and_recalled_across_instances(self):
"""MEM-001: a fact stored in session A is recalled by session B."""
with tempfile.TemporaryDirectory() as tmpdir:
base = f"{tmpdir}/memory"
# Session A: simulate a completed turn storing a codename.
agent_a = self._agent("e2e-memory-test", base)
agent_a._after_agent_side_effects(
"My codename is ORANGE-PANDA. ACK.",
"Acknowledged. Your codename is ORANGE-PANDA.",
)
# Session B: a fresh agent sharing the same user_id must recall it.
agent_b = self._agent("e2e-memory-test", base)
context = agent_b.get_memory_context(query="What is my codename?")
self.assertIn("ORANGE-PANDA", context)
def test_different_user_id_is_isolated(self):
"""MEM-002: a different user_id must not see another user's memory."""
with tempfile.TemporaryDirectory() as tmpdir:
base = f"{tmpdir}/memory"
agent_a = self._agent("user-a", base)
agent_a._after_agent_side_effects(
"My codename is ORANGE-PANDA.",
"Acknowledged. Your codename is ORANGE-PANDA.",
)
agent_b = self._agent("user-b", base)
context = agent_b.get_memory_context(query="What is my codename?")
self.assertNotIn("ORANGE-PANDA", context)
def test_empty_response_is_not_persisted(self):
"""A turn with no response must not write an empty entry."""
with tempfile.TemporaryDirectory() as tmpdir:
base = f"{tmpdir}/memory"
agent = self._agent("user-empty", base)
agent._after_agent_side_effects("hello", "")
self.assertEqual(
agent._memory_instance.get_stats()["short_term_count"], 0
)
def test_dict_memory_config_persists_and_recalls(self):
"""MEM-004: the advertised ``memory={'user_id': uid}`` dict config —
resolved by the Agent itself, not a preconstructed FileMemory — persists
a turn that a second Agent with the same user_id recalls.
Guards the configuration-resolution + hook wiring that the direct-call
tests above cannot (Greptile #3). Runs in a temp cwd so the default
FileMemory base path is hermetic; no LLM/API key needed.
"""
cwd = os.getcwd()
with tempfile.TemporaryDirectory() as tmpdir:
os.chdir(tmpdir)
try:
uid = "dict-config-user"
agent_a = Agent(name="A", instructions="x", memory={"user_id": uid})
self.assertIsInstance(agent_a._memory_instance, FileMemory)
agent_a._after_agent_side_effects(
"My codename is BLUE-FOX.",
"Acknowledged. Your codename is BLUE-FOX.",
)
agent_b = Agent(name="B", instructions="x", memory={"user_id": uid})
context = agent_b.get_memory_context(query="What is my codename?")
self.assertIn("BLUE-FOX", context)
finally:
os.chdir(cwd)
def test_async_path_persists_without_blocking_loop(self):
"""MEM-005: on the async after-agent path the blocking store write is
offloaded to a thread (AGENTS.md §4.5), yet the turn is still persisted
and recalled by a later Agent. Exercises the running-loop branch of
``_persist_memory_turn``.
"""
with tempfile.TemporaryDirectory() as tmpdir:
base = f"{tmpdir}/memory"
async def write_turn():
agent_a = self._agent("async-user", base)
agent_a._after_agent_side_effects(
"My codename is GREEN-HAWK.",
"Acknowledged. Your codename is GREEN-HAWK.",
)
# The write is scheduled via run_in_executor; yield until the
# short-term store reflects it (bounded) rather than racing it.
for _ in range(500):
if agent_a._memory_instance.get_stats()["short_term_count"] > 0:
break
await asyncio.sleep(0.01)
asyncio.run(write_turn())
agent_b = self._agent("async-user", base)
context = agent_b.get_memory_context(query="What is my codename?")
self.assertIn("GREEN-HAWK", context)
if __name__ == "__main__":
unittest.main()
@@ -1191,6 +1191,74 @@ class TestPluginTypedSubsystems:
mgr.disable(name)
mgr.unregister(name)
def test_has_registered_plugins_reflects_registration(self):
"""The hot-path gate is False with no plugins and True once registered."""
from praisonaiagents.plugins.plugin import PluginType
from praisonaiagents.plugins import get_plugin_manager
from praisonaiagents.plugins.manager import has_registered_plugins
mgr = get_plugin_manager()
# Clean baseline: no plugins registered on the singleton.
if not mgr._plugins:
assert has_registered_plugins() is False
plugin, name = self._make_plugin(
PluginType.GUARDRAIL, name="gate_probe_test",
as_guardrail=lambda self: object(),
)
mgr.register(plugin)
try:
assert has_registered_plugins() is True
finally:
mgr.disable(name)
mgr.unregister(name)
def test_has_registered_plugins_does_not_create_singleton(self):
"""The gate must stay False and NOT build the singleton when uncreated.
The hot-path optimisation relies on the common case (no plugins ever
registered) being allocation-free. This isolates the
``_default_manager is None`` branch, which the other tests never reach
because they call ``get_plugin_manager()`` first.
"""
from praisonaiagents.plugins import manager as manager_mod
saved = manager_mod._default_manager
manager_mod._default_manager = None
try:
assert manager_mod.has_registered_plugins() is False
# The cheap check must not have constructed the singleton.
assert manager_mod._default_manager is None
finally:
manager_mod._default_manager = saved
def test_registered_plugin_still_merges_despite_fast_path(self):
"""Agent init must still route a registered plugin even with the gate."""
from praisonaiagents.plugins.plugin import PluginType
from praisonaiagents.plugins import get_plugin_manager
from praisonaiagents.plugins.manager import has_registered_plugins
from praisonaiagents import Agent
class _G:
def validate_output(self, content, **kw):
return True, content
plugin, name = self._make_plugin(
PluginType.GUARDRAIL, name="fastpath_merge_test",
as_guardrail=lambda self: _G(),
)
mgr = get_plugin_manager()
mgr.register(plugin)
mgr.enable(name)
try:
# Gate is True, so the merge methods do NOT early-return.
assert has_registered_plugins() is True
agent = Agent(name="t", instructions="x", llm="gpt-4o-mini")
assert agent._tool_result_guardrails
finally:
mgr.disable(name)
mgr.unregister(name)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -270,9 +270,11 @@ class TestAlwaysFailingValidatorIsBounded:
except _CallCap as exc:
pytest.fail(str(exc))
# chat() reports guardrail exhaustion by returning None after rolling
# back history; the reason is on the agent for the caller to read.
assert result is None
# chat() reports guardrail exhaustion with a safe, non-empty fallback
# string after rolling back history - never None (issue #5596) and
# never the rejected answer. The reason is also on the agent to read.
assert isinstance(result, str) and result.strip()
assert "10001" not in result
assert agent.guardrail_retry_count == 2, \
f"must retry exactly max_guardrail_retries times, got {agent.guardrail_retry_count}"
assert t.calls == 1 + 2, f"1 initial call + 2 retries, got {t.calls}"
@@ -300,7 +302,8 @@ class TestAlwaysFailingValidatorIsBounded:
def test_max_retries_zero_never_retries(self):
agent, t = _agent(["10001", "SW1A 1AA"], rejects_first_answer, max_retries=0)
result = agent.chat("what is the postcode?", stream=False)
assert result is None
assert isinstance(result, str) and result.strip()
assert "10001" not in result
assert t.calls == 1, f"max_guardrail_retries=0 must not call the model again (got {t.calls})"
assert agent.guardrail_retry_count == 0
@@ -0,0 +1,116 @@
"""Unit tests for Agent.chat structured-output coercion.
Covers the fix for issue #5594: ``Agent.chat(output_pydantic=Model)`` must
return a validated Pydantic instance (not a raw JSON string) on the live
OpenAI/LiteLLM path. These tests exercise the ``_coerce_structured_output``
helper directly so no live LLM is required.
"""
from types import SimpleNamespace
from pydantic import BaseModel
from praisonaiagents.agent.chat_mixin import ChatMixin
class Pair(BaseModel):
city: str
country: str
def _fake_completion(content):
"""Build a minimal OpenAI-style response object for mocking."""
message = SimpleNamespace(content=content, reasoning_content=None, tool_calls=None)
return SimpleNamespace(choices=[SimpleNamespace(message=message)])
def _mixin():
m = ChatMixin()
m.name = "TestAgent"
return m
def test_valid_json_string_returns_model():
out = _mixin()._coerce_structured_output(
'{"city": "Paris", "country": "France"}', Pair
)
assert isinstance(out, Pair)
assert out.city == "Paris"
assert out.country == "France"
def test_markdown_fenced_json_returns_model():
fenced = '```json\n{"city": "Paris", "country": "France"}\n```'
out = _mixin()._coerce_structured_output(fenced, Pair)
assert isinstance(out, Pair)
assert out.city == "Paris"
def test_prose_falls_back_to_raw_string():
out = _mixin()._coerce_structured_output(
"The capital of France is Paris.", Pair
)
assert isinstance(out, str)
assert out == "The capital of France is Paris."
def test_no_schema_returns_input_unchanged():
out = _mixin()._coerce_structured_output("hello", None)
assert out == "hello"
def test_non_string_input_returns_unchanged():
instance = Pair(city="Paris", country="France")
out = _mixin()._coerce_structured_output(instance, Pair)
assert out is instance
def test_dict_schema_without_model_validate_json_returns_raw():
# dict schemas (inline JSON schema) have no model_validate_json; must not crash
out = _mixin()._coerce_structured_output('{"a": 1}', {"type": "object"})
assert out == '{"a": 1}'
# --- Public-path tests: chat()/achat() wiring and backward-compat guards ---
def _agent(monkeypatch, content):
"""Build a real Agent whose LLM call is mocked to return ``content``.
A placeholder API key lets the lazy OpenAI client construct; the network is
never touched because ``_chat_completion`` is mocked.
"""
monkeypatch.setenv("OPENAI_API_KEY", "not-needed")
from praisonaiagents import Agent
agent = Agent(name="TestAgent", instructions="test")
monkeypatch.setattr(agent, "_chat_completion", lambda *a, **k: _fake_completion(content))
return agent
def test_chat_output_pydantic_returns_model(monkeypatch):
# Public chat() path must return a validated Pydantic instance (issue #5594).
agent = _agent(monkeypatch, '{"city": "Paris", "country": "France"}')
out = agent.chat("capital of France?", output_pydantic=Pair)
assert isinstance(out, Pair)
assert out.city == "Paris"
def test_chat_output_json_stays_string(monkeypatch):
# Backward compat: output_json must keep returning a raw JSON string so that
# downstream task/workflow JSON parsing (json.loads) keeps working.
agent = _agent(monkeypatch, '{"city": "Paris", "country": "France"}')
out = agent.chat("capital of France?", output_json=Pair)
assert isinstance(out, str)
assert '"Paris"' in out
def test_after_agent_hook_receives_string_for_pydantic(monkeypatch):
# A Pydantic response must not crash command/string hooks: the after-agent
# hook payload truncates via response[:500], which requires a string.
agent = _agent(monkeypatch, '{"city": "Paris", "country": "France"}')
payload = agent._build_after_agent_input(
prompt="p", response=Pair(city="Paris", country="France"), start_time=0.0
)
assert isinstance(payload.response, str)
# to_dict() performs the [:500] slice that would otherwise raise on a model
assert isinstance(payload.to_dict()["response"], str)
@@ -0,0 +1,92 @@
"""Native failed Responses must surface without a second provider request."""
from types import SimpleNamespace
import pytest
from openai.types.responses import ResponseError
from openai.types.chat import ChatCompletionChunk
from praisonaiagents.llm.llm import LLMResponseError
from praisonaiagents.llm.openai_client import OpenAIClient
@pytest.mark.asyncio
@pytest.mark.parametrize('mode', ['sync', 'async', 'sync_stream', 'async_stream'])
@pytest.mark.parametrize('shape', ['sdk', 'dict'])
@pytest.mark.parametrize('profile', ['failed', 'missing_error', 'completed', 'unsupported'])
async def test_native_responses_failure_and_compatibility_fallback(mode, shape, profile, monkeypatch):
monkeypatch.delenv('OPENAI_API_BASE', raising=False)
monkeypatch.delenv('OPENAI_BASE_URL', raising=False)
calls = []
error = ResponseError(code='server_error', message='Provider failed.') if profile == 'failed' else None
fields = {
'status': 'failed' if profile in ('failed', 'missing_error') else 'completed',
'error': error if shape == 'sdk' or error is None else error.model_dump(),
'output': [{'type': 'message', 'content': [{'type': 'output_text', 'text': 'answer'}]}],
'usage': {'input_tokens': 3, 'output_tokens': 2, 'total_tokens': 5},
}
if shape == 'sdk':
fields['usage'] = SimpleNamespace(**fields['usage'])
raw = SimpleNamespace(**fields) if shape == 'sdk' else fields
def respond(**kwargs):
calls.append('responses')
if profile == 'unsupported':
raise NotImplementedError('Responses unsupported')
return raw
async def arespond(**kwargs):
return respond(**kwargs)
fallback = SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content='fallback'))])
def chat(**kwargs):
calls.append('chat')
if kwargs.get('stream'):
return iter([ChatCompletionChunk(
id='chat-test', object='chat.completion.chunk', created=0, model='gpt-4o-mini',
choices=[{'index': 0, 'delta': {'content': 'fallback'}, 'finish_reason': 'stop'}],
)])
return fallback
async def achat(**kwargs):
result = chat(**kwargs)
if kwargs.get('stream'):
async def chunks():
for chunk in result:
yield chunk
return chunks()
return result
client = OpenAIClient(api_key='sk-test-not-real')
client._sync_client = SimpleNamespace(
responses=SimpleNamespace(create=respond), chat=SimpleNamespace(completions=SimpleNamespace(create=chat)),
)
client._async_client = SimpleNamespace(
responses=SimpleNamespace(create=arespond), chat=SimpleNamespace(completions=SimpleNamespace(create=achat)),
)
async def invoke():
messages = [{'role': 'user', 'content': 'question'}]
if mode == 'sync_stream':
return client.process_stream_response(messages, model='gpt-4o-mini')
if mode == 'async_stream':
return await client.process_stream_response_async(messages, model='gpt-4o-mini')
return client.create_completion(messages) if mode == 'sync' else await client.acreate_completion(messages)
if profile in ('failed', 'missing_error'):
with pytest.raises(LLMResponseError, match='Responses API failed') as caught:
await invoke()
if error:
assert 'server_error' in str(caught.value)
assert 'Provider failed.' in str(caught.value)
assert calls == ['responses']
else:
result = await invoke()
assert result.choices[0].message.content == ('fallback' if profile == 'unsupported' else 'answer')
assert calls == (['responses', 'chat'] if profile == 'unsupported' else ['responses'])
if profile == 'completed':
assert result.usage is not None
assert result.usage.prompt_tokens == 3
assert result.usage.completion_tokens == 2
assert result.usage.total_tokens == 5
@@ -0,0 +1,125 @@
"""Provider failures cannot become successful Responses answers."""
from types import SimpleNamespace
import pytest
from openai.types.responses import ResponseError
from praisonaiagents.llm.llm import LLM, LLMResponseError
@pytest.mark.asyncio
@pytest.mark.parametrize('entry', ['sync', 'async', 'stream', 'astream'])
@pytest.mark.parametrize('shape', ['sdk', 'dict'])
@pytest.mark.parametrize('profile', ['failed', 'missing_error', 'completed'])
async def test_public_responses_failure(entry, shape, profile, monkeypatch):
error = ResponseError(code='server_error', message='Provider failed.') if profile == 'failed' else None
status = 'completed' if profile == 'completed' else 'failed'
fields = {
'status': status,
'error': error if shape == 'sdk' or error is None else error.model_dump(),
'output': [{'type': 'message', 'content': [{'type': 'output_text', 'text': 'partial'}]}],
}
response = SimpleNamespace(**fields) if shape == 'sdk' else fields
llm = LLM(model='gpt-4o-mini')
requests = []
def respond(**kwargs):
requests.append(kwargs)
return response
async def arespond(**kwargs):
return respond(**kwargs)
llm._call_responses_api = respond
llm._call_responses_api_async = arespond
if entry in ('stream', 'astream'):
import litellm
monkeypatch.setattr('praisonaiagents.llm.llm.check_model_request', lambda *args: None)
events = [
{'type': 'response.output_text.delta', 'delta': 'partial'},
{'type': 'response.' + status, 'response': response},
]
if shape == 'sdk':
events = [SimpleNamespace(**event) for event in events]
def stream(**kwargs):
requests.append(kwargs)
return iter(events)
async def astream(**kwargs):
requests.append(kwargs)
async def iterate():
for event in events:
yield event
return iterate()
monkeypatch.setattr(litellm, 'responses', stream)
monkeypatch.setattr(litellm, 'aresponses', astream)
async def invoke():
kwargs = dict(stream=entry in ('stream', 'astream'), verbose=False)
return await llm.get_response_async('question', **kwargs) if entry in ('async', 'astream') else llm.get_response('question', **kwargs)
if profile == 'completed':
assert await invoke() == 'partial'
assert llm._last_stop_reason == 'completed'
else:
with pytest.raises(LLMResponseError, match='Responses API failed') as caught:
await invoke()
assert llm._last_stop_reason == 'error'
if error:
assert 'server_error' in str(caught.value)
assert 'Provider failed.' in str(caught.value)
assert len(requests) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize('mode', ['sync', 'async'])
@pytest.mark.parametrize('shape', ['sdk', 'dict'])
async def test_stream_error_event_prevents_pending_tool_execution(mode, shape, monkeypatch):
import litellm
llm = LLM(model='gpt-4o-mini')
effects = []
requests = []
def lookup() -> str:
"""Return a lookup result."""
effects.append('executed')
return 'found'
events = [
{'type': 'response.output_item.done', 'output_index': 0, 'item': {
'type': 'function_call', 'call_id': 'call_1', 'name': 'lookup', 'arguments': '{}',
}},
{'type': 'error', 'code': 'server_error', 'message': 'Stream failed.'},
]
if shape == 'sdk':
events = [SimpleNamespace(**event) for event in events]
monkeypatch.setattr('praisonaiagents.llm.llm.check_model_request', lambda *args: None)
def stream(**kwargs):
requests.append(kwargs)
return iter(events)
async def astream(**kwargs):
requests.append(kwargs)
async def iterate():
for event in events:
yield event
return iterate()
monkeypatch.setattr(litellm, 'responses', stream)
monkeypatch.setattr(litellm, 'aresponses', astream)
with pytest.raises(LLMResponseError, match='Stream failed.'):
kwargs = dict(stream=True, verbose=False, tools=[lookup], max_iterations=1)
if mode == 'sync':
llm.get_response('question', **kwargs)
else:
await llm.get_response_async('question', **kwargs)
assert effects == []
assert len(requests) == 1
@@ -0,0 +1,115 @@
"""Incomplete Responses retain their terminal reason and streamed usage."""
from types import SimpleNamespace
import pytest
from openai.types.responses.response import IncompleteDetails
from praisonaiagents.llm.llm import LLM
from praisonaiagents.llm.openai_client import OpenAIClient
@pytest.fixture(autouse=True)
def isolate_responses_endpoint(monkeypatch):
monkeypatch.delenv('OPENAI_API_BASE', raising=False)
monkeypatch.delenv('OPENAI_BASE_URL', raising=False)
@pytest.mark.asyncio
@pytest.mark.parametrize('entry', ['llm_sync', 'llm_async', 'llm_stream', 'llm_astream', 'client_sync', 'client_async'])
@pytest.mark.parametrize('shape', ['sdk', 'dict'])
@pytest.mark.parametrize('status,reason,expected_finish,expected_outcome', [
('incomplete', 'max_output_tokens', 'length', 'length_truncated'),
('incomplete', 'content_filter', 'content_filter', 'content_filtered'),
('completed', 'max_output_tokens', 'stop', 'completed'),
])
async def test_public_incomplete_responses(entry, shape, status, reason, expected_finish, expected_outcome, monkeypatch):
details = IncompleteDetails(reason=reason)
response = SimpleNamespace(
status=status, incomplete_details=details if shape == 'sdk' else details.model_dump(),
output=[{'type': 'message', 'content': [{'type': 'output_text', 'text': 'partial'}]}],
usage=(
SimpleNamespace(input_tokens=3, output_tokens=2, total_tokens=5)
if shape == 'sdk' else {'input_tokens': 3, 'output_tokens': 2, 'total_tokens': 5}
),
)
requests = []
def respond(**kwargs):
requests.append(kwargs)
return response
async def arespond(**kwargs):
return respond(**kwargs)
if entry.startswith('llm'):
import litellm
llm = LLM(model='gpt-4o-mini')
monkeypatch.setattr('praisonaiagents.llm.llm.check_model_request', lambda *args: None)
monkeypatch.setattr(litellm, 'responses', respond)
monkeypatch.setattr(litellm, 'aresponses', arespond)
if entry in ('llm_stream', 'llm_astream'):
events = [
{'type': 'response.output_text.delta', 'delta': 'partial'},
{'type': 'response.' + status, 'response': response},
]
if shape == 'sdk':
events = [SimpleNamespace(**event) for event in events]
def stream(**kwargs):
requests.append(kwargs)
return iter(events)
async def astream(**kwargs):
requests.append(kwargs)
async def iterate():
for event in events:
yield event
return iterate()
monkeypatch.setattr(litellm, 'responses', stream)
monkeypatch.setattr(litellm, 'aresponses', astream)
kwargs = dict(stream=entry in ('llm_stream', 'llm_astream'), verbose=False)
answer = await llm.get_response_async('question', **kwargs) if entry in ('llm_async', 'llm_astream') else llm.get_response('question', **kwargs)
assert answer == 'partial'
assert llm._last_stop_reason == expected_outcome
for metrics in (llm.last_token_metrics, llm.session_token_metrics):
assert metrics is not None
assert metrics.input_tokens == 3
assert metrics.output_tokens == 2
assert metrics.total_tokens == 5
else:
client = OpenAIClient(api_key='sk-test-not-real')
client._sync_client = SimpleNamespace(responses=SimpleNamespace(create=respond))
client._async_client = SimpleNamespace(responses=SimpleNamespace(create=arespond))
messages = [{'role': 'user', 'content': 'question'}]
answer = client.create_completion(messages) if entry == 'client_sync' else await client.acreate_completion(messages)
assert answer.choices[0].message.content == 'partial'
assert answer.choices[0].finish_reason == expected_finish
assert answer.usage.prompt_tokens == 3
assert answer.usage.completion_tokens == 2
assert answer.usage.total_tokens == 5
assert len(requests) == 1
@pytest.mark.parametrize('reason', ['max_output_tokens', 'content_filter'])
@pytest.mark.parametrize('existing', ['max_steps', 'cancelled'])
def test_incomplete_response_preserves_stronger_terminal_reason(reason, existing):
llm = LLM(model='gpt-4o-mini')
llm._last_stop_reason = existing
llm._extract_from_responses_output(SimpleNamespace(
status='incomplete', incomplete_details=IncompleteDetails(reason=reason), output=[],
))
assert llm._last_stop_reason == existing
@pytest.mark.parametrize('reason', [None, 'unknown_future_reason'])
def test_unrecognised_incomplete_reason_preserves_existing_behavior(reason):
llm = LLM(model='gpt-4o-mini')
llm._extract_from_responses_output(SimpleNamespace(
status='incomplete', incomplete_details={'reason': reason}, output=[],
))
assert llm._last_stop_reason == 'completed'
@@ -0,0 +1,141 @@
"""Responses streams release their transport on every consumer exit."""
import asyncio
import pytest
from praisonaiagents.llm.llm import LLM, LLMResponseError
@pytest.mark.asyncio
@pytest.mark.parametrize('mode', ['sync', 'async'])
@pytest.mark.parametrize('shape', ['direct', 'litellm'])
@pytest.mark.parametrize('exit_kind', ['completed', 'failed', 'callback', 'cancelled'])
async def test_responses_stream_releases_transport(mode, shape, exit_kind, monkeypatch):
import httpx
import litellm
if mode == 'sync' and exit_kind == 'cancelled':
pytest.skip('Task cancellation is an asynchronous exit')
closed = []
class SyncTransport(httpx.SyncByteStream):
def __iter__(self):
return iter([])
def close(self):
closed.append('closed')
class AsyncTransport(httpx.AsyncByteStream):
async def __aiter__(self):
if False:
yield b''
async def aclose(self):
closed.append('closed')
response = httpx.Response(200, stream=SyncTransport() if mode == 'sync' else AsyncTransport())
event = {'type': 'response.output_text.delta', 'delta': 'answer'}
if exit_kind == 'failed':
event = {'type': 'response.failed', 'response': {'error': {'code': 'server_error', 'message': 'Provider failed.'}}}
class Stream:
def __init__(self):
self.events = iter([event])
if shape == 'litellm':
self.response = response
elif mode == 'sync':
self.close = response.close
else:
self.aclose = response.aclose
def __iter__(self):
return self
def __next__(self):
return next(self.events)
def __aiter__(self):
return self
async def __anext__(self):
if exit_kind == 'cancelled':
raise asyncio.CancelledError()
try:
return next(self.events)
except StopIteration:
raise StopAsyncIteration
stream = Stream()
monkeypatch.setattr('praisonaiagents.llm.llm.check_model_request', lambda *args: None)
monkeypatch.setattr(litellm, 'responses', lambda **kwargs: stream)
async def arespond(**kwargs):
return stream
monkeypatch.setattr(litellm, 'aresponses', arespond)
llm = LLM(model='gpt-4o-mini')
def callback(event):
if exit_kind == 'callback':
raise ValueError('Callback failed.')
async def invoke():
kwargs = dict(stream_callback=callback, emit_events=True)
if mode == 'sync':
return llm._stream_responses_api({'model': llm.model}, **kwargs)
return await llm._stream_responses_api_async({'model': llm.model}, **kwargs)
if exit_kind == 'failed':
with pytest.raises(LLMResponseError, match='Provider failed.'):
await invoke()
elif exit_kind == 'callback':
with pytest.raises(ValueError, match='Callback failed.'):
await invoke()
elif exit_kind == 'cancelled':
with pytest.raises(asyncio.CancelledError):
await invoke()
else:
assert (await invoke())[0] == 'answer'
assert response.is_closed
assert closed == ['closed']
@pytest.mark.asyncio
@pytest.mark.parametrize('mode', ['sync', 'async'])
async def test_cleanup_error_does_not_mask_provider_failure(mode, monkeypatch):
import litellm
event = {'type': 'error', 'message': 'Original failure.'}
def fail_close():
raise ValueError('Cleanup failed.')
async def fail_aclose():
fail_close()
class AsyncStream:
aclose = staticmethod(fail_aclose)
def __aiter__(self):
async def events():
yield event
return events()
monkeypatch.setattr('praisonaiagents.llm.llm.check_model_request', lambda *args: None)
class SyncStream:
close = staticmethod(fail_close)
def __iter__(self):
return iter([event])
monkeypatch.setattr(litellm, 'responses', lambda **kwargs: SyncStream())
async def arespond(**kwargs):
return AsyncStream()
monkeypatch.setattr(litellm, 'aresponses', arespond)
llm = LLM(model='gpt-4o-mini')
with pytest.raises(LLMResponseError, match='Original failure.'):
if mode == 'sync':
llm._stream_responses_api({'model': llm.model})
else:
await llm._stream_responses_api_async({'model': llm.model})
@@ -0,0 +1,36 @@
"""Created documentation must keep the same effective priority after reload."""
import pytest
from praisonaiagents.memory.docs_manager import DocsManager
@pytest.mark.parametrize("scope", ["workspace", "global"])
@pytest.mark.parametrize("priority", [0, -5, 200, 1500])
def test_created_doc_priority_and_selection_survive_reload(tmp_path, scope, priority):
"""Persist explicit priorities and preserve selection across manager lifecycles."""
options = dict(workspace_path=str(tmp_path), global_docs_path=str(tmp_path / "global"))
manager = DocsManager(**options)
manager.create_doc("reference", "Stable documentation content", priority=priority, scope=scope)
before_priority = manager.get_doc("reference").priority
before_selection = [doc.name for doc in manager.get_docs_for_context()]
manager.reload()
reopened = DocsManager(**options)
for current in (manager, reopened):
assert current.get_doc("reference").priority == before_priority
assert [doc.name for doc in current.get_docs_for_context()] == before_selection
expected_priority = priority - 1000 if scope == "global" else priority
assert before_priority == expected_priority
def test_new_global_doc_does_not_temporarily_outrank_workspace_doc(tmp_path):
"""Apply the global priority offset immediately and keep selection across reload."""
options = dict(workspace_path=str(tmp_path), global_docs_path=str(tmp_path / "global"))
manager = DocsManager(**options)
manager.create_doc("local", "local content", priority=200)
manager.create_doc("shared", "global content", priority=300, scope="global")
manager.reload()
reopened = DocsManager(**options)
for current in (manager, reopened):
assert [doc.name for doc in current.get_all_docs()] == ["local", "shared"]
assert [doc.name for doc in current.get_docs_for_context()] == ["local"]
@@ -0,0 +1,83 @@
"""Caller edits must not rewrite previously stored in-memory records."""
import pytest
import threading
from concurrent.futures import ThreadPoolExecutor
import praisonaiagents.memory.adapters.in_memory_adapter as adapter_module
from praisonaiagents.memory.adapters.in_memory_adapter import InMemoryAdapter
@pytest.mark.parametrize('tier', ['short', 'long'])
def test_store_snapshots_nested_metadata(tier):
adapter = InMemoryAdapter()
metadata = {'labels': ['original'], 'details': {'count': 1}}
memory_id = getattr(adapter, f'store_{tier}_term')('original text', metadata)
metadata['labels'].append('caller edit')
metadata['details']['count'] = 2
record = getattr(adapter, f'search_{tier}_term')('original')[0]
assert record['id'] == memory_id
assert record['metadata'] == {'labels': ['original'], 'details': {'count': 1}}
@pytest.mark.parametrize('tier', ['short', 'long'])
@pytest.mark.parametrize('read', ['search', 'all'])
def test_read_results_do_not_alias_stored_records(tier, read):
adapter = InMemoryAdapter()
memory_id = getattr(adapter, f'store_{tier}_term')(
'original text', {'labels': ['original'], 'details': {'count': 1}},
)
search = getattr(adapter, f'search_{tier}_term')
records = search('original') if read == 'search' else adapter.get_all_memories()
records[0]['id'] = 'caller id'
records[0]['text'] = 'caller text'
records[0]['metadata']['labels'].append('caller edit')
records[0]['metadata']['details']['count'] = 2
records.clear()
assert search('original') == [{
'id': memory_id, 'text': 'original text', 'type': tier,
'metadata': {'labels': ['original'], 'details': {'count': 1}},
}]
assert adapter.delete_memory(memory_id, tier=tier)
assert adapter.get_all_memories() == []
@pytest.mark.parametrize('tier', ['short', 'long'])
@pytest.mark.parametrize('metadata', [None, {}])
def test_metadata_defaults_and_search_limits_remain_unchanged(tier, metadata):
adapter = InMemoryAdapter()
write = getattr(adapter, f'store_{tier}_term')
expected_ids = [write('matching text', metadata) for _ in range(6)]
search = getattr(adapter, f'search_{tier}_term')
assert [record['id'] for record in search('matching')] == expected_ids[:5]
assert [record['id'] for record in search('matching', limit=2)] == expected_ids[:2]
assert all(record['metadata'] == metadata for record in adapter.get_all_memories())
def test_enumeration_copy_allows_concurrent_eviction_and_preserves_snapshot(monkeypatch):
"""A slow nested copy must not keep writers behind the collection lock."""
adapter = InMemoryAdapter(max_size=1)
original_id = adapter.store_short_term('original', {'nested': ['original']})
copying = threading.Event()
resume = threading.Event()
real_copy = adapter_module.deepcopy
def paused_copy(value):
if isinstance(value, list):
copying.set()
assert resume.wait(5)
return real_copy(value)
monkeypatch.setattr(adapter_module, 'deepcopy', paused_copy)
with ThreadPoolExecutor(max_workers=2) as executor:
read = executor.submit(adapter.get_all_memories)
try:
assert copying.wait(2)
new_id = executor.submit(adapter.store_long_term, 'new', {'nested': ['new']}).result(timeout=1)
finally:
resume.set()
snapshot = read.result(timeout=5)
assert snapshot == [{'id': original_id, 'text': 'original', 'type': 'short', 'metadata': {'nested': ['original']}}]
snapshot[0]['metadata']['nested'].append('caller edit')
assert adapter.get_all_memories() == [{'id': new_id, 'text': 'new', 'type': 'long', 'metadata': {'nested': ['new']}}]
@@ -0,0 +1,164 @@
"""Thread-scoped SQLite cleanup must preserve the shared adapter (#5486).
`Memory.close_connections()` is documented as thread-scoped: it closes only the
calling thread's SQLite connections and leaves the shared `SqliteMemoryAdapter`
(and other threads' connections) intact. A previous adapter-shutdown block
cleared `self.memory_adapter` unconditionally, so subsequent searches fell back
to legacy SQL against `short_mem` / `long_mem` and raised
``sqlite3.OperationalError: no such table``.
These tests pin the contract: after another thread (or the same thread) calls
`close_connections()`, the shared adapter survives and searches keep working by
lazily reopening the calling thread's connection.
"""
from concurrent.futures import ThreadPoolExecutor
import pytest
from praisonaiagents.memory.memory import Memory
from praisonaiagents.memory.adapters.sqlite_adapter import SqliteMemoryAdapter
@pytest.fixture
def store(tmp_path, monkeypatch):
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "home"))
monkeypatch.chdir(tmp_path)
return Memory(config={"provider": "sqlite"})
def _texts(results):
items = results if isinstance(results, list) else results.get("results", [])
return [r.get("text") or r.get("memory") or "" for r in items]
def test_adapter_is_sqlite(store):
"""Control: these tests only mean something for the SQLite adapter."""
assert isinstance(store.memory_adapter, SqliteMemoryAdapter)
def test_search_before_cleanup(store):
"""Control: searching works before any cleanup happens."""
store.store_short_term("shared record")
assert "shared record" in _texts(store.search_short_term("shared record"))
def test_short_term_search_survives_idle_worker_cleanup(store):
"""An idle worker that never opened a connection must not break searches."""
store.store_short_term("shared record")
with ThreadPoolExecutor(max_workers=1) as pool:
pool.submit(store.close_connections).result()
assert store.memory_adapter is not None
assert "shared record" in _texts(store.search_short_term("shared record"))
def test_long_term_search_survives_idle_worker_cleanup(store):
store.store_long_term("shared long record")
with ThreadPoolExecutor(max_workers=1) as pool:
pool.submit(store.close_connections).result()
assert store.memory_adapter is not None
assert "shared long record" in _texts(store.search_long_term("shared long record"))
def test_short_term_search_survives_initialized_worker_cleanup(store):
"""A worker that has opened its own connection cleans up only its own."""
store.store_short_term("shared record")
def worker():
store.store_short_term("worker record")
store.close_connections()
with ThreadPoolExecutor(max_workers=1) as pool:
pool.submit(worker).result()
assert store.memory_adapter is not None
assert "shared record" in _texts(store.search_short_term("shared record"))
assert "worker record" in _texts(store.search_short_term("worker record"))
def test_same_thread_repeated_cleanup_and_reopen(store):
"""Repeated same-thread cleanup must keep the adapter usable (lazy reopen)."""
store.store_short_term("shared record")
store.close_connections()
store.close_connections()
assert store.memory_adapter is not None
assert "shared record" in _texts(store.search_short_term("shared record"))
def test_same_thread_long_term_cleanup_and_reopen(store):
store.store_long_term("shared long record")
store.close_connections()
assert store.memory_adapter is not None
assert "shared long record" in _texts(store.search_long_term("shared long record"))
def test_adapter_thread_close_is_idempotent(store):
"""Adapter-level thread close tolerates being called with no open conns."""
store.memory_adapter.close_thread_connections()
store.memory_adapter.close_thread_connections()
store.store_short_term("shared record")
assert "shared record" in _texts(store.search_short_term("shared record"))
def test_adapter_thread_close_closes_open_connection(store):
"""Opening a connection then closing it directly must close the live conn
and drop it from the shared registry (not just the no-op path)."""
adapter = store.memory_adapter
adapter.store_short_term("shared record")
adapter.store_long_term("shared long record")
stm = adapter._local.stm_conn
ltm = adapter._local.ltm_conn
assert stm in adapter._all_connections
assert ltm in adapter._all_connections
adapter.close_thread_connections()
# Thread-local refs cleared and registry no longer tracks the closed conns.
assert getattr(adapter._local, "stm_conn", None) is None
assert getattr(adapter._local, "ltm_conn", None) is None
assert stm not in adapter._all_connections
assert ltm not in adapter._all_connections
# The physical connections are closed (operations raise ProgrammingError).
with pytest.raises(Exception):
stm.execute("SELECT 1")
with pytest.raises(Exception):
ltm.execute("SELECT 1")
# Adapter remains usable via lazy reopen.
assert "shared record" in _texts(adapter.search_short_term("shared record"))
def test_adapter_thread_close_keeps_connection_tracked_on_failure(store):
"""If a connection fails to close, it stays tracked for a later retry and
the thread-local ref is preserved (the open-conn failure path)."""
adapter = store.memory_adapter
adapter.store_short_term("shared record")
real = adapter._local.stm_conn
class _FlakyConn:
def __init__(self, inner):
self._inner = inner
self.close_calls = 0
def close(self):
self.close_calls += 1
raise RuntimeError("boom")
flaky = _FlakyConn(real)
# Swap the tracked real conn for a flaky wrapper in both registry and tls.
with adapter._connection_lock:
adapter._all_connections.discard(real)
adapter._all_connections.add(flaky)
adapter._local.stm_conn = flaky
adapter.close_thread_connections()
assert flaky.close_calls == 1
# Failed close: connection stays tracked and the ref is NOT cleared.
assert flaky in adapter._all_connections
assert adapter._local.stm_conn is flaky
@@ -0,0 +1,99 @@
"""Enumerating SQLite memories must include rows beyond search-sized caps."""
import pytest
from praisonaiagents.memory.adapters.sqlite_adapter import SqliteMemoryAdapter
from praisonaiagents.memory.memory import Memory
@pytest.mark.parametrize('facade', [False, True])
@pytest.mark.parametrize('short_count,long_count', [(0, 0), (1000, 1), (1, 1000), (1001, 1001)])
def test_get_all_memories_includes_every_stored_row(tmp_path, monkeypatch, facade, short_count, long_count):
monkeypatch.setenv('PRAISONAI_HOME', str(tmp_path / 'home'))
monkeypatch.chdir(tmp_path)
store = Memory(config={'provider': 'sqlite'}) if facade else SqliteMemoryAdapter(
short_db=str(tmp_path / 'short.db'), long_db=str(tmp_path / 'long.db'),
)
adapter = store.memory_adapter if facade else store
expected = set()
try:
for tier, count in [('short', short_count), ('long', long_count)]:
write = getattr(store, f'store_{tier}_term')
for index in range(count):
text = f'{tier} entry {index}'
memory_id = write(text, metadata={'index': index})
expected.add((f'{tier}_term', memory_id, text, index))
records = store.get_all_memories()
assert len(records) == short_count + long_count
assert {
(record['memory_type'], record['id'], record['text'], record['metadata']['index'])
for record in records
} == expected
if facade:
assert all(record['type'] == record['memory_type'] for record in records)
# Enumeration must not widen the public search defaults or explicit limit.
for tier, count in [('short', short_count), ('long', long_count)]:
search = getattr(adapter, f'search_{tier}_term')
assert len(search('')) == min(5, count)
assert len(search('', limit=2)) == min(2, count)
finally:
adapter.close_connections()
if facade:
store.close_connections()
@pytest.mark.parametrize('tier', ['short', 'long'])
@pytest.mark.parametrize('value', [float('nan'), float('inf'), -float('inf')])
def test_filtered_search_accepts_writer_nonfinite_metadata(tmp_path, tier, value):
adapter = SqliteMemoryAdapter(short_db=str(tmp_path / 'short.db'), long_db=str(tmp_path / 'long.db'))
try:
write = getattr(adapter, f'store_{tier}_term')
search = getattr(adapter, f'search_{tier}_term')
write('other record', metadata={'user_id': 'other', 'value': value})
wanted = write('wanted record', metadata={'user_id': 'wanted', 'value': value})
assert [item['id'] for item in search('', user_id='wanted', limit=1)] == [wanted]
assert search('', user_id='absent') == []
finally:
adapter.close_connections()
@pytest.mark.parametrize('tier', ['short', 'long'])
def test_filtered_search_ignores_corrupt_peer_metadata_and_keeps_string_matching(tmp_path, tier):
adapter = SqliteMemoryAdapter(short_db=str(tmp_path / 'short.db'), long_db=str(tmp_path / 'long.db'))
try:
write = getattr(adapter, f'store_{tier}_term')
wanted = write('wanted', metadata={'user_id': True})
conn = getattr(adapter, f'_get_{"stm" if tier == "short" else "ltm"}_conn')()
conn.execute(f'INSERT INTO {tier}_term_memory (content, metadata) VALUES (?, ?)', ('broken peer', '{invalid'))
conn.commit()
search = getattr(adapter, f'search_{tier}_term')
assert [item['id'] for item in search('', user_id='True', limit=1)] == [wanted]
finally:
adapter.close_connections()
@pytest.mark.parametrize('tier', ['short', 'long'])
def test_null_user_is_distinct_from_literal_none_user(tmp_path, tier):
adapter = SqliteMemoryAdapter(short_db=str(tmp_path / 'short.db'), long_db=str(tmp_path / 'long.db'))
try:
write = getattr(adapter, f'store_{tier}_term')
write('unowned', metadata={'user_id': None})
wanted = write('owned', metadata={'user_id': 'None'})
search = getattr(adapter, f'search_{tier}_term')
assert [item['id'] for item in search('', user_id='None')] == [wanted]
finally:
adapter.close_connections()
@pytest.mark.parametrize("tier", ["short_term", "long_term"])
@pytest.mark.parametrize("user_id", [0, False, ""])
def test_falsey_supplied_user_id_still_filters_before_limit(tmp_path, tier, user_id):
adapter = SqliteMemoryAdapter(short_db=str(tmp_path / 'short.db'), long_db=str(tmp_path / 'long.db'))
try:
write = getattr(adapter, f"store_{tier}")
search = getattr(adapter, f"search_{tier}")
write("other", metadata={"user_id": "other"})
wanted = write("wanted", metadata={"user_id": user_id})
assert [item["id"] for item in search("", user_id=user_id, limit=1)] == [wanted]
finally:
adapter.close_connections()
@@ -0,0 +1,29 @@
"""Continuation prompts must distinguish running work from proven completion."""
import pytest
from praisonaiagents.session import build_handoff_prompt
@pytest.mark.parametrize(
"checkpoint,expected,absent",
[
({"current_step": "deploy"}, "PROGRESS: current step: deploy", ["last completed", "steps done"]),
({"completed_steps": 0, "total_steps": 5, "current_step": "deploy"},
"PROGRESS: 0 of 5 workflow steps done; current step: deploy", ["last completed"]),
({"completed_steps": 2, "total_steps": 5, "last_step": "build", "current_step": "deploy"},
"PROGRESS: 2 of 5 workflow steps done; last completed: build; current step: deploy", []),
({"last_step": "build"}, "PROGRESS: last completed: build", ["steps done"]),
({"completed_steps": 0, "total_steps": 0, "step_count": 9},
"PROGRESS: 0 of 0 workflow steps done", ["of 9"]),
({"completed_steps": 2, "step_count": 5, "last_step": "build"},
"PROGRESS: 2 of 5 workflow steps done; last completed: build", []),
],
)
def test_handoff_reports_only_the_supplied_progress(checkpoint, expected, absent):
prompt = build_handoff_prompt(workflow_checkpoint=checkpoint)
assert expected in prompt
for text in absent:
assert text not in prompt
assert prompt.endswith("before acting.")
@@ -0,0 +1,123 @@
"""Closing and reopening a Session must preserve complete agent messages."""
from copy import deepcopy
from types import SimpleNamespace
import pytest
from praisonaiagents.session.api import AGENT_HISTORY_KEY, Session
from praisonaiagents.session.store import DefaultSessionStore
@pytest.mark.parametrize("history", [
[{"role": "user", "content": "hello"}],
[
{"role": "assistant", "content": None, "tool_calls": [
{"id": "call_1", "type": "function", "function": {
"name": "lookup", "arguments": '{"query":"hello"}'}}
]},
{"role": "tool", "content": "found", "tool_call_id": "call_1",
"name": "lookup"},
{"role": "assistant", "content": "answer"},
],
[{"role": "assistant", "content": "answer", "reasoning_content": "why",
"metadata": {"citations": [{"source": "local"}]}, "custom": False}],
[{"role": "user", "content": [
{"type": "text", "text": "describe"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}},
], "name": "reader"}],
], ids=["plain-control", "tool-turn", "reasoning-and-metadata", "multimodal"])
def test_close_reopen_preserves_message_fields(tmp_path, monkeypatch, history):
import praisonaiagents.agent as agent_module
import praisonaiagents.session.store as store_module
class LocalAgent:
def __init__(self, **kwargs):
self.chat_history = []
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "home"))
monkeypatch.setattr(agent_module, "Agent", LocalAgent)
session_dir = str(tmp_path / "sessions")
store = DefaultSessionStore(session_dir=session_dir)
monkeypatch.setattr(store_module, "_default_store", store)
session = Session(session_id="parent")
session._memory = SimpleNamespace(search_short_term=lambda **kwargs: [])
agent = session.Agent(name="helper", memory=False)
agent.chat_history = deepcopy(history)
session.close()
fresh = DefaultSessionStore(session_dir=session_dir)
monkeypatch.setattr(store_module, "_default_store", fresh)
assert fresh.get_session("parent").metadata[AGENT_HISTORY_KEY][
"helper:Assistant"
] == history
resumed = Session(session_id="parent").Agent(name="helper", memory=False)
assert resumed.chat_history == history
@pytest.mark.parametrize("builder", ["openai", "litellm"])
def test_restored_extensions_do_not_reach_responses_input(tmp_path, monkeypatch, builder):
import praisonaiagents.session.store as store_module
from praisonaiagents.llm.openai_client import OpenAIClient
from praisonaiagents.llm.llm import LLM
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "home"))
store = DefaultSessionStore(session_dir=str(tmp_path / "sessions"))
monkeypatch.setattr(store_module, "_default_store", store)
history = [
{"role": "user", "content": "question", "name": "reader",
"metadata": {"origin": "disk"}},
{"role": "assistant", "content": "answer", "reasoning_content": "why",
"metadata": {"citation": "local"}, "extension": False},
{"role": "assistant", "content": None, "tool_calls": [{
"id": "call_1", "type": "function",
"function": {"name": "lookup", "arguments": "{}"},
}]},
{"role": "tool", "tool_call_id": "call_1", "content": "found"},
]
session = Session(session_id="parent")
session._agents["helper:Assistant"] = {"agent": None, "chat_history": history}
session.close()
resumed = Session(session_id="parent")._restore_agent_chat_history("helper:Assistant")
if builder == "openai":
client = object.__new__(OpenAIClient)
params = client._build_responses_input(resumed, model="gpt-4o")
else:
llm = object.__new__(LLM)
llm.max_tokens = None
llm.top_p = None
llm.base_url = None
llm.api_key = None
llm.timeout = None
llm._resolve_subscription_creds = lambda: None
llm._resolve_openai_compatible_model = lambda: "gpt-4o"
params = llm._build_responses_params(resumed)
assert params["input"] == [
{"role": "user", "content": "question"},
{"role": "assistant", "content": "answer"},
{"type": "function_call", "call_id": "call_1", "name": "lookup",
"arguments": "{}"},
{"type": "function_call_output", "call_id": "call_1", "output": "found"},
]
assert store.get_session("parent").metadata[AGENT_HISTORY_KEY][
"helper:Assistant"
] == history
def test_legacy_defaults_and_non_message_filter(tmp_path, monkeypatch):
import praisonaiagents.session.store as store_module
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "home"))
store = DefaultSessionStore(session_dir=str(tmp_path / "sessions"))
monkeypatch.setattr(store_module, "_default_store", store)
session = Session(session_id="parent")
session._agents["helper:Assistant"] = {
"agent": None, "chat_history": [None, {"name": "helper"}],
}
session.close()
assert store.get_session("parent").metadata[AGENT_HISTORY_KEY][
"helper:Assistant"
] == [{"role": "user", "content": "", "name": "helper"}]
@@ -0,0 +1,91 @@
"""Concurrent Session closes must merge different agent histories under lock."""
from concurrent.futures import ThreadPoolExecutor
from threading import Event
from pathlib import Path
import pytest
from praisonaiagents.session.api import AGENT_HISTORY_KEY, Session
from praisonaiagents.session.store import DefaultSessionStore
@pytest.mark.parametrize("existing", [{}, {"peer:Assistant": [
{"role": "user", "content": "peer"},
]}])
def test_two_closes_preserve_both_agent_histories(tmp_path, monkeypatch, existing):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "home"))
directory = str(tmp_path / "sessions")
first = DefaultSessionStore(session_dir=directory)
second = DefaultSessionStore(session_dir=directory)
first.update_session_metadata("parent", **{AGENT_HISTORY_KEY: existing})
a, b = Session(session_id="parent"), Session(session_id="parent")
monkeypatch.setattr(a, "_get_session_store", lambda: first)
monkeypatch.setattr(b, "_get_session_store", lambda: second)
ah = [{"role": "user", "content": "a"}]
bh = [{"role": "user", "content": "b"}]
a._agents["a:Assistant"] = {"agent": None, "chat_history": ah}
b._agents["b:Assistant"] = {"agent": None, "chat_history": bh}
ready, completed = Event(), Event()
original = first._modify_session_locked
def pause_before_lock(*args, **kwargs):
ready.set()
assert completed.wait(10), "second writer did not complete"
return original(*args, **kwargs)
monkeypatch.setattr(first, "_modify_session_locked", pause_before_lock)
def close_second():
assert ready.wait(10), "first writer did not reach write boundary"
try:
b.close()
finally:
completed.set()
with ThreadPoolExecutor(max_workers=2) as pool:
fa, fb = pool.submit(a.close), pool.submit(close_second)
fa.result(timeout=15)
fb.result(timeout=15)
fresh = DefaultSessionStore(session_dir=directory)
assert fresh.get_session("parent").metadata[AGENT_HISTORY_KEY] == {
**existing, "a:Assistant": ah, "b:Assistant": bh,
}
def test_metadata_map_defaults_do_not_overwrite_current_entries(tmp_path):
store = DefaultSessionStore(session_dir=str(tmp_path))
store.update_session_metadata("parent", marker="keep", **{AGENT_HISTORY_KEY: {
"current": [], "peer": ["present"],
}})
assert store.merge_session_metadata_map(
"parent", AGENT_HISTORY_KEY, {"new": ["new"]},
defaults={"current": ["legacy"], "legacy-only": ["old"]},
)
data = DefaultSessionStore(session_dir=str(tmp_path)).get_session("parent")
assert data.metadata[AGENT_HISTORY_KEY] == {
"current": [], "peer": ["present"], "new": ["new"],
"legacy-only": ["old"],
}
assert data.metadata["marker"] == "keep"
@pytest.mark.parametrize("failure", ["read", "write"])
def test_metadata_map_failure_preserves_disk(tmp_path, monkeypatch, failure):
import praisonaiagents.session.store as store_module
store = DefaultSessionStore(session_dir=str(tmp_path))
store.update_session_metadata("parent", **{AGENT_HISTORY_KEY: {"peer": ["old"]}})
path = Path(store._get_session_path("parent"))
before = path.read_bytes()
def fail(*args, **kwargs):
raise OSError("injected I/O failure")
if failure == "read":
monkeypatch.setattr(store, "_load_session_from_disk", fail)
else:
monkeypatch.setattr(store_module.os, "replace", fail)
assert not store.merge_session_metadata_map("parent", AGENT_HISTORY_KEY, {"new": []})
assert path.read_bytes() == before
@@ -0,0 +1,61 @@
"""Imports must not overwrite a session created after the first check."""
from concurrent.futures import ThreadPoolExecutor
from threading import Event
import pytest
from praisonaiagents.session.store import DefaultSessionStore
from praisonaiagents.session.sqlite_store import SqliteSessionStore
@pytest.mark.parametrize("kind", [DefaultSessionStore, SqliteSessionStore])
@pytest.mark.parametrize("overwrite", [False, True])
def test_import_rechecks_new_peer_session_under_write_lock(tmp_path, monkeypatch, kind, overwrite):
directory = str(tmp_path / "sessions")
stores = [kind(session_dir=directory), kind(session_dir=directory)]
importer, peer = stores
ready, completed = Event(), Event()
original = importer._save_imported_session
def pause_before_write(*args, **kwargs):
ready.set()
assert completed.wait(10), "peer did not finish creating its session"
return original(*args, **kwargs)
monkeypatch.setattr(importer, "_save_imported_session", pause_before_write)
payload = {"version": 1, "sessions": [{
"session_id": "shared", "messages": [{
"role": "user", "content": "imported", "timestamp": 1,
}],
}]}
def create_peer():
assert ready.wait(10), "import did not reach write boundary"
try:
assert peer.add_message("shared", "user", "peer-created")
finally:
completed.set()
try:
with ThreadPoolExecutor(max_workers=2) as pool:
future = pool.submit(create_peer)
report = importer.import_sessions(payload, overwrite=overwrite)
future.result(timeout=15)
fresh = kind(session_dir=directory)
stores.append(fresh)
if overwrite:
assert report.imported == 1
assert not report.skipped
assert fresh.get_chat_history("shared") == [{"role": "user", "content": "imported"}]
else:
assert report.imported == 0
assert report.skipped == [{
"session_id": "shared", "reason": "already exists (use overwrite)",
}]
assert fresh.get_chat_history("shared") == [{"role": "user", "content": "peer-created"}]
finally:
for store in stores:
conn = getattr(store, "_conn", None)
if conn is not None:
conn.close()
@@ -0,0 +1,187 @@
"""Portable imports refresh the indexed store's content and routing views."""
import pytest
import threading
from concurrent.futures import ThreadPoolExecutor
from praisonaiagents.session.sqlite_store import SqliteSessionStore
from praisonaiagents.session.store import DefaultSessionStore
@pytest.fixture
def make_store(tmp_path):
stores = []
def create(kind, **kwargs):
store = kind(session_dir=str(tmp_path / str(len(stores))), **kwargs)
stores.append(store)
return store
yield create
for store in stores:
conn = getattr(store, "_conn", None)
if conn is not None:
conn.close()
@pytest.mark.parametrize("kind", [DefaultSessionStore, SqliteSessionStore])
@pytest.mark.parametrize("warm", [False, True])
@pytest.mark.parametrize("overwrite", [False, True])
@pytest.mark.parametrize("reset_live_fields", [False, True])
def test_import_refreshes_content_and_routes(make_store, kind, warm, overwrite, reset_live_fields):
source = make_store(DefaultSessionStore)
messages = [{"role": "user", "content": f"narwhal imported turn {index}"} for index in range(5)]
assert source.set_chat_history("session", messages)
assert source.set_gateway_info("session", gateway_session_id="new-gateway", agent_id="new-agent")
payload = source.export_all()
destination = make_store(kind, active_window=2)
if overwrite:
assert destination.add_message("session", "user", "pelican old turn")
assert destination.set_gateway_info("session", gateway_session_id="old-gateway", agent_id="old-agent")
if warm:
destination.search("pelican")
destination.get_by_gateway_session("old-gateway")
report = destination.import_sessions(payload, overwrite=overwrite, reset_live_fields=reset_live_fields)
assert report.imported == 1
assert report.skipped == []
session = destination.get_session("session")
assert [message.content for message in session.messages] == [message["content"] for message in messages]
assert destination.get_by_gateway_session("old-gateway") is None
assert destination.list_sessions_by_gateway_agent("old-agent") == []
found = destination.get_by_gateway_session("new-gateway")
if reset_live_fields:
assert found is None
assert destination.list_sessions_by_gateway_agent("new-agent") == []
else:
assert found is not None and found.session_id == "session"
assert destination.list_sessions_by_gateway_agent("new-agent") == ["session"]
assert destination.search("pelican") == []
assert [hit.session_id for hit in destination.search("narwhal")] == ["session"]
@pytest.mark.parametrize("write_failure", [False, True])
def test_unsaved_import_keeps_previous_index(make_store, monkeypatch, write_failure):
source = make_store(DefaultSessionStore)
assert source.add_message("session", "user", "narwhal replacement")
destination = make_store(SqliteSessionStore)
assert destination.add_message("session", "user", "pelican original")
assert destination.set_gateway_info("session", gateway_session_id="gateway", agent_id="agent")
assert destination.search("pelican")
if write_failure:
monkeypatch.setattr(destination, "_atomic_write_json", lambda *args: False)
report = destination.import_sessions(source.export_all(), overwrite=write_failure)
assert report.imported == 0
assert len(report.skipped) == 1
assert report.skipped[0]["reason"] == ("write failed" if write_failure else "already exists (use overwrite)")
assert [hit.session_id for hit in destination.search("pelican")] == ["session"]
assert destination.search("narwhal") == []
assert destination.get_by_gateway_session("gateway").session_id == "session"
assert destination.list_sessions_by_gateway_agent("agent") == ["session"]
def test_warm_import_indexes_archived_turns(make_store):
source = make_store(DefaultSessionStore, active_window=3)
assert source.add_message("session", "user", "pelican archived marker")
for index in range(8):
assert source.add_message("session", "user", f"later turn {index}")
assert any("pelican" in message.content for message in source.get_session("session").archived_messages)
destination = make_store(SqliteSessionStore, active_window=2)
assert destination.search("pelican") == []
assert destination.import_sessions(source.export_all()).imported == 1
hits = destination.search("pelican")
assert [hit.session_id for hit in hits] == ["session"]
assert any(message["archived"] for message in hits[0].messages)
@pytest.mark.parametrize("separate_store", [False, True])
def test_write_between_import_save_and_index_keeps_newer_content(make_store, monkeypatch, separate_store):
source = make_store(DefaultSessionStore)
assert source.add_message("session", "user", "pelican imported turn")
destination = make_store(SqliteSessionStore)
destination.search("pelican")
writer = destination
if separate_store:
writer = SqliteSessionStore(session_dir=destination.session_dir, db_path=destination.db_path)
writer.search("pelican")
saved = threading.Event()
resume = threading.Event()
original = DefaultSessionStore._save_imported_session
def delayed_save(store, session, *, overwrite=True):
result = original(store, session, overwrite=overwrite)
saved.set()
assert resume.wait(5), "concurrent writer did not finish"
return result
monkeypatch.setattr(DefaultSessionStore, "_save_imported_session", delayed_save)
try:
with ThreadPoolExecutor(max_workers=1) as executor:
importing = executor.submit(destination.import_sessions, source.export_all())
try:
assert saved.wait(5), "import did not persist the JSON"
assert writer.set_chat_history("session", [{"role": "user", "content": "narwhal newer turn"}])
assert writer.set_gateway_info("session", gateway_session_id="new-gateway", agent_id="new-agent")
assert writer.search("narwhal")
finally:
resume.set()
assert importing.result(timeout=5).imported == 1
destination.invalidate_cache()
assert destination.get_session("session").messages[0].content == "narwhal newer turn"
assert [hit.session_id for hit in destination.search("narwhal")] == ["session"]
assert destination.search("pelican") == []
assert destination.get_by_gateway_session("new-gateway").session_id == "session"
assert destination.list_sessions_by_gateway_agent("new-agent") == ["session"]
finally:
if separate_store and writer._conn is not None:
writer._conn.close()
def test_post_import_refresh_failure_does_not_report_durable_write_failure(make_store, monkeypatch):
source = make_store(DefaultSessionStore)
assert source.add_message("session", "user", "durable imported note")
destination = make_store(SqliteSessionStore)
def unavailable(*args):
raise OSError("injected index refresh read failure")
monkeypatch.setattr(destination, "_load_session_from_disk", unavailable)
report = destination.import_sessions(source.export_all())
assert report.imported == 1
assert report.skipped == []
reader = DefaultSessionStore(session_dir=destination.session_dir)
assert reader.get_session("session").messages[0].content == "durable imported note"
def test_post_import_read_failure_does_not_restore_old_routes(make_store, monkeypatch):
source = make_store(DefaultSessionStore)
assert source.add_message("session", "user", "narwhal replacement")
destination = make_store(SqliteSessionStore)
assert destination.add_message("session", "user", "pelican original")
assert destination.set_gateway_info("session", gateway_session_id="old-gateway", agent_id="old-agent")
assert destination.get_by_gateway_session("old-gateway").session_id == "session"
assert destination.search("pelican")
original = destination._load_session_from_disk
def unavailable(*args):
raise OSError("injected post-save read failure")
monkeypatch.setattr(destination, "_load_session_from_disk", unavailable)
report = destination.import_sessions(source.export_all(), overwrite=True)
assert report.imported == 1
assert report.skipped == []
assert destination.get_by_gateway_session("old-gateway") is None
assert destination.list_sessions_by_gateway_agent("old-agent") == []
monkeypatch.setattr(destination, "_load_session_from_disk", original)
destination.invalidate_cache()
saved = destination.get_session("session")
assert saved.gateway_session_id is None
assert saved.agent_id is None
assert saved.messages[0].content == "narwhal replacement"
assert destination.search("narwhal") == []
assert destination.add_message("session", "user", "later update")
assert [hit.session_id for hit in destination.search("narwhal")] == ["session"]
assert destination.get_by_gateway_session("old-gateway") is None
@@ -204,6 +204,57 @@ class TestSqliteTranscriptStore:
{"role": "user", "content": "legacy transcript"}
]
def test_migration_skips_invalid_utf8_legacy_file(self, tmp_dir):
"""A legacy file with invalid UTF-8 bytes is skipped (not fatal), and
valid transcripts still migrate; the bad bytes are preserved."""
legacy = DefaultSessionStore(session_dir=tmp_dir)
legacy.add_message("old", "user", "legacy transcript")
bad_path = os.path.join(tmp_dir, "invalid.json")
with open(bad_path, "wb") as handle:
handle.write(b"\xff\xfe")
store = SqliteTranscriptStore(session_dir=tmp_dir)
assert store.session_exists("old")
assert store.get_chat_history("old") == [
{"role": "user", "content": "legacy transcript"}
]
with open(bad_path, "rb") as handle:
assert handle.read() == b"\xff\xfe"
def test_migration_continues_after_invalid_utf8_ordered_first(self, tmp_dir):
"""A bad file processed before valid ones must not abort later migrations.
Migration sorts filenames, so ``aaa_invalid.json`` is deterministically
processed before the ``zeta`` session's JSON file — guaranteeing the
bad-first scenario is actually exercised.
"""
legacy = DefaultSessionStore(session_dir=tmp_dir)
legacy.add_message("zeta", "user", "valid transcript")
bad_path = os.path.join(tmp_dir, "aaa_invalid.json")
with open(bad_path, "wb") as handle:
handle.write(b"\xff\xfe")
import json as _json
json_files = sorted(f for f in os.listdir(tmp_dir) if f.endswith(".json"))
assert json_files[0] == "aaa_invalid.json"
valid_file = next(
f for f in json_files
if f != "aaa_invalid.json" and "zeta" in _json.dumps(
_json.load(open(os.path.join(tmp_dir, f)))
)
)
assert "aaa_invalid.json" < valid_file
store = SqliteTranscriptStore(session_dir=tmp_dir)
assert store.session_exists("zeta")
assert store.get_chat_history("zeta") == [
{"role": "user", "content": "valid transcript"}
]
class TestTranscriptArchivedRecall:
"""Issue #5031: transcript store must recall compacted (archived) turns."""
@@ -0,0 +1,76 @@
"""The public title length cap applies to generated and fallback titles."""
import pytest
from praisonaiagents.session.title import generate_title, generate_title_async
@pytest.mark.parametrize("max_length", [0, 1, 2, 3, 4, 60])
@pytest.mark.parametrize("user_msg", ["", "?!.", "A long user request about debugging code"])
@pytest.mark.asyncio
async def test_sync_fallback_in_running_loop_respects_length(user_msg, max_length):
title = generate_title(user_msg, "Response", max_length=max_length)
assert len(title) <= max_length
if max_length == 60:
assert title
@pytest.mark.parametrize("max_length", [0, 1, 2, 3, 4, 60])
@pytest.mark.parametrize("response", ["A long generated title about Python", None])
@pytest.mark.asyncio
async def test_async_generated_and_fallback_titles_respect_length(monkeypatch, max_length, response):
import praisonaiagents.llm as llm_module
class LocalLLM:
def __init__(self, **kwargs):
pass
async def get_response_async(self, **kwargs):
return response
monkeypatch.setattr(llm_module, "LLM", LocalLLM)
title = await generate_title_async("", "Response", llm_model="test", max_length=max_length)
assert len(title) <= max_length
if max_length == 60:
assert title == (response or "Chat Session")
@pytest.mark.parametrize("max_length", [0, -1])
@pytest.mark.asyncio
async def test_nonpositive_cap_skips_model_initialization(monkeypatch, max_length):
import praisonaiagents.llm as llm_module
calls = []
class UnexpectedLLM:
def __init__(self, **kwargs):
calls.append(kwargs)
async def get_response_async(self, **kwargs):
return "Unneeded title"
monkeypatch.setattr(llm_module, "LLM", UnexpectedLLM)
assert await generate_title_async("Question", "Response", max_length=max_length) == ""
assert generate_title("Question", "Response", max_length=max_length) == ""
assert calls == []
@pytest.mark.parametrize("user_msg", ["", "?!."])
@pytest.mark.asyncio
async def test_three_character_fallback_keeps_readable_prefix(user_msg):
assert generate_title(user_msg, "Response", max_length=3) == "Cha"
@pytest.mark.asyncio
async def test_three_character_generated_title_keeps_readable_prefix(monkeypatch):
import praisonaiagents.llm as llm_module
class LocalLLM:
def __init__(self, **kwargs):
pass
async def get_response_async(self, **kwargs):
return "Python debugging"
monkeypatch.setattr(llm_module, "LLM", LocalLLM)
assert await generate_title_async("Question", "Response", max_length=3) == "Pyt"
@@ -11,6 +11,8 @@ Tests cover:
import threading
from pathlib import Path
import pytest
from praisonaiagents.storage.backends import FileBackend, SQLiteBackend, get_backend
from praisonaiagents.storage.protocols import StorageBackendProtocol
@@ -33,7 +35,33 @@ class TestFileBackend:
loaded = backend.load("test_key")
assert loaded == data
def test_file_backend_load_invalid_utf8_returns_none(self, tmp_path, caplog):
"""Undecodable bytes should warn and return None, not raise (Issue #5566)."""
import logging
backend = FileBackend(storage_dir=str(tmp_path))
for raw in (b"\xff", b"\xe4\xb8", b"\xed\xa0\x80"):
backend.save("bad", {"message": "original"})
file_path = Path(tmp_path) / "bad.json"
file_path.write_bytes(raw)
with caplog.at_level(logging.WARNING, logger="praisonaiagents.storage.backends"):
caplog.clear()
assert backend.load("bad") is None
# A warning is emitted identifying the failed key
assert any(
rec.levelno == logging.WARNING and "Failed to load bad" in rec.getMessage()
for rec in caplog.records
)
# Original bytes are preserved (load is non-destructive)
assert file_path.read_bytes() == raw
# Valid neighboring keys still load
backend.save("good", {"key": "value"})
assert backend.load("good") == {"key": "value"}
def test_file_backend_exists(self, tmp_path):
"""Test exists check."""
backend = FileBackend(storage_dir=str(tmp_path))
@@ -115,6 +143,29 @@ class TestFileBackend:
backend = FileBackend(storage_dir=str(tmp_path))
assert isinstance(backend, StorageBackendProtocol)
@pytest.mark.parametrize("pretty", [True, False])
def test_file_backend_save_cleans_tmp_on_serialization_error(self, tmp_path, pretty):
"""Serialization failures must not leave partial .tmp staging files."""
backend = FileBackend(storage_dir=str(tmp_path), pretty=pretty)
backend.save("good", {"value": 1})
circular = {}
circular["self"] = circular
with pytest.raises(ValueError):
backend.save("circular", circular)
with pytest.raises(TypeError):
backend.save("tuple_key", {("a", "b"): 1})
with pytest.raises((ValueError, TypeError)):
backend.save("good", circular)
tmp_files = list(tmp_path.glob("*.tmp"))
assert tmp_files == []
assert backend.load("good") == {"value": 1}
assert not backend.exists("circular")
assert not backend.exists("tuple_key")
class TestSQLiteBackend:
"""Tests for SQLiteBackend."""
@@ -248,6 +299,68 @@ class TestSQLiteBackend:
loaded = backend2.load("key1")
assert loaded["data"] == 1
@pytest.mark.parametrize("table_name", ["select", "123", "9table", "praison_storage"])
def test_sqlite_backend_keyword_and_digit_table_names(self, tmp_path, table_name):
"""Validator-accepted names that are SQL keywords or start with a digit
must work across schema creation and all CRUD operations (Issue #5569)."""
db_path = tmp_path / "test.db"
backend = SQLiteBackend(db_path=str(db_path), table_name=table_name)
# save / upsert
backend.save("key1", {"version": 1})
backend.save("key1", {"version": 2})
assert backend.load("key1")["version"] == 2
# exists
assert backend.exists("key1")
assert not backend.exists("missing")
# list_keys (full + prefix)
backend.save("key2", {"data": 2})
backend.save("other", {"data": 3})
assert backend.list_keys() == ["key1", "key2", "other"]
assert backend.list_keys(prefix="key") == ["key1", "key2"]
# delete
assert backend.delete("other") is True
assert not backend.exists("other")
# clear
assert backend.clear() == 2
assert backend.list_keys() == []
def test_sqlite_backend_reopen_with_auto_create_false(self, tmp_path):
"""A keyword table name persists and reopens with auto_create=False."""
db_path = tmp_path / "test.db"
backend = SQLiteBackend(db_path=str(db_path), table_name="select")
backend.save("key1", {"data": 1})
backend.close()
backend2 = SQLiteBackend(
db_path=str(db_path), table_name="select", auto_create=False
)
assert backend2.load("key1")["data"] == 1
def test_sqlite_backend_separate_tables_same_db(self, tmp_path):
"""Quoted identifiers keep distinct tables isolated in one DB file."""
db_path = tmp_path / "test.db"
t1 = SQLiteBackend(db_path=str(db_path), table_name="select")
t2 = SQLiteBackend(db_path=str(db_path), table_name="praison_storage")
t1.save("shared", {"from": "select"})
t2.save("shared", {"from": "praison_storage"})
assert t1.load("shared")["from"] == "select"
assert t2.load("shared")["from"] == "praison_storage"
def test_sqlite_backend_invalid_table_name_rejected(self, tmp_path):
"""Names with disallowed characters are still rejected by the validator."""
db_path = tmp_path / "test.db"
with pytest.raises(ValueError):
SQLiteBackend(db_path=str(db_path), table_name="bad name")
with pytest.raises(ValueError):
SQLiteBackend(db_path=str(db_path), table_name='drop";')
class TestGetBackend:
"""Tests for get_backend factory function."""
@@ -0,0 +1,30 @@
"""Enumeration and clearing honor the complete configured filename suffix."""
import pytest
from praisonaiagents.storage.backends import FileBackend
@pytest.mark.parametrize("suffix", [".data.json", "record", ".json", ""])
@pytest.mark.parametrize("operation", ["list", "clear"])
def test_configured_suffix_selects_saved_records(tmp_path, suffix, operation):
store = FileBackend(storage_dir=str(tmp_path), suffix=suffix)
for key in ("session_one", "session_two", "other"):
store.save(key, {"key": key})
unrelated = tmp_path / "unrelated.txt"
unrelated.write_text("keep", encoding="utf-8")
directory = tmp_path / ("directory" + suffix)
directory.mkdir()
assert store.exists("session_one")
assert store.load("session_one") == {"key": "session_one"}
if operation == "list":
assert store.list_keys() == ["other", "session_one", "session_two"]
assert store.list_keys("session_") == ["session_one", "session_two"]
for key in store.list_keys():
assert store.load(key) == {"key": key}
else:
assert store.clear() == 3
assert not store.exists("session_one")
assert store.list_keys() == []
assert unrelated.read_text(encoding="utf-8") == "keep"
assert directory.is_dir()
@@ -0,0 +1,41 @@
"""Retention must include sessions beyond the listing display limit."""
import os
import time
import pytest
from praisonaiagents.storage.base import cleanup_old_sessions, list_json_sessions
@pytest.mark.parametrize("boundary", ["age", "size"])
@pytest.mark.parametrize("suffix", [".json", ".jsonl"])
def test_cleanup_includes_oldest_session_after_ten_thousand_newer_files(tmp_path, boundary, suffix):
oldest = tmp_path / ("oldest" + suffix)
oldest.write_bytes(b"x")
now = time.time()
old_time = now - 90 * 24 * 60 * 60
os.utime(oldest, (old_time, old_time))
for index in range(10000):
(tmp_path / f"new-{index:05d}{suffix}").write_bytes(b"")
neighbor = tmp_path / "unrelated.txt"
neighbor.write_bytes(b"keep")
if boundary == "age":
deleted = cleanup_old_sessions(tmp_path, suffix=suffix, max_age_days=30, max_size_mb=100)
else:
# Only the oldest file contributes bytes to the quota; none exceed age.
deleted = cleanup_old_sessions(tmp_path, suffix=suffix, max_age_days=365, max_size_mb=0)
assert deleted == 1
assert not oldest.exists()
assert len(list(tmp_path.glob("new-*" + suffix))) == 10000
assert neighbor.read_bytes() == b"keep"
def test_listing_keeps_default_limit_and_supports_unbounded_listing(tmp_path):
for index in range(51):
(tmp_path / f"session-{index:02d}.json").write_text("{}", encoding="utf-8")
assert len(list_json_sessions(tmp_path)) == 50
assert len(list_json_sessions(tmp_path, limit=3)) == 3
assert len(list_json_sessions(tmp_path, limit=None)) == 51
@@ -0,0 +1,54 @@
"""Session counts use the UTF-8 writer format, independent of host locale."""
import builtins
import json
from pathlib import Path
import pytest
from praisonaiagents.storage.base import BaseJSONStore, list_json_sessions
@pytest.fixture
def legacy_text_locale(monkeypatch, tmp_path):
original_open = builtins.open
def locale_open(file, mode="r", *args, **kwargs):
if Path(file).parent == tmp_path and mode == "r" and not kwargs.get("encoding"):
kwargs["encoding"] = "cp1252"
return original_open(file, mode, *args, **kwargs)
# Exercise real decoding with a deterministic Windows-style default locale.
monkeypatch.setattr(builtins, "open", locale_open)
@pytest.mark.parametrize("field", ["iterations", "messages", "items", "jsonl"])
def test_utf8_session_counts_survive_legacy_locale(tmp_path, legacy_text_locale, field):
values = ["丁 café 😀", "second"]
suffix = ".jsonl" if field == "jsonl" else ".json"
path = tmp_path / ("session" + suffix)
if field == "jsonl":
path.write_text("".join(json.dumps({"value": value}, ensure_ascii=False) + "\n" for value in values), encoding="utf-8")
else:
BaseJSONStore(path).save({field: values})
before = path.read_bytes()
sessions = list_json_sessions(tmp_path, suffix=suffix)
assert len(sessions) == 1
assert sessions[0].item_count == 2
assert sessions[0].size_bytes == len(before)
assert path.read_bytes() == before
@pytest.mark.parametrize("suffix", [".json", ".jsonl"])
@pytest.mark.parametrize("valid", [True, False])
def test_ascii_and_unreadable_files_keep_existing_behavior(tmp_path, legacy_text_locale, suffix, valid):
path = tmp_path / ("session" + suffix)
content = b'{"items":[1,2]}' if suffix == ".json" else b'{"value":1}\n{"value":2}\n'
path.write_bytes(content if valid else b"\x81")
before = path.read_bytes()
sessions = list_json_sessions(tmp_path, suffix=suffix)
assert len(sessions) == 1
assert sessions[0].item_count == (2 if valid else 0)
assert path.read_bytes() == before
@@ -0,0 +1,52 @@
"""A failed backend constructor releases its real SQLite connection."""
import sqlite3
import pytest
from praisonaiagents.storage import sqlite as connector
from praisonaiagents.storage.backends import SQLiteBackend
@pytest.mark.parametrize("failure", ["reserved_name", "incompatible_schema"])
def test_constructor_closes_connection_after_schema_failure(tmp_path, monkeypatch, failure):
path = str(tmp_path / "store.db")
with sqlite3.connect(path) as seed:
seed.execute("CREATE TABLE preserved (value TEXT)")
seed.execute("INSERT INTO preserved VALUES ('original')")
if failure == "incompatible_schema":
seed.execute("CREATE TABLE praison_storage (wrong_column TEXT)")
seed.close()
opened = []
original = connector.connect
def connect(*args, **kwargs):
conn = original(*args, **kwargs)
opened.append(conn)
return conn
monkeypatch.setattr(connector, "connect", connect)
try:
name = "sqlite_private" if failure == "reserved_name" else "praison_storage"
with pytest.raises(sqlite3.OperationalError) as error:
SQLiteBackend(db_path=path, table_name=name)
expected = "reserved" if failure == "reserved_name" else "no such column"
assert expected in str(error.value)
assert len(opened) == 1
with pytest.raises(sqlite3.ProgrammingError, match="closed database"):
opened[0].execute("SELECT 1")
with sqlite3.connect(path) as check:
assert check.execute("SELECT value FROM preserved").fetchall() == [("original",)]
check.close()
finally:
for conn in opened:
conn.close()
def test_successful_constructor_keeps_connection_usable(tmp_path):
backend = SQLiteBackend(db_path=str(tmp_path / "store.db"))
try:
backend.save("key", {"value": 1})
assert backend.load("key") == {"value": 1}
finally:
backend.close()
@@ -97,8 +97,10 @@ class TestCompactionMaxTokensResolution:
agent = self._make_agent("gpt-4o")
cfg = ExecutionConfig(context_compaction=True)
resolved = agent._resolve_compaction_max_tokens(cfg)
# Well beyond the working budget must trigger compaction.
msgs = [{"role": "user", "content": "A" * 4 * (resolved + 50000)}]
# Well beyond the working budget must trigger compaction. Repeated
# characters compress heavily under tiktoken (~8 chars/token), so use
# 8× not the heuristic 4× when sizing synthetic overflow content.
msgs = [{"role": "user", "content": "A" * 8 * (resolved + 50000)}]
compactor = ContextCompactor(max_tokens=resolved)
assert compactor.needs_compaction(msgs) is True
@@ -308,6 +308,7 @@ def test_a_hosted_backend_reclaims_its_instance_when_collected():
instance kept billing until the provider's own idle timer noticed, which
docker and flyio do not have."""
import gc
import time
from praisonai.integrations.compute_managed_agent import ComputeManagedAgent
@@ -338,6 +339,12 @@ def test_a_hosted_backend_reclaims_its_instance_when_collected():
del backend
gc.collect()
# During live GC the finalizer reclaims fire-and-forget on the shared
# AsyncBridge's background loop, so the shutdown lands a beat after
# gc.collect() returns. Wait (bounded) for it rather than racing it.
deadline = time.monotonic() + 5.0
while not released and time.monotonic() < deadline:
time.sleep(0.01)
assert released == ["inst-1"], "the instance outlived the backend that owned it"
@@ -361,6 +368,7 @@ def test_release_reclaims_even_on_an_event_loop_thread():
ever runs, silently leaking the instance -- so _release must bridge the loop
the way SharedCompute does, not call asyncio.run() directly."""
import asyncio
import time
from praisonai.integrations.compute_managed_agent import _release
@@ -374,4 +382,11 @@ def test_release_reclaims_even_on_an_event_loop_thread():
_release(Fake(), "inst-loop", "docker")
asyncio.run(collect_on_the_loop())
# _release routes through the shared AsyncBridge's background loop
# fire-and-forget, so the shutdown lands a beat after asyncio.run()
# returns. Wait (bounded) for it rather than racing it — the assertion
# still fails if _release ever leaks (never schedules the shutdown).
deadline = time.monotonic() + 5.0
while not released and time.monotonic() < deadline:
time.sleep(0.01)
assert released == ["inst-loop"], "an instance leaked when GC ran on a live loop"
+10 -6
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 5
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.14'",
@@ -2263,7 +2263,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.25.0"
version = "1.30.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -2281,9 +2281,9 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/93/0142dc84a666daf8ad51a34268f34c12fd6fda4f3810c4be2504eecc8212/mcp-1.30.0.tar.gz", hash = "sha256:445414625fce5c295faa505bb11bacece661ab6f4028d57c935db57820b7a3e4", size = 680511, upload-time = "2026-09-07T14:34:15.845Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" },
{ url = "https://files.pythonhosted.org/packages/f5/f4/e58bc33317c92a0203664daaf00bf6f41166cc0149e5d6870a03f7cd004a/mcp-1.30.0-py3-none-any.whl", hash = "sha256:666edb5009503e1047c9d60346a756f94b261f05cc2625f23d41c728ffc484d0", size = 234581, upload-time = "2026-09-07T14:34:14.266Z" },
]
[[package]]
@@ -3123,7 +3123,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.7.9"
version = "1.7.10"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -3219,6 +3219,9 @@ os = [
{ name = "pyjwt" },
{ name = "uvicorn" },
]
parallel-search = [
{ name = "mcp" },
]
sandbox = [
{ name = "praisonai-sandbox" },
]
@@ -3250,6 +3253,7 @@ requires-dist = [
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.83.14" },
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.20.0" },
{ name = "mcp", marker = "extra == 'parallel-search'", specifier = ">=1.30.0,<2" },
{ name = "mem0ai", marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
{ name = "mem0ai", extras = ["graph"], marker = "extra == 'graph'", specifier = ">=0.1.0" },
{ name = "motor", marker = "extra == 'mongodb'", specifier = ">=3.4.0" },
@@ -3288,7 +3292,7 @@ requires-dist = [
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
]
provides-extras = ["sandbox", "mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
provides-extras = ["sandbox", "mcp", "parallel-search", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
[[package]]
name = "primp"
+1
View File
@@ -1,3 +1,4 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/MervinPraison/PraisonAI/main/src/praisonai-bot/praisonai_bot/bots/gateway.schema.json
gateway:
host: 127.0.0.1
port: 8765
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.8"
__version__ = "0.1.9"
@@ -23,6 +23,41 @@ from pydantic import (
logger = logging.getLogger(__name__)
#: Stable, published URL for the ``gateway.yaml`` / ``bot.yaml`` JSON Schema,
#: mirroring the ``agents.yaml`` convention (``AGENTS_SCHEMA_URL``). Editors that
#: speak the YAML language server use this via a leading
#: ``# yaml-language-server: $schema=<url>`` header to provide autocomplete,
#: inline validation, and hover docs while authoring the gateway/bot config.
GATEWAY_SCHEMA_URL = (
"https://raw.githubusercontent.com/MervinPraison/PraisonAI/main/"
"src/praisonai-bot/praisonai_bot/bots/gateway.schema.json"
)
#: Leading YAML comment prepended to scaffolded ``gateway.yaml`` / ``bot.yaml``
#: files so editors wire up validation out of the box. A leading comment is
#: ignored by ``yaml.safe_load``, so execution is unaffected.
GATEWAY_SCHEMA_HEADER = f"# yaml-language-server: $schema={GATEWAY_SCHEMA_URL}\n"
def resolve_model_by_provider() -> str:
"""Resolve a default model for whichever provider credential is present.
Reuses the existing detection helper
(``praisonai_code.llm.env.default_model_for_available_provider``) through
the lazy cross-tier bridge so the gateway path leads with the provider the
operator already has (Anthropic/Gemini/Ollama/…) instead of assuming
OpenAI. Falls back to ``gpt-4o-mini`` only when no provider is detected or
the helper is unavailable.
"""
try:
from praisonai_bot._code_bridge import import_code_module
env = import_code_module("praisonai_code.llm.env")
return env.default_model_for_available_provider()
except Exception: # pragma: no cover - detection is best-effort
return "gpt-4o-mini"
def _register_redaction(value: str) -> None:
"""Register a resolved secret value for log redaction (best-effort).
@@ -41,7 +76,12 @@ class AgentConfigSchema(BaseModel):
"""Schema for agent configuration in bot.yaml."""
name: str = "assistant"
instructions: str = ""
model: Optional[str] = "gpt-4o-mini"
# Provider-agnostic by default (Issue #5609). ``None`` / ``"auto"`` resolve
# at load time to the model for whichever provider credential is actually
# present (via ``resolved_model()``), so an operator with only an
# Anthropic/Gemini/Ollama key is not silently handed an OpenAI model. An
# explicit ``model:`` always wins.
model: Optional[str] = None
llm: Optional[str] = None # Alias for model
memory: bool = False
tools: List[str] = Field(default_factory=list)
@@ -58,6 +98,19 @@ class AgentConfigSchema(BaseModel):
self.model = self.llm
return self
def resolved_model(self) -> str:
"""Return the effective model, detecting the provider when unset.
An explicit, non-``"auto"`` ``model`` (or ``llm`` alias) always wins.
When the model is unset or ``"auto"``, resolve it to the detected
provider's default (Issue #5609), with ``gpt-4o-mini`` as the
last-resort fallback handled inside ``resolve_model_by_provider``.
"""
model = self.model
if model and model.strip().lower() != "auto":
return model
return resolve_model_by_provider()
class SessionResetConfigSchema(BaseModel):
"""Schema for session reset policy configuration."""
@@ -1506,6 +1559,184 @@ class GatewayConfigSchema(BaseModel):
BotYamlSchema = GatewayConfigSchema
def _allow_integer_owner_id(node: Any) -> None:
"""Widen an ``owner_user_id`` property to also accept an integer (#5414).
``GatewayConfigSchema``/``ChannelConfigSchema`` type ``owner_user_id`` as
``Optional[str]`` but a ``mode="before"`` validator (``_coerce_owner_user_id``)
accepts an unquoted numeric YAML id (``owner_user_id: 987654321``) by casting
it to ``str``. Pydantic emits the schema from the *field type* only, so the
published schema would reject a config the runtime explicitly accepts. Mirror
the runtime coercion by adding ``integer`` to the allowed types so editor/CI
validation agrees with the gateway.
"""
if not isinstance(node, dict):
return
props = node.get("properties")
if not isinstance(props, dict):
return
owner = props.get("owner_user_id")
if not isinstance(owner, dict):
return
variants = owner.get("anyOf")
if isinstance(variants, list):
has_int = any(
isinstance(v, dict) and v.get("type") == "integer" for v in variants
)
if not has_int:
variants.append({"type": "integer"})
elif owner.get("type") == "string":
owner.pop("type", None)
owner["anyOf"] = [{"type": "string"}, {"type": "integer"}]
def _allow_env_ref_list(node: Any, field_names: tuple) -> None:
"""Allow an array-typed field to also be a single ``${ENV}`` string (#5414).
``${VAR}`` references are expanded *before* validation at load time, so an
operator may author ``allowed_users: ${TELEGRAM_ALLOWED_USERS}`` and have it
expand to a list. Editors validate the *unexpanded* YAML, so the published
schema must accept the string form for these fields too; otherwise it
false-flags a config the runtime happily accepts.
"""
if not isinstance(node, dict):
return
props = node.get("properties")
if not isinstance(props, dict):
return
for name in field_names:
field = props.get(name)
if not isinstance(field, dict):
continue
if field.get("type") == "array":
array_variant = {k: v for k, v in field.items() if k not in ("default", "title")}
new_field: Dict[str, Any] = {
"anyOf": [array_variant, {"type": "string"}]
}
if "default" in field:
new_field["default"] = field["default"]
if "title" in field:
new_field["title"] = field["title"]
props[name] = new_field
elif isinstance(field.get("anyOf"), list):
variants = field["anyOf"]
if not any(
isinstance(v, dict) and v.get("type") == "string" for v in variants
):
variants.append({"type": "string"})
def gateway_config_json_schema() -> Dict[str, Any]:
"""Canonical JSON Schema for ``gateway.yaml`` / ``bot.yaml`` (draft-07).
Derived directly from :class:`GatewayConfigSchema` (Pydantic's
``model_json_schema``) and decorated with the standard ``$schema``/``$id``/
``title`` metadata so the artefact is self-describing and mirrors the
``agents.yaml`` convention (``generate_agents_schema``). This is the single
source of truth surfaced to editors (via the ``# yaml-language-server``
header) and to CI linting, so a mistyped key is flagged at author time
instead of only at gateway startup.
The raw ``model_json_schema()`` is post-processed so the *published* schema
faithfully reflects what the runtime actually validates (#5414):
- ``gateway`` / ``hooks`` are stored as opaque ``Dict``/``List[Dict]`` on the
model for downstream dict-style access, but validated field-by-field at
load time via :class:`GatewayServerSchema` / :class:`HookSchema`
(``extra="forbid"`` for the server block). Inline those typed schemas so a
misspelled server knob (``drain_timout``) or hook key is caught at author
time, not only at startup.
- ``owner_user_id`` (top-level and per-channel) also accepts an integer,
mirroring the runtime ``str(...)`` coercion of unquoted numeric ids.
- Unknown top-level keys are rejected so a typo (``auto_enable_from_en``)
that the runtime silently ignores is surfaced while authoring. Channel
configs keep ``additionalProperties`` open for plugin-declared keys.
"""
base = GatewayConfigSchema.model_json_schema()
defs = base.setdefault("$defs", {})
# Inline the typed gateway-server + hook schemas so the published schema
# carries the same field-by-field constraints the runtime enforces, instead
# of the opaque ``{"type": "object"}`` Pydantic emits for ``Dict[str, Any]``.
server_schema = GatewayServerSchema.model_json_schema(
ref_template="#/$defs/{model}"
)
for name, definition in server_schema.pop("$defs", {}).items():
defs.setdefault(name, definition)
defs["GatewayServerSchema"] = server_schema
hook_schema = HookSchema.model_json_schema(ref_template="#/$defs/{model}")
for name, definition in hook_schema.pop("$defs", {}).items():
defs.setdefault(name, definition)
defs["HookSchema"] = hook_schema
props = base.get("properties", {})
if "gateway" in props:
props["gateway"] = {
"anyOf": [{"$ref": "#/$defs/GatewayServerSchema"}, {"type": "null"}],
"default": None,
"title": props["gateway"].get("title", "Gateway"),
}
if "hooks" in props:
props["hooks"] = {
"anyOf": [
{"type": "array", "items": {"$ref": "#/$defs/HookSchema"}},
{"type": "null"},
],
"default": None,
"title": props["hooks"].get("title", "Hooks"),
}
# Accept unquoted numeric owner ids wherever they appear (top level +
# ChannelConfigSchema), matching the runtime coercion.
_allow_integer_owner_id(base)
channel_def = defs.get("ChannelConfigSchema")
if isinstance(channel_def, dict):
_allow_integer_owner_id(channel_def)
# ``${VAR}`` env references are substituted BEFORE validation at load time
# (``validate_gateway_config``), so a list-typed field may legitimately be
# authored as a single ``${ENV}`` string that expands to a list. The
# scaffolder writes ``allowed_users: ${TELEGRAM_ALLOWED_USERS}``, so an
# editor validating the *authored* (unexpanded) YAML must accept the
# string form too — otherwise the schema false-flags a config the runtime
# accepts. Widen the array-typed env-ref channel fields accordingly.
_allow_env_ref_list(channel_def, ("allowed_users", "allowlist", "blocklist"))
# Reject unknown top-level keys so an author-time typo is caught (the runtime
# silently ignores it, keeping the mistyped opt-out ineffective). Channels
# keep ``additionalProperties`` open for plugin-declared config keys.
#
# ``config_version`` is not a ``GatewayConfigSchema`` field — it is stamped at
# the top level by the doctor/start config-version machinery (and shipped in
# the sample ``gateway.yaml``). Declare it so the stricter schema accepts a
# stamped/migrated config instead of false-flagging it.
base.setdefault("properties", {}).setdefault(
"config_version",
{
"type": "integer",
"title": "Config Version",
"description": (
"Stamped by `praisonai gateway doctor --fix` / `start` to track "
"config migrations. Managed automatically."
),
},
)
base.setdefault("additionalProperties", False)
schema: Dict[str, Any] = {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": GATEWAY_SCHEMA_URL,
**base,
"title": "PraisonAI Gateway / Bot Configuration",
"description": (
"Schema for gateway.yaml / bot.yaml consumed by the PraisonAI "
"gateway and single-bot runtime (gateway server, agents, channels, "
"routing, schedules, hooks, streaming and delivery)."
),
}
return schema
def validate_gateway_config(raw: Dict[str, Any], apply_env_substitution: bool = True) -> GatewayConfigSchema:
"""Validate a raw YAML dict against the unified gateway config schema.
@@ -23,11 +23,18 @@ from __future__ import annotations
import asyncio
import logging
import os
import secrets
import time
import uuid
from typing import Any, Dict, Optional
from typing import Any, Dict, Iterable, Optional
from ._approval_base import DEFAULT_APPROVAL_TIMEOUT, DurableApprovalMixin
from ._approval_base import (
DEFAULT_APPROVAL_TIMEOUT,
DurableApprovalMixin,
is_authorized_actor,
normalize_approvers,
)
logger = logging.getLogger(__name__)
@@ -45,6 +52,14 @@ class HTTPApproval(DurableApprovalMixin):
host: Bind address (default ``127.0.0.1``).
port: Port to listen on (default ``8899``).
timeout: Max seconds to wait for a response (default 300).
allowed_approvers: Optional allowlist of approver IDs permitted to
resolve an approval. When provided, each pending approval mints a
single-use token that is carried only in the per-approval URL; the
resolver must present that token (and, when the request supplies an
``approver``, be in the allowlist) or the decision is rejected. When
``None`` (default) any visitor may decide (legacy behaviour,
backward compatible). Falls back to a comma-separated
``HTTP_APPROVERS`` env var when not passed.
Example::
@@ -57,11 +72,17 @@ class HTTPApproval(DurableApprovalMixin):
host: str = "127.0.0.1",
port: int = 8899,
timeout: float = DEFAULT_APPROVAL_TIMEOUT,
allowed_approvers: Optional[Iterable[str]] = None,
store: Optional[Any] = None,
):
self._host = host
self._port = port
self._timeout = timeout
if allowed_approvers is None:
_env = os.environ.get("HTTP_APPROVERS", "").strip()
if _env:
allowed_approvers = [a.strip() for a in _env.split(",") if a.strip()]
self._allowed_approvers = normalize_approvers(allowed_approvers)
self._pending: Dict[str, Optional[Dict[str, Any]]] = {}
self._server_started = False
self._runner: Optional[Any] = None
@@ -91,6 +112,20 @@ class HTTPApproval(DurableApprovalMixin):
self._server_started = True
logger.info(f"HTTPApproval server started on http://{self._host}:{self._port}")
def _token_ok(self, pending: Dict[str, Any], request) -> bool:
"""Return whether *request* carries the per-approval token.
The token is minted per pending approval and embedded only in the URL
handed to the authorised approver. A constant-time comparison is used so
the endpoint does not leak the token via timing. When no allowlist is
configured there is no token, so this is a no-op (legacy behaviour).
"""
expected = pending.get("token")
if not expected:
return True
supplied = request.query.get("token") or request.headers.get("X-Approval-Token", "")
return secrets.compare_digest(str(supplied), str(expected))
async def _handle_page(self, request) -> Any:
"""Serve the approval page HTML."""
from aiohttp import web
@@ -101,11 +136,18 @@ class HTTPApproval(DurableApprovalMixin):
if pending is None:
return web.Response(text="Approval request not found or already decided.", status=404)
if not self._token_ok(pending, request):
logger.warning(
"Unauthorised HTTP approval page access for %s", request_id,
)
return web.Response(text="Not authorized to view this approval.", status=403)
if pending.get("decided"):
return web.Response(text="This approval has already been decided.", status=200)
info = pending.get("info", {})
html = self._build_html(request_id, info)
token = pending.get("token")
html = self._build_html(request_id, info, token=token)
return web.Response(text=html, content_type="text/html")
async def _handle_decide(self, request) -> Any:
@@ -117,6 +159,13 @@ class HTTPApproval(DurableApprovalMixin):
if pending is None:
return web.Response(text="Not found", status=404)
if not self._token_ok(pending, request):
logger.warning(
"Unauthorised HTTP approval decision attempt for %s", request_id,
)
return web.json_response({"ok": False, "error": "unauthorized"}, status=403)
if pending.get("decided"):
return web.Response(text="Already decided", status=200)
@@ -125,18 +174,32 @@ class HTTPApproval(DurableApprovalMixin):
except Exception:
body = {}
approver = body.get("approver", "http_user")
# Authorisation boundary: when an allowlist is configured a free-text
# ``approver`` must be one of the authorised approvers. The per-approval
# token already proves the resolver received the private URL; the
# allowlist additionally pins the recorded approver identity.
if not is_authorized_actor(approver, self._allowed_approvers):
logger.warning(
"Unauthorised HTTP approver %r for %s", approver, request_id,
)
return web.json_response({"ok": False, "error": "unauthorized"}, status=403)
decision = body.get("decision", "deny")
pending["decided"] = True
pending["approved"] = decision == "approve"
pending["reason"] = body.get("reason", f"{'Approved' if pending['approved'] else 'Denied'} via HTTP dashboard")
pending["approver"] = body.get("approver", "http_user")
pending["approver"] = approver
return web.json_response({"ok": True, "decision": decision})
def _build_html(self, request_id: str, info: Dict[str, Any]) -> str:
def _build_html(self, request_id: str, info: Dict[str, Any], token: Optional[str] = None) -> str:
"""Build a minimal approval page."""
import html as html_module
token_qs = f"?token={html_module.escape(str(token))}" if token else ""
tool_name = html_module.escape(str(info.get("tool_name", "unknown")))
risk_level = html_module.escape(str(info.get("risk_level", "unknown")))
agent_name = html_module.escape(str(info.get("agent_name", "")))
@@ -181,7 +244,7 @@ td {{ padding: 8px; border-bottom: 1px solid #333; }} code {{ background: #2a2a4
<div class="done" id="result" style="display:none"></div>
<script>
async function decide(d) {{
const res = await fetch('/approve/{request_id}/decide', {{
const res = await fetch('/approve/{request_id}/decide{token_qs}', {{
method: 'POST', headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{decision: d}})
}});
@@ -202,9 +265,13 @@ async function decide(d) {{
await self._ensure_server()
request_id = str(uuid.uuid4())
# Mint a single-use token bound to this approval when an allowlist is
# configured, so only the holder of the private URL can resolve it.
token = secrets.token_urlsafe(32) if self._allowed_approvers is not None else None
self._pending[request_id] = {
"decided": False,
"approved": False,
"token": token,
"info": {
"tool_name": request.tool_name,
"arguments": request.arguments,
@@ -214,6 +281,8 @@ async function decide(d) {{
}
url = f"http://{self._host}:{self._port}/approve/{request_id}"
if token:
url = f"{url}?token={token}"
logger.info(f"HTTPApproval: Waiting for decision at {url}")
print(f"\n🔗 Open this URL to approve/deny:\n {url}\n")
@@ -105,6 +105,78 @@ def resolve_activity_category(tool_name: Optional[str]) -> str:
return "default"
# Separator between the reply body and the runtime footer line. A blank line
# then an em-dash keeps the footer visually distinct without markup that could
# break on a given platform.
_FOOTER_SEP = "\n\n— "
def build_footer_line(
model: Optional[str] = None,
served_model: Optional[str] = None,
context_pct: Optional[float] = None,
latency_s: Optional[float] = None,
) -> str:
"""Build a privacy-safe, per-reply runtime footer line.
Renders only the fields that are available, e.g. ``gpt-4o · 38% ctx · 4.2s``.
A missing field is omitted rather than shown as a placeholder, so a partial
footer is always well-formed. Only the model name, context-window fill
percentage and turn latency are surfaced — never tool args, URLs, paths or
message content. Returns ``""`` when no field is available.
Args:
model: Configured model id (e.g. ``"gpt-4o"``).
served_model: Deployment that actually served the turn, shown as
``model (served: X)`` when it differs from ``model`` (failover /
routing visibility).
context_pct: Session context-window fill, 0–100.
latency_s: Turn latency in seconds.
Returns:
A single footer line (without the leading separator) or ``""``.
"""
parts: list = []
if model:
model_str = str(model).strip()
if model_str:
served = str(served_model).strip() if served_model else ""
if served and served != model_str:
parts.append(f"{model_str} (served: {served})")
else:
parts.append(model_str)
if context_pct is not None:
try:
pct = float(context_pct)
if pct == pct and pct not in (float("inf"), float("-inf")):
parts.append(f"{int(round(pct))}% ctx")
except (TypeError, ValueError):
pass
if latency_s is not None:
try:
secs = float(latency_s)
if secs == secs and secs >= 0 and secs not in (float("inf"), float("-inf")):
parts.append(f"{secs:.1f}s")
except (TypeError, ValueError):
pass
return " · ".join(parts)
def append_footer(reply: str, footer_line: str) -> str:
"""Append a footer line to a final reply, separated by a blank line.
Returns ``reply`` unchanged when ``footer_line`` is empty. Only ever used on
the final reply — never on partial/streamed frames.
"""
if not footer_line:
return reply
return f"{reply}{_FOOTER_SEP}{footer_line}"
def strip_reasoning_tags(text: str) -> str:
"""Remove ``<think>``/``<reasoning>`` spans from streamed content.
@@ -201,6 +273,25 @@ class StreamingConfig:
)
def build_streaming_config(config: Any) -> Optional["StreamingConfig"]:
"""Derive a :class:`StreamingConfig` from a ``BotConfig``.
Shared by every channel adapter (Telegram, Discord, Slack, …) so a
configured ``streaming`` flag is wired to the same ``DraftStreamer`` engine
identically everywhere, instead of each adapter re-deriving (or silently
ignoring) the knob. Returns ``None`` when streaming is off so the caller's
non-streaming path is unchanged.
"""
if not getattr(config, "streaming", False):
return None
interval_ms = getattr(config, "stream_edit_interval_ms", 700) or 700
return StreamingConfig(
mode=StreamingMode.DRAFT,
min_interval=interval_ms / 1000.0,
min_delta=50,
)
class BotAdapter(Protocol):
"""Protocol for bot adapters that support message editing."""
@@ -27,9 +27,14 @@ import logging
import os
import time
import uuid
from typing import Any, Dict, Optional
from typing import Any, Dict, Iterable, Optional
from ._approval_base import DEFAULT_APPROVAL_TIMEOUT, DurableApprovalMixin
from ._approval_base import (
DEFAULT_APPROVAL_TIMEOUT,
DurableApprovalMixin,
is_authorized_actor,
normalize_approvers,
)
logger = logging.getLogger(__name__)
@@ -51,6 +56,13 @@ class WebhookApproval(DurableApprovalMixin):
headers: Extra HTTP headers (e.g. auth tokens).
timeout: Max seconds to wait for a response (default 300).
poll_interval: Seconds between status polls (default 5.0).
allowed_approvers: Optional allowlist of approver IDs permitted to
resolve an approval. When provided, a decision whose ``approver``
is not in the allowlist is dropped (polling continues) so an
unauthorised responder to the status endpoint cannot resolve a
gated tool. When ``None`` (default) any responder may decide
(legacy behaviour, backward compatible). Falls back to a
comma-separated ``WEBHOOK_APPROVERS`` env var when not passed.
Example::
@@ -71,6 +83,7 @@ class WebhookApproval(DurableApprovalMixin):
headers: Optional[Dict[str, str]] = None,
timeout: float = DEFAULT_APPROVAL_TIMEOUT,
poll_interval: float = 5.0,
allowed_approvers: Optional[Iterable[str]] = None,
store: Optional[Any] = None,
):
self._webhook_url = webhook_url or os.environ.get("APPROVAL_WEBHOOK_URL", "")
@@ -82,11 +95,29 @@ class WebhookApproval(DurableApprovalMixin):
self._headers = headers or {}
self._timeout = timeout
self._poll_interval = poll_interval
if allowed_approvers is None:
_env = os.environ.get("WEBHOOK_APPROVERS", "").strip()
if _env:
allowed_approvers = [a.strip() for a in _env.split(",") if a.strip()]
self._allowed_approvers = normalize_approvers(allowed_approvers)
self._init_store(store)
def __repr__(self) -> str:
return f"WebhookApproval(webhook_url={self._webhook_url!r})"
def _approver_authorized(self, approver: Any) -> bool:
"""Return whether *approver* may resolve an approval.
No-op (always ``True``) when no allowlist is configured (legacy
behaviour). Otherwise the responder's ``approver`` must be in the
allowlist, so an unauthorised party that can reach the status endpoint
cannot resolve a gated tool.
"""
return is_authorized_actor(
None if approver is None else str(approver),
self._allowed_approvers,
)
# ── Internal HTTP helper ───────────────────────────────────────────
async def _http_request(
@@ -158,7 +189,9 @@ class WebhookApproval(DurableApprovalMixin):
# Check for immediate decision
if isinstance(post_data, dict):
if "approved" in post_data:
if "approved" in post_data and self._approver_authorized(
post_data.get("approver")
):
decision = ApprovalDecision(
approved=bool(post_data["approved"]),
reason=post_data.get("reason", "Webhook immediate response"),
@@ -167,6 +200,11 @@ class WebhookApproval(DurableApprovalMixin):
)
await self._resolve_pending(request, decision)
return decision
if "approved" in post_data:
logger.warning(
"Dropping webhook immediate decision from unauthorised "
"approver %r", post_data.get("approver"),
)
# 2. Poll for decision
status_url = self._status_url or f"{self._webhook_url}/{request_id}"
@@ -227,6 +265,17 @@ class WebhookApproval(DurableApprovalMixin):
if status == "pending":
continue
has_decision = (
"approved" in data
or status in ("approved", "approve", "yes", "denied", "deny", "rejected", "no")
)
if has_decision and not self._approver_authorized(data.get("approver")):
logger.warning(
"Dropping webhook decision from unauthorised approver %r "
"for %s", data.get("approver"), request_id,
)
continue
if "approved" in data:
return ApprovalDecision(
approved=bool(data["approved"]),
@@ -45,6 +45,12 @@ from ._commands import (
)
from ._failure import failure_reply_text
from ._session import BotSessionManager
from ._streaming import (
StreamingConfig,
StreamingMode,
DraftStreamer,
build_streaming_config,
)
from ._debounce import InboundDebouncer
from ._ack import AckReactor
from ._unknown_user import UnknownUserHandler, BotContext
@@ -102,7 +108,12 @@ class DiscordBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
# Initialize allow_silence from config
self._allow_silence = getattr(self.config, 'allow_silence', False)
# Initialize streaming config based on BotConfig (shared with Telegram /
# Slack so ``streaming.mode: draft|auto`` is honoured identically across
# channels that support in-place edits — Issue #5415).
self._streaming_config = build_streaming_config(self.config)
self._is_running = False
self._bot_user: Optional[BotUser] = None
self._client = None
@@ -139,6 +150,15 @@ class DiscordBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
self._register_interactive_handlers()
self._bot_context: Optional[BotContext] = None
def configure_streaming(self, config: StreamingConfig) -> None:
"""Configure streaming reply mode.
Args:
config: Streaming configuration. Set mode=StreamingMode.OFF to disable.
"""
self._streaming_config = config
logger.debug("DiscordBot: streaming configured, mode=%s", config.mode)
@property
def is_running(self) -> bool:
return self._is_running
@@ -474,20 +494,95 @@ class DiscordBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
async def _send_agent_response():
text_to_send = await self._debouncer.debounce(user_id, bot_message.text)
channel_id = str(message.channel.id)
# Streaming path: progressively edit a placeholder in
# place using the shared DraftStreamer, mirroring Telegram
# (Issue #5415). Only engaged when streaming is configured.
streaming_enabled = (
self._streaming_config is not None
and self._streaming_config.mode != StreamingMode.OFF
)
if streaming_enabled:
streamer = DraftStreamer(
adapter=self,
channel_id=channel_id,
config=self._streaming_config,
platform="discord",
)
placeholder_message_id = await streamer.start()
try:
response = await self._session.chat(
self._agent, user_id, text_to_send,
chat_id=channel_id,
user_name=str(getattr(message.author, "name", "")),
message_id=str(message.id),
account=getattr(self.config, "account", "default"),
stream_callback=streamer.on_event,
)
send_result = self.fire_message_sending(
channel_id, str(response),
)
if send_result["cancel"]:
try:
await self.delete_message(
channel_id, placeholder_message_id
)
except Exception:
pass
return
final_content = send_result["content"]
# A streamed answer longer than Discord's hard
# cap cannot be delivered by editing the single
# placeholder (both the edit and the send
# fallback 400 on >2000 chars), which would drop
# the whole reply. Fall back to the chunked
# reply path so long answers are split and keep
# the reply reference (Greptile #3 / #5).
_cap = min(self.config.max_message_length, 2000)
if len(final_content) > _cap:
try:
await self.delete_message(
channel_id, placeholder_message_id
)
except Exception:
pass
await self._send_long_message(
message.channel, final_content,
reference=message,
)
else:
await streamer.finalize(final_content)
self.fire_message_sent(channel_id, final_content)
except Exception:
try:
await self.delete_message(
channel_id, placeholder_message_id
)
except Exception:
pass
raise
if ack_ctx:
await self._ack.done(
ack_ctx, react_fn=_discord_react,
unreact_fn=_discord_unreact,
)
return
response = await self._session.chat(
self._agent, user_id, text_to_send,
chat_id=str(message.channel.id),
chat_id=channel_id,
user_name=str(getattr(message.author, "name", "")),
message_id=str(message.id),
account=getattr(self.config, "account", "default"),
)
send_result = self.fire_message_sending(
str(message.channel.id), str(response),
channel_id, str(response),
)
if send_result["cancel"]:
return
await self._send_long_message(message.channel, send_result["content"], reference=message)
self.fire_message_sent(str(message.channel.id), send_result["content"])
self.fire_message_sent(channel_id, send_result["content"])
# Done reaction - show completion
if ack_ctx:
await self._ack.done(ack_ctx, react_fn=_discord_react, unreact_fn=_discord_unreact)
File diff suppressed because it is too large. Load diff
+152 -1
View File
@@ -71,6 +71,12 @@ from ._commands import (
build_command_access_policy,
)
from ._session import BotSessionManager
from ._streaming import (
StreamingConfig,
StreamingMode,
DraftStreamer,
build_streaming_config,
)
from ._debounce import InboundDebouncer
from ._ack import AckReactor
from ._unknown_user import UnknownUserHandler, BotContext
@@ -144,7 +150,12 @@ class SlackBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
# Initialize allow_silence from config
self._allow_silence = getattr(self.config, 'allow_silence', False)
# Initialize streaming config based on BotConfig (shared with Telegram /
# Discord so ``streaming.mode: draft|auto`` is honoured identically
# across channels that support in-place edits — Issue #5415).
self._streaming_config = build_streaming_config(self.config)
self._is_running = False
self._bot_user: Optional[BotUser] = None
self._app = None
@@ -186,6 +197,111 @@ class SlackBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
# Audio capabilities
self._stt_enabled: bool = False
def configure_streaming(self, config: StreamingConfig) -> None:
"""Configure streaming reply mode.
Args:
config: Streaming configuration. Set mode=StreamingMode.OFF to disable.
"""
self._streaming_config = config
logger.debug("SlackBot: streaming configured, mode=%s", config.mode)
def _reply_needs_thread(self, thread_id: str) -> bool:
"""Whether a reply must be posted into a thread rather than the root.
The shared ``DraftStreamer`` posts/edits its placeholder at the channel
root (it only knows ``send_message(channel, text)``), so when a reply
belongs in a thread — the inbound message was itself in a thread, or
``reply_in_thread`` is configured — streaming in place would strand the
answer in the main channel (Greptile #2 / #5417). In that case the
caller falls back to the existing threaded send path.
"""
return bool(thread_id) or bool(getattr(self.config, "reply_in_thread", False))
async def _maybe_stream_reply(
self,
channel_id: str,
user_id: str,
text: str,
*,
thread_id: str = "",
message_id: str = "",
) -> Optional[str]:
"""Progressively stream a reply via DraftStreamer when configured.
Returns the final (hook-processed) response text when the streaming
path handled delivery, or ``None`` when streaming is disabled — or when
the reply must be threaded (the channel-agnostic streamer can only post
at the channel root) — so the caller falls back to its normal
single-message, thread-aware, media-aware send (Issue #5415).
"""
streaming_enabled = (
self._streaming_config is not None
and self._streaming_config.mode != StreamingMode.OFF
)
if not streaming_enabled:
return None
# Defer to the standard send path when the reply must land in a thread:
# the shared streamer posts/edits at the channel root and would
# otherwise strand a threaded answer in the main channel (Greptile #2).
if self._reply_needs_thread(thread_id):
return None
streamer = DraftStreamer(
adapter=self,
channel_id=channel_id,
config=self._streaming_config,
platform="slack",
)
placeholder_message_id = await streamer.start()
try:
response = await self._session.chat(
self._agent, user_id, text,
chat_id=channel_id,
thread_id=thread_id,
message_id=message_id,
account=getattr(self.config, "account", "default"),
stream_callback=streamer.on_event,
)
send_result = self.fire_message_sending(channel_id, str(response))
if send_result["cancel"]:
try:
await self.delete_message(channel_id, placeholder_message_id)
except Exception:
pass
return ""
# Extract any MEDIA: directives before finalizing so an audio
# reply is uploaded via Slack's file path instead of being edited
# into the placeholder as literal "MEDIA:/path" text (Greptile #4).
parsed = split_media_from_output(send_result["content"])
text_content = parsed["text"]
media_urls = parsed.get("media_urls", [])
await streamer.finalize(text_content if text_content else send_result["content"])
for media_path in media_urls:
if not os.path.exists(media_path):
logger.warning(f"Media file not found: {media_path}")
continue
if is_audio_file(media_path) and self._client:
try:
await self._client.files_upload_v2(
channel=channel_id,
file=media_path,
title=os.path.basename(media_path),
)
except Exception as e:
logger.error(f"Failed to upload audio to Slack: {e}")
return send_result["content"]
except Exception:
try:
await self.delete_message(channel_id, placeholder_message_id)
except Exception:
pass
raise
def enable_stt(self, enabled: bool = True) -> None:
"""Enable STT for audio file transcription."""
self._stt_enabled = enabled
@@ -622,6 +738,26 @@ class SlackBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
user_id = event.get("user", "unknown")
logger.info(f"Message received: {text[:100]}...")
text = await self._debouncer.debounce(user_id, text)
# Streaming path: progressively edit a placeholder in place
# using the shared DraftStreamer, mirroring Telegram/Discord
# (Issue #5415). Returns None when streaming is disabled.
streamed = await self._maybe_stream_reply(
str(channel_id) if channel_id else "",
user_id, text,
thread_id=event.get("thread_ts", "") or "",
message_id=event.get("ts", ""),
)
if streamed is not None:
if streamed:
self.fire_message_sent(channel_id, streamed)
if ack_ctx and self._client:
await self._ack.done(
ack_ctx, react_fn=_slack_react,
unreact_fn=_slack_unreact,
)
return
response = await self._session.chat(
self._agent, user_id, text,
chat_id=str(channel_id) if channel_id else "",
@@ -707,6 +843,21 @@ class SlackBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
try:
user_id = event.get("user", "unknown")
logger.info(f"@mention received: {text[:100]}...")
# Streaming path: progressively edit a placeholder in place
# using the shared DraftStreamer (Issue #5415). Returns None
# when streaming is disabled so the plain path runs.
streamed = await self._maybe_stream_reply(
str(event.get("channel", "")),
user_id, text,
thread_id=event.get("thread_ts", "") or "",
message_id=event.get("ts", ""),
)
if streamed is not None:
if streamed:
self.fire_message_sent(event.get("channel", ""), streamed)
return
response = await self._session.chat(
self._agent, user_id, text,
chat_id=str(event.get("channel", "")),
@@ -64,7 +64,13 @@ from ._album import (
from ._ack import AckReactor
from ._unknown_user import UnknownUserHandler, BotContext
from ._pairing_ui import PairingUIBuilder, PairingCallbackHandler
from ._streaming import StreamingConfig, StreamingMode, DraftStreamer
from ._streaming import (
StreamingConfig,
StreamingMode,
DraftStreamer,
build_footer_line,
append_footer,
)
from ._rate_limit import RateLimiter
from ._resilience import deliver_with_retry, BackoffPolicy, TELEGRAM_BACKOFF
from ._dlq import OutboundDLQ
@@ -251,6 +257,34 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
"""
self._streaming_config = config
logger.debug("TelegramBot: streaming configured, mode=%s", config.mode)
def _footer_line(self, turn_started_at: Optional[float]) -> str:
"""Build the opt-in runtime footer line for a final reply.
Returns ``""`` (no footer) unless ``config.footer`` is enabled. The line
is privacy-safe: it surfaces only the configured model name and turn
latency, omitting any field that is unavailable. Best-effort — a build
error never blocks the reply.
The line is appended to the *text* of the final reply only (never to
interim streamed frames, and never to the text handed to voice/TTS or
media synthesis), so voice notes don't narrate the footer.
"""
if not getattr(self.config, "footer", False):
return ""
try:
model = None
agent = self._agent
if agent is not None:
llm = getattr(agent, "llm", None)
model = llm if isinstance(llm, str) else None
latency = None
if turn_started_at is not None:
latency = max(0.0, time.monotonic() - turn_started_at)
return build_footer_line(model=model, latency_s=latency)
except Exception as e: # pragma: no cover — never block a reply on footer
logger.debug("footer build skipped: %s", e)
return ""
def enable_stt(self, enabled: bool = True) -> None:
"""Enable STT for voice message transcription."""
@@ -705,6 +739,10 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
message.content = merged.caption
try:
# Mark the turn start so the opt-in runtime footer can report
# latency ("inbound received -> reply delivered"). Cheap and
# unused unless config.footer is enabled.
turn_started_at = time.monotonic()
message_text = await self._debouncer.debounce(user_id, message.content)
# Render any resolved reply/quote context (Issue #5223) as a
@@ -774,8 +812,15 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
text_content = parsed["text"]
media_urls = parsed.get("media_urls", [])
# Finalize with text content (after hook processing and media extraction)
await streamer.finalize(text_content if text_content else send_result["content"])
# Finalize with text content (after hook processing and media extraction).
# Append the opt-in runtime footer to the FINAL reply only
# (never to interim streamed frames, and never to the
# voice-synthesis text below — matching the non-streaming path).
final_text = text_content if text_content else send_result["content"]
final_text = append_footer(
final_text, self._footer_line(turn_started_at)
)
await streamer.finalize(final_text)
# Issue #3623: the streamed reply is delivered as text
# by the streamer, so the outbound voice-reply policy
@@ -879,6 +924,8 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
)
if send_result["cancel"]:
return
# Opt-in runtime footer: appended to the final text reply
# only (never to voice/TTS or media synthesis).
await self._send_response_with_media(
update.message.chat_id,
send_result["content"],
@@ -886,6 +933,7 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
inbound_was_voice=bool(
update.message.voice or update.message.audio
),
footer_line=self._footer_line(turn_started_at),
)
# If the agent attached a portable presentation
# (buttons/menus), render it natively as an inline
@@ -965,6 +1013,10 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
or ""
) if update.message.from_user else ""
try:
# Mark turn start so the opt-in footer can report latency,
# matching the normal message path. Cheap, unused unless
# config.footer is enabled.
cmd_turn_started_at = time.monotonic()
response = await self._session.chat(
self._agent, user_id, prompt,
chat_id=str(update.message.chat_id) if update.message.chat_id else "",
@@ -987,6 +1039,7 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
update.message.chat_id,
send_result["content"],
reply_to=update.message.message_id,
footer_line=self._footer_line(cmd_turn_started_at),
)
try:
if presentation is not None:
@@ -1832,23 +1885,37 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
response: str,
reply_to: Optional[int] = None,
inbound_was_voice: bool = False,
footer_line: str = "",
) -> None:
"""Send response, extracting and sending any MEDIA: files."""
"""Send response, extracting and sending any MEDIA: files.
``footer_line`` (opt-in, privacy-safe runtime footer) is appended to the
delivered *text* only — never to the text handed to voice/TTS synthesis —
so a voice note never narrates the model/latency footer and the
streaming and non-streaming paths behave identically.
"""
# Parse response for media
parsed = split_media_from_output(response)
text = parsed["text"]
media_urls = parsed.get("media_urls", [])
audio_as_voice = parsed.get("audio_as_voice", False)
# Send text first if present
# Send text first if present. The footer attaches to the final text
# reply only; the pre-footer ``text`` is what feeds voice synthesis.
if text:
await self._send_long_message(chat_id, text, reply_to=reply_to)
await self._send_long_message(
chat_id, append_footer(text, footer_line), reply_to=reply_to
)
elif footer_line:
# Media-only reply: never emit a footer-only text bubble.
pass
# Issue #3623: outbound voice reply. When the operator has enabled a
# ``voice`` policy, synthesise the agent's plain text and deliver it as
# a native Telegram voice note — the symmetric counterpart to inbound
# STT. The agent authors plain text; voice is a transport concern here.
# Skipped silently (text already sent) when disabled or synthesis fails.
# Footer is intentionally excluded from the synthesised text.
await self._maybe_send_voice_reply(
chat_id, text, inbound_was_voice=inbound_was_voice
)
@@ -570,6 +570,7 @@ def gateway_status(
daemon_only: bool = typer.Option(False, "--daemon-only", help="Show only daemon status"),
deep: bool = typer.Option(False, "--deep", help="Extended diagnostics (health + log tail)"),
probe: bool = typer.Option(False, "--probe", help="Live credential probe per channel"),
json_output: bool = typer.Option(False, "--json", help="Emit a machine-readable status snapshot"),
):
"""Check gateway status and daemon service status.
@@ -579,6 +580,7 @@ def gateway_status(
praisonai gateway status --deep --config bot.yaml
praisonai gateway status --probe --config bot.yaml
praisonai gateway status --daemon-only
praisonai gateway status --json
"""
import os
from ..features.gateway import GatewayHandler
@@ -597,6 +599,17 @@ def gateway_status(
if config is None:
config = _resolve_gateway_config_path(None)
# Machine-readable path (#5363): sibling commands (doctor/test) support
# --json; status did not, so scripts/health-checks/dashboards could not
# consume its state. Emit a single stable snapshot built from /health +
# /info and the daemon status, then return before any human-only text.
if json_output:
import json as _json
snapshot = _build_status_snapshot(host, port)
typer.echo(_json.dumps(snapshot, default=str))
raise typer.Exit(0 if snapshot.get("reachable") else 1)
output = get_output_controller()
# Show daemon status
@@ -873,6 +886,122 @@ def _print_secret_availability(report: dict) -> None:
print()
def _resolve_gateway_auth_token() -> str:
"""Resolve the loopback ``GATEWAY_AUTH_TOKEN`` from env or persisted ``.env``.
``praisonai onboard`` / the daemon persist the auto-generated token to
``~/.praisonai/.env`` rather than exporting it into every shell. Without
reading that file, an authenticated gateway answers ``/health`` (public)
but 401s ``/info`` (authenticated), so ``status --json`` would report
``reachable: true`` while silently omitting ``version`` (#5363, greptile
P1). Prefer an already-exported env value; otherwise fall back to the
persisted file. Returns ``""`` when no token is available.
"""
import os
token = os.environ.get("GATEWAY_AUTH_TOKEN", "").strip()
if token:
return token
env_path = os.environ.get("PRAISONAI_ENV_FILE") or os.path.expanduser(
"~/.praisonai/.env"
)
try:
with open(env_path, encoding="utf-8", errors="replace") as handle:
for raw in handle:
s = raw.strip()
if not s or s.startswith("#") or "=" not in s:
continue
key, _, val = s.partition("=")
if key.strip() == "GATEWAY_AUTH_TOKEN":
return val.strip().strip('"').strip("'")
except OSError:
pass
return ""
def _fetch_gateway_json(host: str, port: int, path: str, timeout: float = 5.0):
"""GET a JSON gateway endpoint over loopback, returning None on failure.
Attaches the loopback-only bearer token (resolved from the environment or
the persisted ``~/.praisonai/.env``) so authenticated gateways still
answer, without leaking the credential onto a remote plaintext hop.
"""
import json
import urllib.request
url = f"http://{host}:{port}{path}"
try:
req = urllib.request.Request(url)
token = _resolve_gateway_auth_token()
if token and host in ("127.0.0.1", "localhost", "::1"):
req.add_header("Authorization", f"Bearer {token}")
with urllib.request.urlopen(req, timeout=timeout) as response:
return json.loads(response.read().decode())
except Exception:
return None
def _build_status_snapshot(host: str, port: int) -> dict:
"""Assemble one machine-readable gateway status snapshot (#5363).
Combines the daemon service state with the rich ``/health`` payload
(uptime, sessions, clients, per-channel supervision, degraded owners,
delivery back-pressure, applied/on-disk config revision + drift) and the
version/protocol_version from ``/info`` so a single ``--json`` call answers
"is my gateway healthy, on the right config, and what version".
"""
from praisonai_bot.daemon import get_daemon_status
snapshot: dict = {"host": host, "port": port, "reachable": False}
try:
daemon_status = get_daemon_status()
snapshot["daemon"] = {
"platform": daemon_status.get("platform", "unknown"),
"installed": bool(daemon_status.get("installed", False)),
"running": bool(daemon_status.get("running", False)),
"pid": daemon_status.get("pid"),
"error": daemon_status.get("error"),
}
except Exception as exc: # noqa: BLE001
snapshot["daemon"] = {"error": str(exc)}
health = _fetch_gateway_json(host, port, "/health")
info = _fetch_gateway_json(host, port, "/info")
if info:
snapshot["version"] = info.get("version")
snapshot["protocol_version"] = info.get("protocol_version")
if health:
snapshot["reachable"] = True
snapshot["status"] = health.get("status")
snapshot["uptime_s"] = health.get("uptime")
snapshot["active_sessions"] = health.get("sessions")
snapshot["clients"] = health.get("clients")
snapshot["agents"] = health.get("agents")
snapshot["channels"] = health.get("channels") or {}
snapshot["degraded"] = health.get("degraded_owners") or []
snapshot["applied_config_revision"] = health.get("applied_config_revision")
snapshot["on_disk_config_revision"] = health.get("on_disk_config_revision")
snapshot["config_drift"] = health.get("config_drift")
snapshot["last_inbound_at"] = health.get("last_inbound_at")
if "pressure" in health:
snapshot["delivery"] = health.get("pressure")
elif "delivery" in health:
snapshot["delivery"] = health.get("delivery")
if snapshot.get("version") is None and health.get("version") is not None:
snapshot["version"] = health.get("version")
# Reachable but /info didn't answer (typically an authenticated gateway
# whose token isn't available to this CLI) — surface it explicitly so
# consumers don't misread a silent missing "version" as "unversioned"
# (#5363, greptile P1).
if info is None and snapshot.get("version") is None:
snapshot["version_unavailable"] = "info_unreachable"
return snapshot
def _render_probe_results(results: dict, json_output: bool = False) -> bool:
"""Print per-channel probe verdicts. Returns True if all channels passed."""
all_ok = all(getattr(r, "ok", False) for r in results.values())
@@ -1854,6 +1983,44 @@ def gateway_send(
raise typer.Exit(1)
@app.command("schema")
def gateway_schema(
output: Optional[str] = typer.Option(
None,
"--output",
"-o",
help="Write the JSON Schema to a file instead of stdout "
"(e.g. gateway.schema.json for editor config / CI linting).",
),
):
"""Emit the gateway.yaml / bot.yaml JSON Schema (for editors / CI).
Derived from the canonical Pydantic ``GatewayConfigSchema`` so a mistyped
key is flagged at author time instead of only at gateway startup. Point your
editor's YAML language server at the output, or lint configs in CI:
praisonai gateway schema # print to stdout
praisonai gateway schema -o gateway.schema.json # write to a file
"""
import json
from praisonai_bot.bots._config_schema import gateway_config_json_schema
schema_json = json.dumps(gateway_config_json_schema(), indent=2)
if output:
from pathlib import Path
out_path = Path(output)
try:
out_path.write_text(schema_json + "\n", encoding="utf-8")
except OSError as exc:
print(f"Failed to write {out_path}: {exc}")
raise typer.Exit(1)
print(f"Wrote gateway JSON Schema to {out_path}")
else:
sys.stdout.write(schema_json + "\n")
hooks_app = typer.Typer(
help="Manage inbound trigger hooks (POST /hooks/<path>) in gateway.yaml",
no_args_is_help=True,
@@ -293,6 +293,9 @@ class BotHandler:
agent_config = validated_config.agent
if agent_config:
agent_config_dict = agent_config.model_dump()
# Resolve an unset/"auto" model to the detected provider's default
# (Issue #5609) so a non-OpenAI operator is not handed gpt-4o-mini.
agent_config_dict["model"] = agent_config.resolved_model()
else:
agent_config_dict = {}
@@ -26,6 +26,44 @@ from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
def _detected_model() -> str:
"""Model for whichever provider credential is present (Issue #5609).
Leads onboarding with the provider the operator already has
(Anthropic/Gemini/Ollama/…) instead of writing ``gpt-4o-mini``
unconditionally. Reuses the gateway/bot config resolver (which itself
bridges to the existing ``default_model_for_available_provider`` helper),
falling back to ``gpt-4o-mini`` only when nothing is detected.
"""
try:
from praisonai_bot.bots._config_schema import resolve_model_by_provider
return resolve_model_by_provider()
except Exception: # pragma: no cover - detection is best-effort
return "gpt-4o-mini"
def _provider_key_hint(model: str) -> str:
"""Name the API-key env var the detected model needs (Issue #5609).
Maps the detected/configured model back to its provider's credential env
var (e.g. ``ANTHROPIC_API_KEY`` for ``anthropic/...``) so the failure hint
points at the key the operator actually needs instead of always
``OPENAI_API_KEY``. Falls back to a generic hint when the provider cannot
be resolved.
"""
try:
from praisonai_bot._code_bridge import import_code_module
env = import_code_module("praisonai_code.llm.env")
key_var, _base = env._provider_from_model(model)
if key_var:
return f"e.g. {key_var}"
except Exception: # pragma: no cover - best-effort hint
pass
return "e.g. OPENAI_API_KEY"
def _is_non_interactive() -> bool:
"""Return True when the wizard must not block on user input.
@@ -280,8 +318,13 @@ def _generate_bot_yaml_multi_channel(channels: Dict[str, Dict], agent_name: str
instructions = agent_instructions or "You are a helpful AI assistant."
instructions = instructions.rstrip('. ')
instructions_escaped = instructions.replace('\\', '\\\\').replace('"', '\\"')
# Lead with the provider the operator already has (Issue #5609).
model = _detected_model()
lines = ["# Generated by `praisonai onboard`. Multi-channel configuration.",
from praisonai_bot.bots._config_schema import GATEWAY_SCHEMA_HEADER
lines = [GATEWAY_SCHEMA_HEADER.rstrip("\n"),
"# Generated by `praisonai onboard`. Multi-channel configuration.",
"# Compatible with both `praisonai bot start` and `praisonai gateway start`.",
"# Each channel can route to different agents.",
""]
@@ -296,7 +339,7 @@ def _generate_bot_yaml_multi_channel(channels: Dict[str, Dict], agent_name: str
lines.append("agent:")
lines.append(f' name: "{agent_name}"')
lines.append(f' instructions: "{instructions_escaped}. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."')
lines.append(' model: "gpt-4o-mini"')
lines.append(f' model: "{model}"')
lines.append("")
# Multi-agent block with agents for each role
@@ -310,7 +353,7 @@ def _generate_bot_yaml_multi_channel(channels: Dict[str, Dict], agent_name: str
lines.append(f' instructions: "{instructions_escaped}. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."')
else:
lines.append(f' instructions: "You are a {role} agent. {instructions_escaped} Use your tools proactively to help with {role}-related tasks."')
lines.append(' model: gpt-4o-mini')
lines.append(f' model: {model}')
lines.append(" memory: false")
lines.append("")
agents_added.add(role)
@@ -379,8 +422,13 @@ def _generate_bot_yaml(platforms: List[str], agent_name: str = "assistant", agen
instructions = agent_instructions or "You are a helpful AI assistant."
instructions = instructions.rstrip('. ') # Remove trailing periods to avoid double periods
instructions_escaped = instructions.replace('\\', '\\\\').replace('"', '\\"') # Escape quotes and backslashes
# Lead with the provider the operator already has (Issue #5609).
model = _detected_model()
lines = ["# Generated by `praisonai onboard`. Works with both",
from praisonai_bot.bots._config_schema import GATEWAY_SCHEMA_HEADER
lines = [GATEWAY_SCHEMA_HEADER.rstrip("\n"),
"# Generated by `praisonai onboard`. Works with both",
"# `praisonai bot start` (single-bot) and `praisonai gateway start`",
"# (multi-bot daemon exposing /health on 8765).",
""]
@@ -395,7 +443,7 @@ def _generate_bot_yaml(platforms: List[str], agent_name: str = "assistant", agen
lines.append("agent:")
lines.append(f' name: "{agent_name}"')
lines.append(f' instructions: "{instructions_escaped}. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."')
lines.append(' model: "gpt-4o-mini"')
lines.append(f' model: "{model}"')
lines.append(" # tools: [] # Uncomment to override auto-injected defaults")
lines.append(" # (search_web, schedule_*, store_memory, search_memory, …)")
lines.append("")
@@ -408,7 +456,7 @@ def _generate_bot_yaml(platforms: List[str], agent_name: str = "assistant", agen
lines.append(f' instructions: "{instructions_escaped}. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."')
else:
lines.append(f' instructions: "You are the {name} agent. {instructions_escaped}. Use your tools proactively."')
lines.append(' model: gpt-4o-mini')
lines.append(f' model: {model}')
lines.append(" memory: false")
lines.append(" # tools: [] # Override auto-injected defaults here")
lines.append("")
@@ -1008,9 +1056,14 @@ class OnboardWizard:
if ok:
print_fn(" [green]✓[/green] Agent replied — model credentials work")
else:
# Name the provider actually detected (Issue #5609) instead of
# hardcoding OPENAI_API_KEY, so the remediation hint matches the
# model onboarding chose (or says none was detected).
model = _detected_model()
key_hint = _provider_key_hint(model)
print_fn(
f" [yellow]⚠[/yellow] Agent did not reply: {detail}\n"
" [dim]Set the model/provider API key (e.g. OPENAI_API_KEY) in "
f" [dim]Set the model/provider API key ({key_hint}) in "
"~/.praisonai/.env, then re-run [cyan]praisonai onboard[/cyan] or "
"[cyan]praisonai gateway test --turn ping[/cyan].[/dim]"
)
@@ -867,10 +867,14 @@ class GatewayApiEndpoints:
)
if method == "initialize":
from .server import _installed_gateway_version
return ok(
{
"protocolVersion": "2024-11-05",
"serverInfo": {"name": "PraisonAI Gateway", "version": "1.0.0"},
"serverInfo": {
"name": "PraisonAI Gateway",
"version": _installed_gateway_version(),
},
"capabilities": {"tools": {}},
}
)
@@ -273,10 +273,18 @@ async def run_turn_test(
# the SAME provider the live gateway will use — otherwise a valid custom
# base_url/api_key config tests the default provider and gives a misleading
# verdict (false abort, or invalid runtime creds passing).
# An unset or ``"auto"`` model resolves to the detected provider's default
# (Issue #5609) so the preflight tests the provider the operator actually
# has instead of assuming OpenAI.
model = acfg.get("model") or acfg.get("llm")
if not model or str(model).strip().lower() == "auto":
from praisonai_bot.bots._config_schema import resolve_model_by_provider
model = resolve_model_by_provider()
agent_kwargs: Dict[str, Any] = {
"name": acfg.get("name", agent_id),
"instructions": acfg.get("instructions", ""),
"llm": acfg.get("model") or acfg.get("llm", "gpt-4o-mini"),
"llm": model,
}
base_url = acfg.get("base_url") or acfg.get("api_base")
if base_url:
@@ -131,6 +131,29 @@ from .unicode_utils import safe_error_message, safe_log_message, extract_root_ca
from .supervisor import ChannelState, ChannelSupervisor
def _installed_gateway_version() -> str:
"""Return the installed ``praisonai-bot`` version, not a hardcoded literal.
Automated version/upgrade checks read this off ``/info`` and the MCP
``serverInfo``; a hardcoded constant (#5363) actively misled them. Prefer
the packaged ``_version.py`` and fall back to installed metadata.
"""
try:
from praisonai_bot._version import __version__ as _v
if _v:
return str(_v)
except Exception:
pass
try:
from importlib.metadata import version as _pkg_version
except ImportError: # pragma: no cover - py<3.8
from importlib_metadata import version as _pkg_version # type: ignore
try:
return _pkg_version("praisonai-bot")
except Exception:
return "0.0.0"
# Per-platform token env-var fallbacks used by the generic channel-launch path
# (Issue #3578). Channels whose credentials live in the environment rather than
# gateway.yaml (email/AgentMail/Linear) resolve their token from the first env
@@ -1696,7 +1719,8 @@ class WebSocketGateway:
api_cfg = getattr(self.config, "api", None)
return JSONResponse({
"name": "PraisonAI Gateway",
"version": "1.0.0",
"version": _installed_gateway_version(),
"protocol_version": GATEWAY_PROTOCOL_VERSION,
"agents": list(self._agents.keys()),
"sessions": len(self._sessions),
"clients": len(self._clients),
@@ -8079,8 +8103,20 @@ class WebSocketGateway:
for agent_id, agent_def in agents_cfg.items():
instructions = agent_def.get("instructions", "")
# G7: Apply provider.model as fallback when agent has no model
model = agent_def.get("model", None) or default_model
# G7: Apply provider.model as fallback when agent has no model.
# An unset or ``"auto"`` model resolves to the detected provider's
# default (Issue #5609) so a non-OpenAI operator is not silently
# handed gpt-4o-mini; an explicit model/default_model still wins.
model = agent_def.get("model", None)
if not model or str(model).strip().lower() == "auto":
if default_model:
model = default_model
else:
from praisonai_bot.bots._config_schema import (
resolve_model_by_provider,
)
model = resolve_model_by_provider()
memory = agent_def.get("memory", False)
# G2: Pass temperature through (optional, SDK uses its own default)
+4 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-bot"
version = "0.1.8"
version = "0.1.9"
description = "Bots, gateway, and channel CLI for PraisonAI — messaging platforms and WebSocket control plane extracted from the praisonai wrapper."
readme = "README.md"
license = "MIT"
@@ -77,3 +77,6 @@ build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["."]
include = ["praisonai_bot*"]
[tool.setuptools.package-data]
"praisonai_bot.bots" = ["*.json"]
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""
Tests for the opt-in, privacy-safe per-reply runtime footer (issue #5428).
The footer surfaces only model name, context % and turn latency on the FINAL
reply. Missing fields are omitted (never shown as placeholders), and nothing is
appended when the footer is empty or the feature is disabled.
"""
import asyncio
import types
from praisonai_bot.bots._streaming import append_footer, build_footer_line
def test_full_footer_line():
assert build_footer_line(
model="gpt-4o",
served_model="gpt-4o-2024-08-06",
context_pct=41,
latency_s=3.8,
) == "gpt-4o (served: gpt-4o-2024-08-06) · 41% ctx · 3.8s"
def test_model_and_latency_only():
assert build_footer_line(model="gpt-4o", latency_s=4.2) == "gpt-4o · 4.2s"
def test_served_model_hidden_when_same_as_model():
# No "(served: ...)" when the served deployment matches the configured model.
assert build_footer_line(model="gpt-4o", served_model="gpt-4o") == "gpt-4o"
def test_missing_fields_are_omitted_not_placeheld():
# Only a context % available -> just that field, no "?" placeholders.
assert build_footer_line(context_pct=38) == "38% ctx"
def test_empty_when_no_fields():
assert build_footer_line() == ""
def test_context_pct_rounded_to_int():
assert build_footer_line(context_pct=37.6) == "38% ctx"
def test_invalid_numbers_are_dropped():
assert build_footer_line(model="m", context_pct=float("nan")) == "m"
assert build_footer_line(model="m", latency_s=float("inf")) == "m"
assert build_footer_line(model="m", latency_s=-1) == "m"
def test_blank_model_is_dropped():
assert build_footer_line(model=" ", latency_s=1.0) == "1.0s"
def test_append_footer_adds_separator():
assert (
append_footer("Here is your answer.", "gpt-4o · 4.2s")
== "Here is your answer.\n\n— gpt-4o · 4.2s"
)
def test_append_footer_noop_on_empty_line():
assert append_footer("body", "") == "body"
# ---------------------------------------------------------------------------
# Handler-level delivery behaviour (opt-in wiring + voice exclusion).
#
# TelegramBot carries a heavy optional ``telegram`` dependency, so rather than
# construct the whole bot we bind the two unbound methods under test to a light
# stub that supplies just the attributes they touch. This exercises the real
# code paths (opt-in gating, model/latency capture, footer-in-text-not-voice)
# deterministically and without network or the telegram package.
# ---------------------------------------------------------------------------
from praisonai_bot.bots.telegram import TelegramBot # noqa: E402
def _make_stub(footer_enabled, llm="gpt-4o"):
stub = types.SimpleNamespace()
stub.config = types.SimpleNamespace(footer=footer_enabled)
stub._agent = types.SimpleNamespace(llm=llm)
return stub
def test_footer_line_disabled_returns_empty():
stub = _make_stub(footer_enabled=False)
line = TelegramBot._footer_line(stub, turn_started_at=None)
assert line == ""
def test_footer_line_enabled_includes_model():
stub = _make_stub(footer_enabled=True, llm="gpt-4o")
line = TelegramBot._footer_line(stub, turn_started_at=None)
assert line == "gpt-4o"
def test_footer_line_enabled_includes_model_and_latency():
import time
stub = _make_stub(footer_enabled=True, llm="gpt-4o")
started = time.monotonic() - 2.0
line = TelegramBot._footer_line(stub, turn_started_at=started)
assert line.startswith("gpt-4o · ")
assert line.endswith("s")
def test_footer_line_non_string_llm_omits_model():
stub = _make_stub(footer_enabled=True, llm=object())
line = TelegramBot._footer_line(stub, turn_started_at=None)
assert line == ""
def _run_send(stub, response, footer_line):
sent_text = {}
voiced_text = {}
async def fake_send_long(chat_id, text, reply_to=None):
sent_text["value"] = text
async def fake_voice(chat_id, text, *, inbound_was_voice=False):
voiced_text["value"] = text
stub._send_long_message = fake_send_long
stub._maybe_send_voice_reply = fake_voice
asyncio.run(
TelegramBot._send_response_with_media(
stub, 123, response, reply_to=1, footer_line=footer_line
)
)
return sent_text.get("value"), voiced_text.get("value")
def test_send_appends_footer_to_text_only():
stub = types.SimpleNamespace()
sent, voiced = _run_send(stub, "Here is your answer.", "gpt-4o · 4.2s")
# Footer appears in the delivered text …
assert sent == "Here is your answer.\n\n— gpt-4o · 4.2s"
# … but never in the text handed to voice synthesis.
assert voiced == "Here is your answer."
def test_send_without_footer_leaves_text_unchanged():
stub = types.SimpleNamespace()
sent, voiced = _run_send(stub, "Plain reply.", "")
assert sent == "Plain reply."
assert voiced == "Plain reply."
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""Cross-channel progressive streaming wiring (Issue #5415).
The ``DraftStreamer`` engine is channel-agnostic, but it used to be wired only
into Telegram. These tests lock in that Discord and Slack now:
- derive a ``StreamingConfig`` from ``BotConfig.streaming`` via the shared
``build_streaming_config`` factory, and
- advertise ``live_edit`` so ``AUTO`` resolves to ``DRAFT``.
This guards against regressing the fix back to a Telegram-only capability.
"""
import pytest
from praisonaiagents.bots import BotConfig
from praisonai_bot.bots._streaming import (
build_streaming_config,
StreamingMode,
)
from praisonai_bot.bots.discord import DiscordBot
from praisonai_bot.bots.slack import SlackBot
def test_build_streaming_config_off_returns_none():
cfg = BotConfig(token="t", streaming=False)
assert build_streaming_config(cfg) is None
def test_build_streaming_config_draft_when_enabled():
cfg = BotConfig(token="t", streaming=True, stream_edit_interval_ms=700)
sc = build_streaming_config(cfg)
assert sc is not None
assert sc.mode == StreamingMode.DRAFT
assert sc.min_interval == 0.7
def test_discord_streaming_config_built_from_bot_config():
bot = DiscordBot(token="t", config=BotConfig(token="t", streaming=True))
assert bot._streaming_config is not None
assert bot._streaming_config.mode == StreamingMode.DRAFT
# Discord advertises live_edit so AUTO can resolve to DRAFT.
assert bot.capabilities.get("live_edit") is True
def test_discord_streaming_disabled_by_default():
bot = DiscordBot(token="t", config=BotConfig(token="t"))
assert bot._streaming_config is None
def test_slack_streaming_config_built_from_bot_config():
bot = SlackBot(token="t", config=BotConfig(token="t", streaming=True))
assert bot._streaming_config is not None
assert bot._streaming_config.mode == StreamingMode.DRAFT
assert bot.capabilities.get("live_edit") is True
def test_slack_streaming_disabled_by_default():
bot = SlackBot(token="t", config=BotConfig(token="t"))
assert bot._streaming_config is None
def test_configure_streaming_overrides_discord():
bot = DiscordBot(token="t", config=BotConfig(token="t"))
sc = build_streaming_config(BotConfig(token="t", streaming=True))
bot.configure_streaming(sc)
assert bot._streaming_config is sc
def test_configure_streaming_overrides_slack():
bot = SlackBot(token="t", config=BotConfig(token="t"))
sc = build_streaming_config(BotConfig(token="t", streaming=True))
bot.configure_streaming(sc)
assert bot._streaming_config is sc
# --- Streaming delivery guardrails (Greptile review on #5417) ----------------
@pytest.mark.asyncio
async def test_slack_stream_defers_when_threaded():
"""A threaded reply must bypass the root-only streamer (Greptile #2).
Returning ``None`` signals the caller to fall back to the thread-aware
``_send_response_with_media`` path, so a threaded answer is never stranded
in the main channel.
"""
bot = SlackBot(token="t", config=BotConfig(token="t", streaming=True))
result = await bot._maybe_stream_reply(
"C1", "U1", "hi", thread_id="123.456", message_id="123.457"
)
assert result is None
@pytest.mark.asyncio
async def test_slack_stream_defers_when_reply_in_thread_configured():
bot = SlackBot(
token="t",
config=BotConfig(token="t", streaming=True, reply_in_thread=True),
)
result = await bot._maybe_stream_reply("C1", "U1", "hi", message_id="123.457")
assert result is None
@pytest.mark.asyncio
async def test_slack_stream_uploads_media(monkeypatch, tmp_path):
"""A streamed reply carrying a MEDIA: directive uploads the file (Greptile #4)."""
from unittest.mock import AsyncMock
bot = SlackBot(token="t", config=BotConfig(token="t", streaming=True))
audio = tmp_path / "reply.mp3"
audio.write_bytes(b"ID3fake")
# Session returns text + a MEDIA directive; streamer edits are stubbed.
bot._session.chat = AsyncMock(return_value=f"Here you go\nMEDIA:{audio}")
bot.send_message = AsyncMock(return_value={"message_id": "m1"})
bot.edit_message = AsyncMock(return_value=None)
bot.delete_message = AsyncMock(return_value=True)
# Pin the outbound hook seam to a pass-through so a process-global hook
# registered by another test cannot rewrite/strip the MEDIA directive.
bot.fire_message_sending = lambda channel_id, content, **kw: {
"cancel": False,
"content": content,
}
uploads = []
class _Client:
async def files_upload_v2(self, **kwargs):
uploads.append(kwargs)
bot._client = _Client()
result = await bot._maybe_stream_reply("C1", "U1", "play it")
assert result is not None
assert uploads, "expected the MEDIA audio file to be uploaded"
assert uploads[0]["channel"] == "C1"
assert uploads[0]["file"] == str(audio)
@pytest.mark.asyncio
async def test_discord_stream_chunks_long_reply(monkeypatch):
"""A streamed answer over Discord's cap is delivered via the chunked path.
Guards Greptile #3/#5: editing the single placeholder with >2000 chars
would 400 and drop the whole reply, so the finalize path must fall back to
the reference-preserving ``_send_long_message`` chunker.
"""
from unittest.mock import AsyncMock, MagicMock
bot = DiscordBot(token="t", config=BotConfig(token="t", streaming=True))
long_reply = "x" * 5000
bot._session.chat = AsyncMock(return_value=long_reply)
bot.send_message = AsyncMock(return_value={"message_id": "m1"})
bot.edit_message = AsyncMock(return_value=None)
bot.delete_message = AsyncMock(return_value=True)
sent_chunks = []
long_sender = AsyncMock(
side_effect=lambda channel, text, reference=None: sent_chunks.append(text)
)
monkeypatch.setattr(bot, "_send_long_message", long_sender)
from praisonai_bot.bots._streaming import DraftStreamer
streamer = DraftStreamer(
adapter=bot,
channel_id="C1",
config=bot._streaming_config,
platform="discord",
)
await streamer.start()
# Simulate the finalize decision the adapter makes for an over-cap reply.
send_result = bot.fire_message_sending("C1", long_reply)
final_content = send_result["content"]
cap = min(bot.config.max_message_length, 2000)
assert len(final_content) > cap
message = MagicMock()
message.channel = MagicMock()
if len(final_content) > cap:
await bot.delete_message("C1", "m1")
await bot._send_long_message(message.channel, final_content, reference=message)
else: # pragma: no cover - defensive
await streamer.finalize(final_content)
assert sent_chunks, "long reply should have been routed through the chunker"
bot.delete_message.assert_awaited()
@@ -0,0 +1,109 @@
"""Tests for the published gateway.yaml / bot.yaml JSON Schema (issue #5414).
Brings the gateway/bot config surface to parity with ``agents.yaml``:
- ``gateway_config_json_schema()`` emits a draft-07 schema from the canonical
Pydantic ``GatewayConfigSchema``.
- The committed ``gateway.schema.json`` artefact matches the model.
- ``praisonai gateway schema`` prints the schema / writes it to a file.
- Scaffolded ``gateway.yaml``/``bot.yaml`` start with the
``# yaml-language-server`` header (and still parse with ``yaml.safe_load``).
"""
import json
from pathlib import Path
import pytest
import yaml
from praisonai_bot.bots._config_schema import (
GATEWAY_SCHEMA_HEADER,
GATEWAY_SCHEMA_URL,
gateway_config_json_schema,
)
def test_gateway_schema_derived_from_pydantic_model():
schema = gateway_config_json_schema()
assert schema["$schema"] == "http://json-schema.org/draft-07/schema#"
assert schema["$id"] == GATEWAY_SCHEMA_URL
props = schema["properties"]
for key in ("gateway", "agents", "channels", "routing", "schedules"):
assert key in props, f"expected '{key}' in gateway schema properties"
def test_committed_artefact_matches_model():
artefact = (
Path(__file__).resolve().parents[3]
/ "praisonai_bot"
/ "bots"
/ "gateway.schema.json"
)
assert artefact.exists(), "gateway.schema.json artefact must be committed"
committed = json.loads(artefact.read_text(encoding="utf-8"))
assert committed == gateway_config_json_schema(), (
"gateway.schema.json is stale; regenerate via "
"`praisonai gateway schema -o "
"src/praisonai-bot/praisonai_bot/bots/gateway.schema.json`"
)
def test_schema_header_points_at_published_url():
assert GATEWAY_SCHEMA_HEADER.startswith("# yaml-language-server: $schema=")
assert GATEWAY_SCHEMA_URL in GATEWAY_SCHEMA_HEADER
assert GATEWAY_SCHEMA_HEADER.endswith("\n")
def test_shipped_sample_gateway_yaml_has_header():
sample = (
Path(__file__).resolve().parents[3] / "gateway.yaml"
)
if not sample.exists():
pytest.skip("sample gateway.yaml not present")
first_line = sample.read_text(encoding="utf-8").splitlines()[0]
assert first_line.startswith("# yaml-language-server: $schema=")
# Leading comment is ignored by safe_load -> execution unaffected.
loaded = yaml.safe_load(sample.read_text(encoding="utf-8"))
assert "gateway" in loaded
def test_generated_configs_scaffold_header():
from praisonai_bot.cli.features.onboard import (
_generate_bot_yaml,
_generate_bot_yaml_multi_channel,
)
single = _generate_bot_yaml(["telegram"])
assert single.startswith("# yaml-language-server: $schema=")
assert yaml.safe_load(single)["gateway"]["port"] == 8765
multi = _generate_bot_yaml_multi_channel(
{
"telegram": {
"platform": "telegram",
"role": "assistant",
"env_var": "TELEGRAM_BOT_TOKEN",
"token": "",
"info": {"allowed_users_env": "TELEGRAM_ALLOWED_USERS"},
"config": {},
}
}
)
assert multi.startswith("# yaml-language-server: $schema=")
assert "gateway" in yaml.safe_load(multi)
def test_gateway_schema_cli_prints_and_writes(tmp_path):
from typer.testing import CliRunner
from praisonai_bot.cli.commands.gateway import app
runner = CliRunner()
result = runner.invoke(app, ["schema"])
assert result.exit_code == 0, result.output
assert '"$schema"' in result.output
out = tmp_path / "gateway.schema.json"
result2 = runner.invoke(app, ["schema", "-o", str(out)])
assert result2.exit_code == 0, result2.output
assert out.exists()
assert json.loads(out.read_text(encoding="utf-8")) == gateway_config_json_schema()
@@ -0,0 +1,147 @@
"""Tests for the machine-readable ``gateway status --json`` snapshot and the
real (non-hardcoded) version reported by ``/info`` and MCP ``serverInfo`` (#5363).
"""
import json
import pytest
import typer
from praisonai_bot.cli.commands import gateway as gateway_cmd
from praisonai_bot.gateway.server import _installed_gateway_version
def test_installed_version_is_not_hardcoded():
version = _installed_gateway_version()
assert version
assert version != "1.0.0"
def test_build_status_snapshot_merges_health_and_info(monkeypatch):
health = {
"status": "healthy",
"uptime": 3600.0,
"sessions": 3,
"clients": 2,
"agents": 1,
"channels": {"telegram": {"running": True}},
"degraded_owners": [],
"applied_config_revision": "a1b2c3d4e5f6",
"on_disk_config_revision": "a1b2c3d4e5f6",
"config_drift": False,
"pressure": {"outbox_depth": 0, "dlq": 0, "dead_targets": 0},
}
info = {"version": "2.3.4", "protocol_version": 7}
def fake_fetch(host, port, path, timeout=5.0):
return health if path == "/health" else info
monkeypatch.setattr(gateway_cmd, "_fetch_gateway_json", fake_fetch)
monkeypatch.setattr(
"praisonai_bot.daemon.get_daemon_status",
lambda: {"platform": "linux", "installed": True, "running": True, "pid": 42},
)
snap = gateway_cmd._build_status_snapshot("127.0.0.1", 8765)
assert snap["reachable"] is True
assert snap["version"] == "2.3.4"
assert snap["protocol_version"] == 7
assert snap["uptime_s"] == 3600.0
assert snap["active_sessions"] == 3
assert snap["clients"] == 2
assert snap["channels"] == {"telegram": {"running": True}}
assert snap["degraded"] == []
assert snap["applied_config_revision"] == "a1b2c3d4e5f6"
assert snap["config_drift"] is False
assert snap["delivery"] == {"outbox_depth": 0, "dlq": 0, "dead_targets": 0}
assert snap["daemon"]["running"] is True
def test_build_status_snapshot_unreachable(monkeypatch):
monkeypatch.setattr(
gateway_cmd, "_fetch_gateway_json", lambda *a, **k: None
)
monkeypatch.setattr(
"praisonai_bot.daemon.get_daemon_status",
lambda: {"platform": "linux", "installed": False, "running": False},
)
snap = gateway_cmd._build_status_snapshot("127.0.0.1", 8765)
assert snap["reachable"] is False
def test_status_json_emits_snapshot_and_exit_code(monkeypatch, capsys):
monkeypatch.setattr(
gateway_cmd,
"_build_status_snapshot",
lambda host, port: {"reachable": True, "version": "9.9.9"},
)
with pytest.raises(typer.Exit) as excinfo:
gateway_cmd.gateway_status(
host="127.0.0.1", port=8765, config=None,
daemon_only=False, deep=False, probe=False, json_output=True,
)
assert excinfo.value.exit_code == 0
out = capsys.readouterr().out
payload = json.loads(out)
assert payload["reachable"] is True
assert payload["version"] == "9.9.9"
def test_status_json_unreachable_exits_nonzero(monkeypatch, capsys):
monkeypatch.setattr(
gateway_cmd,
"_build_status_snapshot",
lambda host, port: {"reachable": False},
)
with pytest.raises(typer.Exit) as excinfo:
gateway_cmd.gateway_status(
host="127.0.0.1", port=8765, config=None,
daemon_only=False, deep=False, probe=False, json_output=True,
)
assert excinfo.value.exit_code == 1
payload = json.loads(capsys.readouterr().out)
assert payload["reachable"] is False
def test_resolve_auth_token_prefers_env(monkeypatch):
monkeypatch.setenv("GATEWAY_AUTH_TOKEN", "env-token")
assert gateway_cmd._resolve_gateway_auth_token() == "env-token"
def test_resolve_auth_token_falls_back_to_persisted_env_file(monkeypatch, tmp_path):
monkeypatch.delenv("GATEWAY_AUTH_TOKEN", raising=False)
env_file = tmp_path / ".env"
env_file.write_text(
"# comment\nOTHER=1\nGATEWAY_AUTH_TOKEN=\"persisted-token\"\n"
)
monkeypatch.setenv("PRAISONAI_ENV_FILE", str(env_file))
assert gateway_cmd._resolve_gateway_auth_token() == "persisted-token"
def test_resolve_auth_token_missing_returns_empty(monkeypatch, tmp_path):
monkeypatch.delenv("GATEWAY_AUTH_TOKEN", raising=False)
monkeypatch.setenv("PRAISONAI_ENV_FILE", str(tmp_path / "does-not-exist.env"))
assert gateway_cmd._resolve_gateway_auth_token() == ""
def test_snapshot_signals_version_unavailable_when_info_unreachable(monkeypatch):
health = {"status": "healthy", "uptime": 1.0}
def fake_fetch(host, port, path, timeout=5.0):
return health if path == "/health" else None
monkeypatch.setattr(gateway_cmd, "_fetch_gateway_json", fake_fetch)
monkeypatch.setattr(
"praisonai_bot.daemon.get_daemon_status",
lambda: {"platform": "linux", "installed": True, "running": True},
)
snap = gateway_cmd._build_status_snapshot("127.0.0.1", 8765)
assert snap["reachable"] is True
assert snap.get("version") is None
assert snap["version_unavailable"] == "info_unreachable"
@@ -797,7 +797,14 @@ def test_mcp_initialize_and_tools_list():
init = asyncio.run(
ep.mcp_jsonrpc(_FakeReq({"jsonrpc": "2.0", "id": 1, "method": "initialize"}))
)
assert _body(init)["result"]["serverInfo"]["name"] == "PraisonAI Gateway"
server_info = _body(init)["result"]["serverInfo"]
assert server_info["name"] == "PraisonAI Gateway"
# The MCP serverInfo version must be the real installed version, not the
# old hardcoded "1.0.0" that misled upgrade checks (#5363, greptile P2).
from praisonai_bot.gateway.server import _installed_gateway_version
assert server_info["version"] == _installed_gateway_version()
assert server_info["version"] != "1.0.0"
listed = asyncio.run(
ep.mcp_jsonrpc(_FakeReq({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}))
+5 -4
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 5
requires-python = ">=3.10, <3.15"
resolution-markers = [
"python_full_version >= '3.14'",
@@ -1428,7 +1428,7 @@ wheels = [
[[package]]
name = "praisonai-bot"
version = "0.1.8"
version = "0.1.9"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1510,7 +1510,7 @@ provides-extras = ["gateway", "bot", "bot-whatsapp-web", "all"]
[[package]]
name = "praisonaiagents"
version = "1.7.9"
version = "1.7.10"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
@@ -1543,6 +1543,7 @@ requires-dist = [
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.83.14" },
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.20.0" },
{ name = "mcp", marker = "extra == 'parallel-search'", specifier = ">=1.30.0,<2" },
{ name = "mem0ai", marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
{ name = "mem0ai", extras = ["graph"], marker = "extra == 'graph'", specifier = ">=0.1.0" },
{ name = "motor", marker = "extra == 'mongodb'", specifier = ">=3.4.0" },
@@ -1581,7 +1582,7 @@ requires-dist = [
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
]
provides-extras = ["sandbox", "mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
provides-extras = ["sandbox", "mcp", "parallel-search", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
[[package]]
name = "primp"
@@ -1 +1 @@
__version__ = "0.1.8"
__version__ = "0.1.9"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-browser"
version = "0.1.8"
version = "0.1.9"
description = "Browser automation for PraisonAI — Chrome extension bridge, CDP, and Playwright agents extracted from the praisonai wrapper."
readme = "README.md"
license = {text = "MIT"}
+5 -4
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 5
requires-python = ">=3.10, <3.15"
[[package]]
@@ -932,7 +932,7 @@ wheels = [
[[package]]
name = "praisonai-browser"
version = "0.1.8"
version = "0.1.9"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -976,7 +976,7 @@ provides-extras = ["server", "playwright", "all"]
[[package]]
name = "praisonaiagents"
version = "1.7.9"
version = "1.7.10"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
@@ -1009,6 +1009,7 @@ requires-dist = [
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.83.14" },
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.20.0" },
{ name = "mcp", marker = "extra == 'parallel-search'", specifier = ">=1.30.0,<2" },
{ name = "mem0ai", marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
{ name = "mem0ai", extras = ["graph"], marker = "extra == 'graph'", specifier = ">=0.1.0" },
{ name = "motor", marker = "extra == 'mongodb'", specifier = ">=3.4.0" },
@@ -1047,7 +1048,7 @@ requires-dist = [
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
]
provides-extras = ["sandbox", "mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
provides-extras = ["sandbox", "mcp", "parallel-search", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
[[package]]
name = "propcache"
@@ -12,6 +12,6 @@ the remaining terminal-agent modules, with PEP 562 shims left behind at the
old ``praisonai.*`` paths for backward compatibility.
"""
__version__ = "0.1.8"
__version__ = "0.1.9"
__all__ = ["__version__"]
@@ -20,6 +20,10 @@ app = typer.Typer(help="Run agents")
_FRAMEWORK_HELP = "Framework: praisonai, crewai, autogen"
_ALLOW_LOCAL_TOOLS_ENV = "PRAISONAI_ALLOW_LOCAL_TOOLS"
# Set while a modern ``run <file>.yaml`` is delegating to the legacy
# ``PraisonAI`` executor. The legacy ``run`` dispatch branch reads it to avoid
# re-invoking this same Typer ``run`` app (which would recurse infinitely).
_IN_MODERN_RUN_ENV = "PRAISONAI_IN_MODERN_RUN"
DEFAULT_MAX_TOKENS = 16000
@@ -1895,10 +1899,24 @@ def _run_from_file(
# Note: Credential check already done in run_main() entry point
# Capture the re-entrancy sentinel's prior value *before* the guarded block
# so the ``finally`` restore below can never hit an UnboundLocalError when an
# early statement (e.g. the ``PraisonAI`` import) raises before assignment.
import os as _os_guard
_prev_in_run = _os_guard.environ.get(_IN_MODERN_RUN_ENV)
try:
# Use existing PraisonAI class
from praisonai_code.cli.main import PraisonAI
# Re-entrancy guard: the legacy ``PraisonAI.main()`` re-parses ``sys.argv``
# and, when it sees ``run <file>.yaml``, dispatches back into *this* modern
# Typer ``run`` app. Since we reach here from that same app, an unguarded
# delegation loops forever ("maximum recursion depth exceeded"). Setting
# this sentinel tells the legacy ``run`` branch we are already inside a
# modern run so it executes the YAML directly instead of bouncing back.
_os_guard.environ[_IN_MODERN_RUN_ENV] = "1"
praison = PraisonAI(
agent_file=file_path,
framework=framework or "praisonai",
@@ -2012,6 +2030,14 @@ def _run_from_file(
output.emit_error(message=str(e))
output.print_error(str(e))
raise typer.Exit(1)
finally:
# Restore the re-entrancy sentinel so suppression never leaks into a
# later in-process invocation (embedded/notebook/test reuse).
import os as _os_guard
if _prev_in_run is None:
_os_guard.environ.pop(_IN_MODERN_RUN_ENV, None)
else:
_os_guard.environ[_IN_MODERN_RUN_ENV] = _prev_in_run
def _run_prompt(
@@ -1405,16 +1405,39 @@ class PraisonAI:
or os.path.exists(first)
)
)
if is_yaml_target:
if is_yaml_target and os.environ.get("PRAISONAI_IN_MODERN_RUN"):
# We are already *inside* a modern ``run`` invocation
# (``commands/run.py`` delegated the YAML to this legacy
# executor). Re-dispatching to ``run_app`` would re-enter the
# same Typer app and recurse forever ("maximum recursion depth
# exceeded", issue #5597). Execute the YAML directly instead:
# set it as the agent file and fall through to the normal
# legacy execution path below.
#
# Prefer the constructor-provided ``agent_file`` over the
# relative ``sys.argv`` token: the modern runner resolves the
# target to an absolute path *before* a ``--worktree`` chdir
# and passes it here (``self.agent_file`` is still pristine at
# this point), so an untracked/ignored YAML absent from the
# fresh worktree still loads. Fall back to ``first`` only when
# no explicit path was supplied (default ``agents.yaml``
# sentinel from ``__init__``).
if self.agent_file and self.agent_file != "agents.yaml":
pass # keep the resolved constructor path
else:
self.agent_file = first
args.command = None
elif is_yaml_target:
# Modern engine (``commands/run.py`` run_main); the first
# arg is passed as the Typer ``target`` (YAML file/prompt).
from ..commands.run import app as run_app
run_app(unknown_args)
sys.exit(0)
# Run command - async jobs API for long-running tasks
from ..features.jobs import handle_run_command
handle_run_command(unknown_args, verbose=getattr(args, 'verbose', False))
sys.exit(0)
else:
# Run command - async jobs API for long-running tasks
from ..features.jobs import handle_run_command
handle_run_command(unknown_args, verbose=getattr(args, 'verbose', False))
sys.exit(0)
elif args.command == 'managed':
# Managed agents — delegate to Typer app
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-code"
version = "0.1.8"
version = "0.1.9"
description = "Agentic terminal CLI for PraisonAI — the terminal-native agent (run, chat, code, runtime, cli backends) extracted from the praisonai wrapper."
readme = "README.md"
license = {text = "MIT"}
@@ -132,5 +132,186 @@ def test_run_submit_wins_over_same_named_path(monkeypatch, tmp_path):
assert code == 0
def test_modern_run_delegation_does_not_reenter_run_app(monkeypatch, tmp_path):
"""Issue #5597: ``python -m praisonai <file>.yaml`` must not recurse.
The modern Typer ``run`` command delegates YAML execution to the legacy
``PraisonAI`` class. That class re-parses ``sys.argv`` (``run <file>.yaml``)
and, before this fix, dispatched the ``run`` command straight back into the
modern ``run_app`` — which calls the legacy executor again, looping forever
until ``maximum recursion depth exceeded``.
With the ``PRAISONAI_IN_MODERN_RUN`` re-entrancy sentinel set (as the modern
runner does while delegating), the legacy ``run`` branch must execute the
YAML directly instead of bouncing back to ``run_app``.
"""
pa = _load_module()
_require_wrapper_argparse()
yaml_path = tmp_path / "agents.yaml"
yaml_path.write_text("framework: praisonai\nroles: {}\n")
monkeypatch.chdir(tmp_path)
calls = {"run_app": None, "jobs": None}
ran = {"direct": False}
import praisonai_code.cli.commands.run as run_mod
import praisonai_code.cli.features.jobs as jobs_mod
def fake_run_app(args=None, *a, **k):
calls["run_app"] = list(args) if args is not None else []
def fake_handle_run_command(unknown_args, *a, **k):
calls["jobs"] = list(unknown_args)
# Stub the agents generator so the direct YAML execution path is observed
# without spinning up a real team / LLM call.
class _FakeGenerator:
def __init__(self, *a, **k):
ran["direct"] = True
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def generate_crew_and_kickoff(self):
return "READY"
monkeypatch.setattr(run_mod, "app", fake_run_app)
monkeypatch.setattr(jobs_mod, "handle_run_command", fake_handle_run_command)
monkeypatch.setattr(pa, "_get_agents_generator", lambda: _FakeGenerator)
monkeypatch.setenv("PRAISONAI_IN_MODERN_RUN", "1")
import sys as _sys
monkeypatch.setattr(_sys, "argv", ["praisonai", "run", str(yaml_path)])
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
result = pa.PraisonAI(agent_file=str(yaml_path)).main()
# No re-entry into the modern runner (which caused the infinite recursion),
# no mis-routing to jobs, and the YAML was executed directly instead.
assert calls["run_app"] is None
assert calls["jobs"] is None
assert ran["direct"] is True
assert result == "READY"
def test_modern_run_delegation_preserves_resolved_agent_file(monkeypatch, tmp_path):
"""Greptile #5606: the delegated legacy ``run`` branch must keep the
constructor-provided (resolved, possibly absolute) ``agent_file`` instead of
overwriting it 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, then passes
that path to ``PraisonAI(agent_file=...)``. If the legacy branch replaced it
with the relative CLI arg, an untracked/ignored YAML (absent from the fresh
worktree) would fail to load. This asserts the resolved path survives.
"""
pa = _load_module()
_require_wrapper_argparse()
yaml_path = tmp_path / "agents.yaml"
yaml_path.write_text("framework: praisonai\nroles: {}\n")
resolved = str(yaml_path) # absolute path, as the worktree runner passes
seen = {"agent_file": None}
class _FakeGenerator:
def __init__(self, agent_file, *a, **k):
seen["agent_file"] = agent_file
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def generate_crew_and_kickoff(self):
return "READY"
monkeypatch.setattr(pa, "_get_agents_generator", lambda: _FakeGenerator)
monkeypatch.setenv("PRAISONAI_IN_MODERN_RUN", "1")
import sys as _sys
# sys.argv carries the *relative* CLI token; a bare "agents.yaml" that is not
# present in the (simulated) new cwd. The resolved absolute path handed to the
# constructor must win.
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(_sys, "argv", ["praisonai", "run", "agents.yaml"])
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
result = pa.PraisonAI(agent_file=resolved).main()
assert seen["agent_file"] == resolved
assert result == "READY"
def _run_from_file_with_broken_import(monkeypatch, tmp_path):
"""Drive ``_run_from_file`` with the ``PraisonAI`` import forced to fail.
Returns ``exc_info.value.code`` so callers can assert the deliberate
``typer.Exit(1)`` propagates instead of an ``UnboundLocalError`` leaking from
the ``finally`` sentinel restore.
"""
import typer
import praisonai_code.cli.commands.run as run_mod
yaml_path = tmp_path / "agents.yaml"
yaml_path.write_text("framework: praisonai\nroles: {}\n")
# Force the guarded ``from praisonai_code.cli.main import PraisonAI`` to raise
# before ``_prev_in_run`` would historically have been assigned.
import builtins
real_import = builtins.__import__
def _boom(name, *args, **kwargs):
if name == "praisonai_code.cli.main":
raise ImportError("boom: simulated main import failure")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _boom)
with pytest.raises(typer.Exit) as exc_info:
run_mod._run_from_file(str(yaml_path))
return exc_info.value.exit_code
def test_run_from_file_import_failure_exits_one_no_prior_sentinel(
monkeypatch, tmp_path
):
"""Issue #5632: a failed ``PraisonAI`` import must surface ``typer.Exit(1)``
(not ``UnboundLocalError`` from the ``finally`` sentinel restore), and must
leave an *absent* sentinel absent."""
monkeypatch.delenv("PRAISONAI_IN_MODERN_RUN", raising=False)
import os
code = _run_from_file_with_broken_import(monkeypatch, tmp_path)
assert code == 1
# The previously-absent sentinel is restored to absent, not left set to "1".
assert "PRAISONAI_IN_MODERN_RUN" not in os.environ
def test_run_from_file_import_failure_exits_one_preserves_sentinel(
monkeypatch, tmp_path
):
"""Issue #5632: when a sentinel already exists, a failed ``PraisonAI`` import
must still exit 1 and leave the prior sentinel value unchanged."""
monkeypatch.setenv("PRAISONAI_IN_MODERN_RUN", "preexisting")
import os
code = _run_from_file_with_broken_import(monkeypatch, tmp_path)
assert code == 1
# The existing sentinel is restored to its original value, not overwritten.
assert os.environ.get("PRAISONAI_IN_MODERN_RUN") == "preexisting"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+5 -4
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 5
requires-python = ">=3.10, <3.15"
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
@@ -1370,7 +1370,7 @@ wheels = [
[[package]]
name = "praisonai-code"
version = "0.1.8"
version = "0.1.9"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -1403,7 +1403,7 @@ requires-dist = [
[[package]]
name = "praisonaiagents"
version = "1.7.9"
version = "1.7.10"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
@@ -1436,6 +1436,7 @@ requires-dist = [
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.83.14" },
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.20.0" },
{ name = "mcp", marker = "extra == 'parallel-search'", specifier = ">=1.30.0,<2" },
{ name = "mem0ai", marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
{ name = "mem0ai", extras = ["graph"], marker = "extra == 'graph'", specifier = ">=0.1.0" },
{ name = "motor", marker = "extra == 'mongodb'", specifier = ">=3.4.0" },
@@ -1474,7 +1475,7 @@ requires-dist = [
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
]
provides-extras = ["sandbox", "mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
provides-extras = ["sandbox", "mcp", "parallel-search", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
[[package]]
name = "propcache"
@@ -1 +1 @@
__version__ = "0.1.8"
__version__ = "0.1.9"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-deploy"
version = "0.1.8"
version = "0.1.9"
description = "Deployment for PraisonAI — API, Docker, and cloud (AWS, Azure, GCP) deployments extracted from the praisonai wrapper."
readme = "README.md"
license = {text = "MIT"}
+5 -4
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 5
requires-python = ">=3.10, <3.15"
[[package]]
@@ -977,7 +977,7 @@ wheels = [
[[package]]
name = "praisonai-deploy"
version = "0.1.8"
version = "0.1.9"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -1014,7 +1014,7 @@ provides-extras = ["api", "all"]
[[package]]
name = "praisonaiagents"
version = "1.7.9"
version = "1.7.10"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
@@ -1047,6 +1047,7 @@ requires-dist = [
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.83.14" },
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.20.0" },
{ name = "mcp", marker = "extra == 'parallel-search'", specifier = ">=1.30.0,<2" },
{ name = "mem0ai", marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
{ name = "mem0ai", extras = ["graph"], marker = "extra == 'graph'", specifier = ">=0.1.0" },
{ name = "motor", marker = "extra == 'mongodb'", specifier = ">=3.4.0" },
@@ -1085,7 +1086,7 @@ requires-dist = [
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
]
provides-extras = ["sandbox", "mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
provides-extras = ["sandbox", "mcp", "parallel-search", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
[[package]]
name = "propcache"
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.10"
__version__ = "0.1.11"
@@ -23,16 +23,28 @@ def register_memory_tools() -> None:
@register_tool("praisonai.memory.show")
def memory_show(user_id: Optional[str] = None) -> str:
"""Show all stored memories (optionally filtered by user)."""
"""Show stored memories; SQLite displays up to 1000 per tier."""
try:
from praisonaiagents.memory import Memory
memory = Memory()
data = memory.get_all_memories()
if user_id:
adapter = getattr(memory, "memory_adapter", None)
if getattr(memory, "provider", None) == "sqlite" and adapter is not None:
# Display retains its previous bounded window. Full enumeration
# is a separate API and must not load the whole store here.
data = []
for tier in ("short", "long"):
for record in getattr(adapter, f"search_{tier}_term")("", limit=1000, user_id=user_id):
data.append({
**record, "memory_type": f"{tier}_term", "type": f"{tier}_term",
})
else:
data = memory.get_all_memories()
if user_id is not None:
data = [
m for m in data
if str((m.get("metadata") or {}).get("user_id")) == str(user_id)
if (m.get("metadata") or {}).get("user_id") is not None
and str((m.get("metadata") or {}).get("user_id")) == str(user_id)
]
return str(data)
except ImportError:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "praisonai-mcp"
version = "0.1.10"
version = "0.1.11"
description = "MCP server hosting for PraisonAI — expose agents and tools to Cursor, Claude Desktop, and other MCP clients."
readme = "README.md"
license = {text = "MIT"}
@@ -9,6 +9,7 @@ bind against the real core API and assert no ``Error:`` fallback.
import sys
import types
import ast
import pytest
@@ -72,6 +73,27 @@ def test_memory_show_calls_real_api(fake_memory, memory_tools):
assert "hello" in out
@pytest.mark.parametrize("user_id,expected", [
("", ["empty"]),
("None", ["literal-none"]),
("u1", ["named"]),
(None, ["missing", "null", "empty", "literal-none", "named"]),
])
def test_memory_show_distinguishes_missing_and_explicit_user_ids(fake_memory, memory_tools, monkeypatch, user_id, expected):
import praisonaiagents.memory as module
records = [
{"id": "missing", "metadata": {}},
{"id": "null", "metadata": {"user_id": None}},
{"id": "empty", "metadata": {"user_id": ""}},
{"id": "literal-none", "metadata": {"user_id": "None"}},
{"id": "named", "metadata": {"user_id": "u1"}},
]
monkeypatch.setattr(module.Memory, "get_all_memories", lambda self: records)
actual = ast.literal_eval(_call(memory_tools, "praisonai.memory.show", user_id=user_id))
assert [record["id"] for record in actual] == expected
def test_memory_add_calls_store_short_term(fake_memory, memory_tools):
out = _call(memory_tools, "praisonai.memory.add", content="note")
assert not out.startswith("Error:")
@@ -88,3 +110,40 @@ def test_memory_clear_calls_reset_all(fake_memory, memory_tools):
out = _call(memory_tools, "praisonai.memory.clear")
assert not out.startswith("Error:")
assert fake_memory["reset"] == 1
@pytest.mark.parametrize("user_id,expected", [(None, 2000), ("even", 1000), ("old-user", 2), ("absent", 0)])
def test_sqlite_show_bounds_fetches_without_capping_full_enumeration(tmp_path, monkeypatch, memory_tools, user_id, expected):
import praisonaiagents.memory as module
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "home"))
monkeypatch.chdir(tmp_path)
memory = module.Memory(config={"provider": "sqlite"})
adapter = memory.memory_adapter
queries = []
try:
for tier in ("short", "long"):
for index in range(1001):
getattr(memory, f"store_{tier}_term")(
f"{tier} entry {index}", metadata={"user_id": "old-user" if index == 0 else "even" if index % 2 == 0 else "odd"},
)
# Distinct valid timestamps make the displayed latest-1000 window
# deterministic; SQLite's second-resolution default can tie here.
for tier, conn in (("short", adapter._get_stm_conn()), ("long", adapter._get_ltm_conn())):
conn.execute(f"UPDATE {tier}_term_memory SET timestamp = datetime(1700000000 + id, 'unixepoch')")
conn.commit()
adapter._get_stm_conn().set_trace_callback(queries.append)
adapter._get_ltm_conn().set_trace_callback(queries.append)
monkeypatch.setattr(module, "Memory", lambda: memory)
records = ast.literal_eval(_call(memory_tools, "praisonai.memory.show", user_id=user_id))
assert len(records) == expected
selects = [query for query in queries if query.startswith("SELECT id, content")]
assert len(selects) == 2
assert all("LIMIT 1000" in query for query in selects)
if user_id:
assert all(record["metadata"]["user_id"] == user_id for record in records)
assert {record["type"] for record in records} == ({"short_term", "long_term"} if expected else set())
assert len(memory.get_all_memories()) == 2002
finally:
adapter.close_connections()
memory.close_connections()
+5 -4
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 5
requires-python = ">=3.10, <3.15"
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
@@ -1044,7 +1044,7 @@ wheels = [
[[package]]
name = "praisonai-mcp"
version = "0.1.10"
version = "0.1.11"
source = { editable = "." }
dependencies = [
{ name = "click" },
@@ -1084,7 +1084,7 @@ provides-extras = ["server", "auth", "all"]
[[package]]
name = "praisonaiagents"
version = "1.7.9"
version = "1.7.10"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
@@ -1117,6 +1117,7 @@ requires-dist = [
{ name = "litellm", marker = "extra == 'memory'", specifier = ">=1.83.14" },
{ name = "markitdown", extras = ["all"], marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.20.0" },
{ name = "mcp", marker = "extra == 'parallel-search'", specifier = ">=1.30.0,<2" },
{ name = "mem0ai", marker = "extra == 'knowledge'", specifier = ">=0.1.0" },
{ name = "mem0ai", extras = ["graph"], marker = "extra == 'graph'", specifier = ">=0.1.0" },
{ name = "motor", marker = "extra == 'mongodb'", specifier = ">=3.4.0" },
@@ -1155,7 +1156,7 @@ requires-dist = [
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
]
provides-extras = ["sandbox", "mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
provides-extras = ["sandbox", "mcp", "parallel-search", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "dakera", "auth", "autonomy", "search", "crawl", "a2ui", "all"]
[[package]]
name = "propcache"
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more