Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude Opus 5.5 69d9906752 fix(browser): assert start_automation on send_json; run test_server.py in CI
#5818 moved start_automation delivery from send_text to send_json, but
test_ws_no_origin_cli_start_session still asserted send_text, and a
not-awaited check on send_text became vacuous. CI never ran test_server.py
(only test_protocol_compliance.py), so the break went unseen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 12:11:33 +00:00
Mervin PraisonandClaude Opus 5.5 e10129677f fix(ci): indent heredoc so the sync-to-praisonai-js workflow parses (#6009)
The PR body heredoc started at column 0, which ends the run: | block, so the
file was invalid YAML: every push recorded a failed zero-job run and the
workflow could not be dispatched. YAML strips the block indentation, so the
shell still sees the same text.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 13:07:13 +01:00
5ab53c48b9 fix(ci): reinstall litellm after unclecode-litellm clobbers it (#6008)
* fix(ci): reinstall litellm after unclecode-litellm clobbers it

crawl4ai depends on unclecode-litellm==1.81.13, which installs into the same
litellm/ directory as litellm. uv installs both in parallel, so whichever lands
last wins and test shards fail at random with
'cannot import name get_env_int_in_range from litellm...env_utils'.

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

* fix(ci): use version-agnostic litellm import check after reinstall

get_env_int_in_range does not exist at the litellm>=1.83.14 floor, so the
check would fail on a valid lower resolution. 'import litellm' loads
constants.py, which is where the clobbered import surfaced.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-10-26 13:05:26 +01:00
praisonai-triage-agent[bot] 649ccf2af9 Merge pull request #6000 from MervinPraison/claude/issue-5997-20261026
fix: implement shared-session observers in the gateway server
2026-10-26 11:58:58 +00:00
MervinPraison 790350da9b docs: auto-update feature parity trackers [skip ci] 2026-10-26 11:52:37 +00:00
2bbe6dbc4a fix: route LLM retry, token budgets and state saves through shared helpers (#5841)
* fix: route LLM retry, token budgets and state saves through shared helpers (fixes #5838)

- classify_error_kind: status code / exception type (error_classifier) is
  authoritative; message text only refines within that category. A 400
  containing "500" is no longer retried as overloaded, 401/403 map to auth,
  404 to model_not_found, a bare 503 to overloaded.
- _parse_retry_delay accepts the exception so Retry-After headers are honoured.
- rag.budget.estimate_tokens, the max_budget pre-call cost guard and
  RouterAgent use context.tokens.estimate_tokens_heuristic (CJK-aware).
- FileTracker, PermissionAllowlist and FileIndex saves use atomic_write_json;
  a corrupt allowlist is quarantined and loads as empty (fail closed).

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

* fix: truncate RAG context by estimated tokens, not 4 chars/token, so CJK stays within budget

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

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 12:50:51 +01:00
a5b6a28f1e fix: ImageAgent uses an image-model default instead of the chat default (#5948)
* fix: ImageAgent uses an image-model default instead of the chat default (fixes #5944)

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

* fix: ImageAgent honours an explicit model= as the image model

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

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 12:49:56 +01:00
6e0d559418 fix: shut down live_server runtime HTTP server on fixture teardown (fixes #5938) (#5940)
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 12:49:52 +01:00
708f4012cf fix(browser): extension start_session without start_automation echo (Windows) (#5818)
* fix(browser): skip start_automation echo for extension WebSocket (Windows)

* fix: update browser server tests for extension start_session contract

- Delivery tests mock send_json (matches server's send_json path)
- Add coverage for extension-originated start_session skipping start_automation echo
- Drop now-unused json_mod import

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

---------

Co-authored-by: Dhivya-Bharathy <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 12:49:47 +01:00
4f52d950ef fix: A2A cancel is real and final, shell timeout keeps partial output, async EventBus errors are logged (#5991)
* fix: make A2A cancel real and final, keep shell output on timeout, log async EventBus subscriber errors (fixes #5990)

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

* fix: drop late A2A results after cancel, cap store on completion, keep unterminated shell output on timeout

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

* fix: keep in-flight sync A2A tasks from eviction so a cancelled send can still respond

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

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-26 12:47:59 +01:00
MervinPraison ce8cb991c4 docs: auto-update feature parity trackers [skip ci] 2026-10-26 11:46:26 +00:00
953196e4ed fix: per-turn loop guard; caller errors no longer open tool circuit breaker (#5861)
* fix: per-turn loop guard and caller errors no longer open tool circuit breaker (fixes #5860)

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

* fix: caller errors are neutral breaker outcomes; streams get their own loop guard

Caller errors (bad input) were returned through the circuit breaker and
counted as successes, so they could close a half-open breaker while the
dependency was still down. They are now recorded as neither success nor
failure. start(stream=True)/iter_stream() now bind a per-turn loop guard
like chat()/achat(), so overlapping streams neither exhaust nor inherit
each other's limits.

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

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 12:45:42 +01:00
1ebbdf0a0f fix: add device-identity handshake contract to gateway hello (#6001)
* fix: add device-identity handshake contract to gateway hello (fixes #5998)

Additive, stdlib-only core contract: DeviceAuth wire shape, optional
HelloParams.device, decode-time validation (device_id must equal the
public-key fingerprint), canonical signing payload bound to nonce/agent,
and a DeviceIdentityProtocol seam for the wrapper gateway to implement.

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

* fix: canonical device-key fingerprint, overflow-safe signed_at, pinned wire vectors (#5998)

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

* fix: make DeviceAuth.signed_at integer seconds so signed bytes are language-neutral

repr(float) is Python-specific (1700000000 -> '1700000000.0', 1e16 -> '1e+16'),
so a JS/TS client could not reproduce signing_payload bytes and every
signature would fail verification.

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

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-26 12:45:37 +01:00
5140e24913 fix: release gateway admission slot while a turn awaits human approval (#5917)
* fix: release gateway admission slot while a turn awaits human approval (fixes #5912)

The approval registry enters an optional host-bound human-wait scope around
the backend wait; the gateway AdmissionGate binds one per admitted turn that
lends the run slot back and re-acquires it (bypassing the policy) when the
decision arrives. Sync agents now receive the turn context in their worker
thread so the hook reaches them. No hook bound -> unchanged behaviour.

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

* fix: never block a resumed human wait on a slot; borrow and repay instead

Addresses Greptile P1s on #5917:
- sync approvals parked in pool workers could deadlock against a turn holding
  the slot but waiting for a worker
- with parallel approvals, an early-answered branch resumed without a slot

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

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 12:45:33 +01:00
c560fe5ae3 fix: show the run's effective model and its source in config provenance (#5907)
* fix: surface the model a run actually dispatches in config provenance (fixes #5906)

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

* fix: report configured agent.model as run.model in config provenance (#5906)

run prefers config agent.model over resolve_default_model(), so provenance
must too; previously a recent/state model was reported instead.

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

* fix: keep provider notice/source on model-resolver exception fallback (#5906)

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

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-26 12:45:28 +01:00
777c4f1cce fix: single-source YAML tool-name keys for ToolResolver build and validate (#5754)
* fix: single-source YAML tool-name keys for ToolResolver build and validate (fixes #5753)

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

* fix: validate_yaml_tools accepts the same local tool sources the build merges

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

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-26 12:45:23 +01:00
3c53f43767 fix: use canonical_preset_key in param_resolver instead of inline duplicate (fixes #5148) (#5226)
Route preset resolution through parse_utils.canonical_preset_key(), the
documented single source of truth for the preset spelling rule, instead of
re-implementing its body inline. Keeps validation and resolution from drifting
apart. Behaviour-preserving.

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-26 12:45:18 +01:00
MervinPraisonandClaude Opus 5.5 734770ac37 fix: attached observers cannot rebind via hello/join; replay records only the recipient's own session
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:42:31 +00:00
praisonai-triage-agent[bot] 3a76737e49 Merge pull request #5947 from MervinPraison/claude/issue-5945-20261022
fix: screen resolved api_base addresses and scope ComputeManagedAgent files per call
2026-10-26 11:21:59 +00:00
praisonai-triage-agent[bot] 4d3d96ba3e Merge pull request #5901 from MervinPraison/claude/issue-5899-20261019
fix: FileScheduleStore loses run history across processes and hides save failures
2026-10-26 11:19:28 +00:00
5b48056781 fix: transcribe inbound voice notes on Discord and Signal (fixes #5929) (#5930)
* fix: transcribe inbound voice notes on Discord and Signal via shared STT helper (fixes #5929)

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

* fix: transcribe voice notes only after access gates, skip download when STT off, read full Signal body

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

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-10-26 12:18:12 +01:00
praisonai-triage-agent[bot] 17aac57b9b Merge pull request #5970 from MervinPraison/claude/issue-5967-20261024
fix: close SSRF gaps in MCP OAuth discovery and web_crawl urllib fallback
2026-10-26 11:14:53 +00:00
MervinPraisonandClaude Opus 5.5 23bb2af225 fix: member-driven shared-session turns reach the owner and its replay buffer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:07:10 +00:00
Mervin Praison 1fccb5739f Merge branch 'main' into claude/issue-5997-20261026 2026-10-26 12:01:34 +01:00
Mervin Praison 66e6868d49 Merge branch 'main' into claude/issue-5945-20261022 2026-10-26 12:01:28 +01:00
Mervin Praison 546cb79a7f Merge branch 'main' into claude/issue-5899-20261019 2026-10-26 12:01:22 +01:00
MervinPraisonandClaude Opus 5.5 82a062316a fix: close redirect responses in web_crawl urllib fallback
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 10:42:24 +00:00
Mervin PraisonandClaude Opus 5.5 0be88481af fix(ci): outsider flagging counts only what the run changed; writers can clear a wrong flag (#6007)
* fix(ci): outsider flagging counts only what the run changed; writers can clear a wrong flag

Every automation acts as the App, so 'the App touched it during an outsider run' also caught routine pipeline label syncs on unrelated PRs (e.g. #5901/#5907 flagged by run 39229895860, whose transcript never mentions them), and the sticky rule then cascaded flags through references (#5945/#5947 -> #5949/#5950). touchedByRun now counts only content-changing App events (comments, reviews, pushes, title/body edits, creating the item, adding the claude label) inside the run window; replaying the real run flags only its own PR. A person with write access can clear a wrong flag by removing the label; bots cannot.

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

* fix(ci): count gate-loosening label changes and bound the outsider window by the Claude job's end

Addresses review on #6007: removing no-auto-merge or a manual-only label (or adding an
override) is a touch; the flag step passes claude-response's completed_at as the upper bound.

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

* fix(ci): match reusable-workflow job names in jobCompletedAt

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 11:40:43 +01:00
Mervin Praison 04694dd1f9 Merge branch 'main' into claude/issue-5967-20261024 2026-10-26 11:37:15 +01:00
2bc462f71a fix: gateway mcp_servers block with one shared MCP client per downstream server (#6006)
* fix: gateway mcp_servers block shares one pooled MCP client across routed agents (fixes #6004)

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

* fix: prefix pooled MCP tools by server, open servers off-loop on reload, retire unused clients

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

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 11:22:36 +01:00
374701f529 fix: fail CI when TS GatewayEventType drifts from Python EventType (#6005)
* fix: lockstep test keeping TS GatewayEventType in sync with Python EventType (fixes #6003)

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

* fix: fail lockstep test on unparseable TS GatewayEventType entries

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

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 11:17:36 +01:00
fc4f83cde3 fix: don't silently replace stored key on auth login (#5950)
* fix: confirm before auth login replaces an existing stored key (fixes #5949)

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

* fix: report replaced key in auth login JSON output

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

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 11:17:32 +01:00
a2c72c6a16 chore(benchmarks): date README perf claim, archive superseded results, fix scripts, re-measure (#5836)
* chore(benchmarks): date README perf claim, archive superseded results, fix benchmark scripts, re-measure (fixes #5824)

- Re-measured on a quiet runner: Agent instantiation median 20.3us (1.6.60)
  -> 47.1us (main) on identical hardware; gradual creep, recorded in
  INSTANTIATION_RCA.md
- performance_benchmark.py: lazy framework imports, rank-based 'fastest'
  label, ratios vs the fastest, min/median/std + versions + machine info,
  no README rewrite; BENCHMARK_RESULTS.md regenerated in one run
- simple_benchmark.py: --save writes SIMPLE_BENCHMARK_RESULTS.md; README
  updater removed
- Archive superseded result files with a 'Superseded' header
- benchmark.yml: PR gate via simple_benchmark --json --fail-over-threshold;
  parse failures now fail instead of passing as 0; threshold 450 -> 150us
- Delete dead nested src/praisonai-agents/.github workflow
- README: dated 46us figure linking BENCHMARK_RESULTS.md

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

* docs(benchmarks): link follow-up issue #5835 in RCA

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

* fix(benchmarks): absolute README link, validate --iterations/--warmup, accurate reproduce command

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

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 11:17:27 +01:00
2c65365389 fix: isolate display-callback errors, atomic state writes, deadlines on remote git/HTTP (#5871)
* fix: isolate display-callback errors, write durable state atomically, add deadlines to remote git/HTTP calls (fixes #5870)

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

* fix: keep symlink target and file mode when saving config; assert cached fallback on git timeout

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

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 11:17:20 +01:00
9a6ae6ea4b fix: adapter YAML parity, owner-scoped jobs reconcile, safe-by-default agent_file root (#5881)
* fix: honour crewai/autogen YAML config, owner-scoped jobs reconcile, cwd-default agent_file root (fixes #5880)

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

* fix: pid-reuse-safe job owner, strict owner migration, keep manager_llm model name (#5880)

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

* fix: treat unreadable owner start time as unknown; ps-based start time off /proc (#5880)

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

* fix: cap litellm <1.106 (requires mcp>=2.2, conflicts with mcp<2 pin) (#5880)

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

* fix: owner-migration test injects failure at ALTER TABLE, not PRAGMA (#5880)

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

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 11:17:15 +01:00
MervinPraisonandClaude Opus 5.5 dc828259ab fix: validate visibility before creating a session, persist it in the initial snapshot, and revoke observer access when visibility is restricted
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:39:35 +00:00
MervinPraisonandClaude Opus 5.5 d220e92f15 fix: implement shared-session observers (attach/detach/observers) in gateway server (fixes #5997)
WebSocketGateway now implements core's SessionObserverProtocol honouring
SessionVisibility/SessionSharingRole: private sessions stay 1:1 (default),
read_only admits viewers, shared admits members who co-drive via the normal
message path. The live stream and final frame fan out to attached observers
gated by their OperatorScope, and an observer leaving/disconnecting detaches
instead of closing the owner's session.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:31:03 +00:00
praisonai-triage-agent[bot] 9ff7f0af3c Release v4.7.18 2026-10-26 08:45:29 +00:00
Mervin PraisonandClaude Opus 5.5 5c623203f3 fix(ci): a mentioned number that names nothing here is not outsider influence (#5994)
* fix(ci): a mentioned number that names nothing here is not outsider influence

Docs issues cite upstream PraisonAI PRs as bare #N. Those numbers 404 in the repo being checked, and the fail-closed rule for unavailable issues turned every such run outsider-origin (28 Docs PRs labelled, each paying for an extra security review). Referenced (mentioned) numbers that 404 are now ignored: there is no content to inject. Deleted issues (410) and unavailable SOURCE issues still fail closed.

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

* fix(ci): referenced issues keep the sticky outsider flag

Review finding: referencedIssueReasons dropped the wasLabelled check the PR path used to run, so deleting an outsider comment on a referenced maintainer issue made it look trusted again.

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

* fix(ci): only a missing issue may be ignored, not a later 404

Review finding: missingOk covered the whole lookup, so a 404 from listComments after the issue was found would skip the outsider-comment check (fail open). Now only the issue lookup itself may come back missing.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-26 09:45:08 +01:00
praisonai-triage-agent[bot] 94aecd0fb7 Merge pull request #5993 from MervinPraison/claude/issue-5992-20261026
fix: jobs create_app no longer clobbers shared store; standardise stops mutating root logger
2026-10-26 08:34:44 +00:00
MervinPraisonandClaude Opus 5.5 dff130535b fix: stub agent run in jobs isolation test; add root-logger regression test for ai_generator
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:22:11 +00:00
MervinPraisonandClaude Opus 5.5 f2f196e7cc fix: per-app jobs store/executor and no root-logger mutation in standardise (fixes #5992)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:15:42 +00:00
praisonai-triage-agent[bot] 5d5fb83a57 Merge pull request #5987 from MervinPraison/claude/issue-5986-20261025
fix: strip secret env vars from shell tool subprocesses
2026-10-25 10:19:25 +00:00
MervinPraisonandClaude Opus 5.5 0252e484d0 fix: keep Windows %VAR% expansion against scrubbed env; test background and cwd paths
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:08:09 +00:00
praisonai-triage-agent[bot] 3b36a80729 Merge pull request #5985 from MervinPraison/claude/issue-5984-20261025
fix: make BasePlatformAdapter satisfy the runtime adapter contract
2026-10-25 09:59:17 +00:00
MervinPraisonandClaude Opus 5.5 df9283650b fix: strip secret env vars from shell tool subprocesses (fixes #5986)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:58:42 +00:00
MervinPraisonandClaude Opus 5.5 79315db0e7 fix: honour early stop, clean up on cancel/probe failure, treat failed SendResult as undelivered
Addresses Greptile review on #5985:
- start(): create the stop event before connect() so a racing stop() is
  honoured; disconnect in finally when cancelled by the supervisor, on
  failure, or when stop() ran mid-connect.
- probe(): always disconnect (also on failure/timeout cancellation).
- DeliveryRouter: a SendResult with status 'failed' is a failed send, so
  the idempotency key is not cached for an undelivered message.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:48:24 +00:00
MervinPraisonandClaude Opus 5.5 a3bfc105a1 fix: make BasePlatformAdapter satisfy the runtime contract Bot/BotOS drive (fixes #5984)
Give the documented base runnable defaults for start/stop/send_message/
probe/health built on connect/disconnect/send/deliver, so a minimal
subclass runs under the gateway instead of hitting NotImplementedError /
AttributeError.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:39:35 +00:00
praisonai-triage-agent[bot] b02bd87726 Merge pull request #5983 from MervinPraison/claude/issue-5982-20261025
fix: stop advertising backend-less container stubs and wrong db import guidance
2026-10-25 08:20:57 +00:00
MervinPraisonandClaude Opus 5.5 52c99d9e9b fix: stop advertising backend-less container stubs and wrong db import guidance (fixes #5982)
- capabilities: drop container_* / container_file_* stubs from __all__
  (still resolvable via lazy loader so MCP/CLI adapters keep working)
- db: remove 'from praisonai import db' guidance; that name resolves to
  the praisonai.db module, which is not callable as db(...)
- __init__: DB alias comment no longer says 'recommended'

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 08:09:14 +00:00
praisonai-triage-agent[bot] 89969c3ce4 Merge pull request #5978 from MervinPraison/claude/issue-5977-20261024
fix: dedupe agent invoke submissions by Idempotency-Key
2026-10-24 10:20:30 +00:00
MervinPraisonandClaude Opus 5.5 4280b9baeb fix: harden invoke idempotency (durable-or-503, no lease expiry mid-run, keep key on cancel, unambiguous key encoding)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 10:07:45 +00:00
MervinPraisonandClaude Opus 5.5 0f219b99af fix: dedupe agent invoke submissions by Idempotency-Key (fixes #5977)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 10:00:14 +00:00
praisonai-triage-agent[bot] db02a80568 Merge pull request #5974 from MervinPraison/claude/issue-5973-20261024
fix: serve a2a/unified and agent register route deliver what they advertise
2026-10-24 08:47:59 +00:00
MervinPraisonandClaude Opus 5.5 71e47fffaa fix: reject non-object /agents bodies and malformed a2a params with client errors
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 08:36:53 +00:00
MervinPraisonandClaude Opus 5.5 eb7ad9e0d6 fix: serve a2a/unified and agent register route deliver what they advertise (fixes #5973)
- serve a2a: message/send runs the --file agents YAML via praisonai.arun
  instead of echoing; missing file rejected; non-localhost requires --api-key.
- serve unified: mounts POST /agents and /a2a when --file exists; discovery,
  banner and / list only mounted providers (recipe/mcp are separate servers).
- POST /agents/{id}/register returns 501 instead of a no-op 200.
- discovery/health handlers annotate Request so FastAPI no longer 422s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 08:22:31 +00:00
MervinPraison 4a9e6f6596 docs: auto-update feature parity trackers [skip ci] 2026-10-24 07:59:15 +00:00
praisonai-triage-agent[bot] 49b2164221 Merge pull request #5972 from MervinPraison/claude/issue-5968-20261024
fix: bound unset LLM request timeout (600s) instead of litellm's 6000s default
2026-10-24 07:58:35 +00:00
MervinPraisonandClaude Opus 5.5 46ebf067e4 fix: waive LLM.__init__.timeout signature parity (TS inherits OpenAI SDK's 600s default)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 07:47:32 +00:00
MervinPraisonandClaude Opus 5.5 ae0c5f2636 test: mock urllib opener in web_crawl size-cap test after SSRF redirect walk
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 07:46:15 +00:00
MervinPraisonandClaude Opus 5.5 d03321ac4e fix: reject non-2xx OAuth credential responses before saving tokens
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 07:45:22 +00:00
MervinPraisonandClaude Opus 5.5 e638d18b5b fix: route MCP OAuth and web_crawl urllib fetches through the SSRF guard (fixes #5967)
- oauth_discovery._require_https now also rejects private/link-local/
  ambiguous hosts via tools.url_safety.is_safe_http_url (loopback still
  allowed for local testing)
- _get_json follows redirects manually, validating each hop before it is
  requested
- oauth_registration reuses the single _require_https; credential POSTs
  (token exchange, refresh, DCR) no longer follow redirects
- web_crawl urllib fallback walks redirects by hand with SSRF checks per hop

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 07:45:22 +00:00
praisonai-triage-agent[bot] 56262f8edd Merge pull request #5971 from MervinPraison/claude/issue-5969-20261024
fix: bound web_crawl memory by streaming with a byte cap
2026-10-24 07:43:50 +00:00
MervinPraisonandClaude Opus 5.5 d4b7b18c86 test: cover web_crawl urllib fallback byte cap and run without httpx
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 07:33:02 +00:00
MervinPraisonandClaude Opus 5.5 01d4d2336b fix: bound unset LLM request timeout to 600s instead of litellm's 6000s (fixes #5968)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 07:31:51 +00:00
MervinPraisonandClaude Opus 5.5 54d3221916 fix: cap web_crawl httpx/urllib reads at 5 MB instead of buffering the whole body (fixes #5969)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 07:28:15 +00:00
praisonai-triage-agent[bot] 13b9467347 Merge pull request #5965 from MervinPraison/claude/issue-5964-20261023
fix: await async def plugin hooks instead of silently dropping them
2026-10-24 01:13:28 +00:00
MervinPraisonandClaude Opus 5.5 676abc8cb0 fix: keep an explicit disable() made during plugin on_init
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 01:01:46 +00:00
MervinPraisonandClaude Opus 5.5 b3c55dba4a fix: keep plugins out of dispatch until on_init succeeds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 00:58:40 +00:00
MervinPraisonandClaude Opus 5.5 4b956e72fc fix: roll back plugin registration when on_init fails
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 11:06:58 +00:00
MervinPraisonandClaude Opus 5.5 b85cb817ae fix: run plugin on_init outside the manager lock so async on_init cannot deadlock
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 11:03:50 +00:00
MervinPraisonandClaude Opus 5.5 13b33e5586 fix: bound async plugin hooks with a deadline and await async on_init/on_config/on_shutdown
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:19:16 +00:00
MervinPraisonandClaude Opus 5.5 5a99664b36 fix: await async def plugin hooks instead of dropping the coroutine (fixes #5964)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:58 +00:00
praisonai-triage-agent[bot] e22d74aec0 Merge pull request #5963 from MervinPraison/claude/issue-5962-20261023
fix: apply group_policy uniformly across Telegram/Discord/Slack (fixes #5962)
2026-10-23 09:56:13 +00:00
MervinPraisonandClaude Opus 5.5 a20ad85489 fix: Slack app_mention honours group_policy and slash-text counts as a command (#5962)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 09:44:31 +00:00
MervinPraisonandClaude Opus 5.5 3162c73ece fix: apply group_policy uniformly across Telegram/Discord/Slack via shared admission decision (fixes #5962)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 09:32:47 +00:00
MervinPraisonandClaude Opus 5.5 83554d8077 test: align #5945 tests with main's api_base screener after rebase
Main (#5960) replaced the passthrough api_base check with the shared
resolve-and-pin screener; drop the duplicate passthrough tests and keep the
ComputeManagedAgent per-call path test plus an IPv4-mapped literal check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:34:41 +00:00
MervinPraisonandClaude Opus 5.5 6f69043f59 fix: run api_base DNS validation off the event loop in apassthrough
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:34:10 +00:00
MervinPraisonandClaude Opus 5.5 f599e91dc8 fix: screen resolved api_base addresses and scope ComputeManagedAgent files per call (fixes #5945)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:34:07 +00:00
praisonai-triage-agent[bot] f484f5356f Merge pull request #5961 from MervinPraison/claude/issue-5960-20261023
fix: close api_base SSRF bypass, surface skipped CVE scans, warn on Claude SDK fallback
2026-10-23 08:32:06 +00:00
MervinPraisonandClaude Opus 5.5 609bfd9fcb fix: pin passthrough fallback to screened IP, resolve DNS off event loop, report pip-audit skipped packages
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:19:27 +00:00
MervinPraisonandClaude Opus 5.5 4cb8dd6cd9 fix: close api_base SSRF bypass, surface skipped CVE scans, warn on Claude SDK fallback (fixes #5960)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:13:20 +00:00
praisonai-triage-agent[bot] e2a7e8fe65 Release v4.7.17 2026-10-23 07:43:30 +00:00
praisonai-triage-agent[bot] 149771bebc Merge pull request #5958 from MervinPraison/claude/issue-5955-20261023
fix: don't send default response_format=url to OpenAI image API
2026-10-23 07:33:06 +00:00
MervinPraisonandClaude Opus 5.5 a1ce903fd1 fix: don't send default response_format=url to OpenAI image API (fixes #5955)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 07:16:24 +00:00
praisonai-triage-agent[bot] 1b41330394 Merge pull request #5954 from MervinPraison/claude/issue-5953-20261022
fix: outbox retry/purge CLI must not re-claim a live gateway's in-flight sends
2026-10-22 10:13:15 +00:00
MervinPraisonandClaude Opus 5.5 8c5b699693 fix: tidy outbox read_only comment wrap and test import (#5953)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:02:05 +00:00
MervinPraisonandClaude Opus 5.5 7a04d17f81 fix: open outbox read_only in gateway outbox retry/purge so a live gateway's in-flight sends are not re-claimed (fixes #5953)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 09:57:16 +00:00
praisonai-triage-agent[bot] e87d55ab63 Merge pull request #5952 from MervinPraison/claude/issue-5951-20261022
fix: honour praisonaiagents range in plugin dependencies at load time
2026-10-22 09:45:50 +00:00
MervinPraisonandClaude Opus 5.5 3e0ae501ba fix: use installed metadata version and honour env markers in plugin core compat check
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 09:34:26 +00:00
MervinPraisonandClaude Opus 5.5 5154e93034 fix: honour praisonaiagents range in plugin dependencies at load time (fixes #5951)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 09:27:04 +00:00
praisonai-triage-agent[bot] 4f5c61dd10 Merge pull request #5943 from MervinPraison/claude/issue-5942-20261022
fix: confine memory store names, word-based SQLite recall, SSRF guard for OpenAPI/knowledge URLs
2026-10-22 07:43:59 +00:00
MervinPraisonandClaude Opus 5.5 8de82cfab5 fix: normalise unpadded episodic dates to YYYY-MM-DD filenames
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 07:31:44 +00:00
MervinPraisonandClaude Opus 5.5 f9e368036c fix: confine memory store names, recall SQLite memory by words, guard OpenAPI/knowledge URLs (fixes #5942)
- FileMemory: validate episodic dates and session/checkpoint names via a
  shared safe_child helper; take the store lock for episodic read-modify-write
- Rules/docs/MCP-config managers and workflow checkpoints use the same helper
- SqliteMemoryAdapter: match the whole query or any significant word, ranked
- OpenAPIToolset and Knowledge._process_url call is_safe_http_url

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 07:22:59 +00:00
praisonai-triage-agent[bot] 40e1e7fedb Merge pull request #5939 from MervinPraison/claude/issue-5937-20261022
fix: bound RulesManager subdirectory walk to the workspace tree
2026-10-22 05:25:50 +00:00
MervinPraisonandClaude Opus 5.5 6dcf21bbbf fix: bound RulesManager subdirectory walk to the workspace/git-root tree (fixes #5937)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 05:13:19 +00:00
praisonai-triage-agent[bot] 1aeee2f39e Merge pull request #5931 from MervinPraison/claude/issue-5928-20261020
fix: mirror delivered text with true authorship (no context poisoning / role alternation breaks)
2026-10-20 10:19:10 +00:00
MervinPraisonandClaude Opus 5.5 640621676e fix: fold trailing user mirror into inbound prompt to keep role alternation (#5928)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 10:07:13 +00:00
MervinPraisonandClaude Opus 5.5 d7a653ed38 fix: mirror delivered text with true authorship and coalesce mirror-induced same-role turns (fixes #5928)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 09:59:13 +00:00
praisonai-triage-agent[bot] f374013a6b Merge pull request #5927 from MervinPraison/claude/issue-5925-20261020
fix: expose channel config_fields as bot run CLI flags
2026-10-20 09:48:35 +00:00
MervinPraisonandClaude Opus 5.5 9e385f81f8 fix: route bot run channel fields via channel.model_extra so a field named 'channel' reaches the adapter
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 09:36:51 +00:00
MervinPraisonandClaude Opus 5.5 a160f9a29f fix: expose channel config_fields as bot run CLI flags (fixes #5925)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 09:30:43 +00:00
praisonai-triage-agent[bot] a0b9e6384e Merge pull request #5922 from MervinPraison/claude/issue-5921-20261020
fix: register groq/ and cohere/ prefixes in LLM provider registry
2026-10-20 08:23:39 +00:00
MervinPraisonandClaude Opus 5.5 a761bb6a9c fix: register groq/ and cohere/ in LLM provider registry so it agrees with PraisonAIModel (fixes #5921)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 08:12:36 +00:00
praisonai-triage-agent[bot] d737b100fe Merge pull request #5902 from MervinPraison/claude/issue-5900-20261019
fix: BackgroundJobManager reliability (duplicate job_id, running cancel, 3.10 timeout, store pruning)
2026-10-20 01:29:39 +00:00
MervinPraisonandClaude Opus 5.5 c3b08ff13b fix: never record an empty error for jobs failed by bare SystemExit/KeyboardInterrupt (#5900)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 01:18:05 +00:00
MervinPraisonandClaude Opus 5.5 93aac824af fix: deliver on_complete/JOB_COMPLETED for jobs failed via _abort (submit failure, BaseException) (#5900)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 01:07:52 +00:00
praisonai-triage-agent[bot] a00ec0197c Merge pull request #5919 from MervinPraison/claude/issue-5918-20261019
fix: skip SHA-256 cache key in heuristic estimate_tokens path
2026-10-19 18:00:41 +00:00
MervinPraisonandClaude Opus 5.5 b4e4517ae0 test: cover validated-path cache and heuristic no-hash path in estimate_tokens
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 17:50:04 +00:00
MervinPraisonandClaude Opus 5.5 d4eacfd121 fix: skip SHA-256 cache key on heuristic token estimation path (fixes #5918)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 17:17:44 +00:00
MervinPraisonandClaude Opus 5.5 6e6175c834 fix: always resolve background job result future (submit failure, dropped work, callback BaseException) and register job+future atomically (#5900)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 08:17:05 +00:00
MervinPraisonandClaude Opus 5.5 7afb2265ee fix: keep run history newest-first when retrying unsaved records
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 08:07:16 +00:00
MervinPraisonandClaude Opus 5.5 a9b8e7cd21 fix: wake get_result waiters on cancel and stop stale writes over reused job ids (#5900)
- get_result waits on a manager-owned Future; cancel_job cancels it so
  untimed waiters get CancelledError immediately
- _persist skips writes from a job object that no longer owns its id
- cancel_job returns False for already-terminal jobs
- tests release blocked workers in finally blocks

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 07:44:51 +00:00
MervinPraisonandClaude Opus 5.5 9030155857 fix: keep history on read/write errors and contain store write errors in mark_run
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 07:32:50 +00:00
MervinPraisonandClaude Opus 5.5 63e22238a9 fix: BackgroundJobManager duplicate job_id guard, running-job cancel, 3.10 timeout, store pruning (fixes #5900)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 07:26:18 +00:00
MervinPraisonandClaude Opus 5.5 49aee2d618 fix: FileScheduleStore cross-process run history and fail-loud persistence (fixes #5899)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 07:23:47 +00:00
199 changed files with 8176 additions and 1888 deletions

No files matched your search

@@ -94,3 +94,17 @@ runs:
cd "$WRAPPER_PKG"
pip install -e "${WRAPPER_SUFFIX}"
fi
# crawl4ai depends on unclecode-litellm, a fork that writes into the same
# litellm/ directory. Installed together, the two overwrite each other's
# files in whatever order they land, so imports of litellm fail at random.
# Reinstall the resolved litellm last so its own files win.
if python -m pip show unclecode-litellm >/dev/null 2>&1; then
LITELLM_VERSION="$(python -m pip show litellm | awk '/^Version:/{print $2}')"
if [ "${{ inputs.use-uv }}" = "true" ]; then
uv pip install --system --reinstall --no-deps "litellm==${LITELLM_VERSION}"
else
pip install --force-reinstall --no-deps "litellm==${LITELLM_VERSION}"
fi
python -c "import litellm"
fi
@@ -178,13 +178,84 @@ assert('no source issue', o.sourceIssueNumbers({ head: { ref: 'feat/x' }, body:
await o.touchedByRun(runGh([{ event: 'committed', committer: { date: '2026-10-17T10:07:00Z' } }]), 'o', 'r', item(), SINCE, APP));
assert('App editing the body during the run is touched (GraphQL login has no [bot])',
await o.touchedByRun(runGh([], [{ editedAt: '2026-10-17T10:08:00Z', editor: { login: 'praisonai-triage-agent' } }]), 'o', 'r', item(), SINCE, APP));
assert('routine label sync by the App during the run is not touched (shared App identity)',
!(await o.touchedByRun(runGh([{ event: 'unlabeled', actor: { login: 'praisonai-triage-agent[bot]' }, label: { name: 'pipeline/blocked:ci' }, created_at: '2026-10-17T10:06:00Z' }]), 'o', 'r', item(), SINCE, APP)));
assert('the App adding the claude label during the run is touched (it starts a Claude run)',
await o.touchedByRun(runGh([{ event: 'labeled', actor: { login: 'praisonai-triage-agent[bot]' }, label: { name: 'claude' }, created_at: '2026-10-17T10:06:00Z' }]), 'o', 'r', item(), SINCE, APP));
const appLabel = (event, name) => runGh([{ event, actor: { login: 'praisonai-triage-agent[bot]' }, label: { name }, created_at: '2026-10-17T10:06:00Z' }]);
assert('the App removing no-auto-merge during the run is touched (it loosens the gate)',
await o.touchedByRun(appLabel('unlabeled', 'no-auto-merge'), 'o', 'r', item(), SINCE, APP));
assert('the App removing a manual-only label during the run is touched',
await o.touchedByRun(appLabel('unlabeled', 'needs-manual-review'), 'o', 'r', item(), SINCE, APP));
assert('the App adding a maintainer-accept override during the run is touched',
await o.touchedByRun(appLabel('labeled', 'maintainer-accept-core-tools'), 'o', 'r', item(), SINCE, APP));
assert('the App adding no-auto-merge (tightening) is not touched',
!(await o.touchedByRun(appLabel('labeled', 'no-auto-merge'), 'o', 'r', item(), SINCE, APP)));
assert('the gate releasing its own lock label is not touched',
!(await o.touchedByRun(appLabel('unlabeled', 'claude-merge-gate-active'), 'o', 'r', item(), SINCE, APP)));
assert('the App force-pushing during the run is touched',
await o.touchedByRun(runGh([{ event: 'head_ref_force_pushed', actor: { login: 'praisonai-triage-agent[bot]' }, created_at: '2026-10-17T10:06:00Z' }]), 'o', 'r', item(), SINCE, APP));
assert('a maintainer comment during the run is not touched',
!(await o.touchedByRun(runGh([{ event: 'commented', user: { login: 'MervinPraison' }, created_at: '2026-10-17T10:06:00Z' }]), 'o', 'r', item(), SINCE, APP)));
assert('App activity after the run ended is not touched',
!(await o.touchedByRun(runGh([{ event: 'commented', user: { login: 'praisonai-triage-agent[bot]' }, created_at: '2026-10-20T10:00:00Z' }]), 'o', 'r', item(), SINCE, APP, '2026-10-17T10:30:00Z')));
assert('App activity before the run is not touched',
!(await o.touchedByRun(runGh([{ event: 'labeled', actor: { login: 'praisonai-triage-agent[bot]' }, created_at: '2026-10-17T09:00:00Z' }]), 'o', 'r', item(), SINCE, APP)));
const editFail = await (async () => { const g = runGh([]); g.graphql = async () => { throw new Error('rate'); }; return o.touchedByRun(g, 'o', 'r', item(), SINCE, APP).then(() => null, (e) => e); })();
assert('an edit-history lookup failure fails closed (throws)', editFail instanceof Error);
{
const jobs = [{ name: 'issue-triage', completed_at: '2026-10-17T10:00:01Z' }, { name: 'claude-response', completed_at: '2026-10-17T10:20:00Z' }];
const g = { rest: { actions: { listJobsForWorkflowRun: 'jobs' } }, paginate: async () => jobs };
assert('the Claude job end time bounds the run window', (await o.jobCompletedAt(g, 'o', 'r', 1, 'claude-response')) === '2026-10-17T10:20:00Z');
const hub = { rest: g.rest, paginate: async () => [{ name: 'claude / claude-response', completed_at: '2026-10-17T10:21:00Z' }] };
assert('a reusable workflow job (caller / job) is found', (await o.jobCompletedAt(hub, 'o', 'r', 1, 'claude-response')) === '2026-10-17T10:21:00Z');
assert('an unfinished or missing job gives no bound', (await o.jobCompletedAt(g, 'o', 'r', 1, 'nope')) === null);
const bad = { rest: { actions: { listJobsForWorkflowRun: 'jobs' } }, paginate: async () => { throw new Error('403'); } };
assert('a jobs lookup failure gives no bound (caller counts up to now)', (await o.jobCompletedAt(bad, 'o', 'r', 1, 'claude-response')) === null);
}
// A request mentioning another repo's numbers as bare #N (Docs issues cite PraisonAI PRs this way)
// must not make the run outsider: a number that names nothing here carries no content.
r = await o.referencedIssueReasons(fakeGithub({ issueError: 404 }), 'o', 'r', 'see upstream #5987 and #6366');
assert('a referenced number that does not exist here (404) is not outsider influence', r.length === 0);
{
// The issue exists (writer-owned) but its comments call 404: that must not silently skip the comment check.
const g = fakeGithub({ issues: { 12: maintainerIssue } });
g.paginate = async (method) => { if (method === 'issues.listComments') throw Object.assign(new Error('nf'), { status: 404 }); return []; };
r = await o.referencedIssueReasons(g, 'o', 'r', 'see #12');
assert('a 404 after the issue was found still fails closed (only a missing issue is ignored)', r.some((x) => x.includes('#12 is unavailable')));
}
r = await o.referencedIssueReasons(fakeGithub({ issueError: 410 }), 'o', 'r', 'see #12');
assert('a referenced issue that was deleted (410) still fails closed', r.some((x) => x.includes('unavailable (HTTP 410)')));
r = await o.referencedIssueReasons(
fakeGithub({ issues: { 500: maintainerIssue }, timeline: [{ event: 'labeled', label: { name: o.OUTSIDER_ORIGIN_LABEL } }] }), 'o', 'r', 'implement #500'
);
assert('a referenced issue flagged earlier stays outsider (outsider comment since deleted)', r.some((x) => x.includes('#500 was flagged')));
r = await o.referencedIssueReasons(fakeGithub({ issues: { 500: { user: { login: 'stranger' } } } }), 'o', 'r', 'implement #500');
assert('a referenced outsider issue still counts', r.some((x) => x.includes('referenced issue #500 opened by @stranger')));
r = await o.outsiderIssueReasons(
fakeGithub({ issues: { 600: maintainerIssue } }), 'o', 'r', 600, 'Docs for upstream #5987 (PraisonAI)'
);
assert('maintainer issue citing missing numbers is not outsider', r.length === 0);
r = await o.outsiderOriginReasons(fakeGithub({ issueError: 404 }), 'o', 'r', internalPr(), []);
assert('a missing SOURCE issue (404) still fails closed', r.some((x) => x.includes('unavailable (HTTP 404)')));
// A person with write access can clear a wrong flag; bots cannot.
const lab = (event, login, t) => ({ event, label: { name: o.OUTSIDER_ORIGIN_LABEL }, actor: { login }, created_at: t });
r = await o.outsiderOriginReasons(fakeGithub({ issue: maintainerIssue, timeline: [
lab('labeled', 'praisonai-triage-agent[bot]', '2026-10-19T09:30:00Z'), lab('unlabeled', 'MervinPraison', '2026-10-26T10:00:00Z')] }), 'o', 'r', internalPr(), []);
assert('a writer removing the flag clears it', !r.some((x) => x.includes('flagged')));
r = await o.outsiderOriginReasons(fakeGithub({ issue: maintainerIssue, timeline: [
lab('labeled', 'praisonai-triage-agent[bot]', '2026-10-19T09:30:00Z'), lab('unlabeled', 'praisonai-triage-agent[bot]', '2026-10-26T10:00:00Z')] }), 'o', 'r', internalPr(), []);
assert('a bot removing the flag does not clear it', r.some((x) => x.includes('flagged')));
r = await o.outsiderOriginReasons(fakeGithub({ issue: maintainerIssue, timeline: [
lab('labeled', 'praisonai-triage-agent[bot]', '2026-10-19T09:30:00Z'), lab('unlabeled', 'stranger', '2026-10-26T10:00:00Z')] }), 'o', 'r', internalPr(), []);
assert('a non-writer removing the flag does not clear it', r.some((x) => x.includes('flagged')));
r = await o.outsiderOriginReasons(fakeGithub({ issue: maintainerIssue, timeline: [
lab('labeled', 'x[bot]', '2026-10-19T09:30:00Z'), lab('unlabeled', 'MervinPraison', '2026-10-20T10:00:00Z'), lab('labeled', 'x[bot]', '2026-10-21T10:00:00Z')] }), 'o', 'r', internalPr(), []);
assert('flagged again after a clear is flagged', r.some((x) => x.includes('flagged')));
r = await o.outsiderOriginReasons(fakeGithub({ issueError: 410 }), 'o', 'r', internalPr(), []);
assert('a deleted or transferred source issue (410) counts as unknown, so outsider', r.some((x) => x.includes('unavailable (HTTP 410)')));
+72 -8
View File
@@ -26,6 +26,15 @@ const TRUSTED_AUTOMATION = new Set(['Copilot']);
const SECURITY_STATUS_CONTEXT = 'security/outsider-review';
const SECURITY_STATUS_CREATOR = 'github-actions[bot]';
const OUTSIDER_ORIGIN_LABEL = 'origin/outsider';
// Timeline events that change content Claude or the merge gate reads.
const CONTENT_EVENTS = new Set(['commented', 'reviewed', 'line-commented', 'commit-commented', 'head_ref_force_pushed', 'renamed']);
// Label changes that loosen what Claude or the merge gate does (see merge-gate.js):
// adding a trigger or an override, or removing a stop or manual-only label.
// Pipeline status labels and the gate's own lock are routine and do not count.
const LOOSENING_LABELS = {
labeled: new Set(['claude', 'merge-gate-ci-only', 'maintainer-accept-example', 'maintainer-accept-core-tools']),
unlabeled: new Set(['no-auto-merge', 'claude-conflict-pending', 'auto-merged-by-gate', 'security', 'breaking-change', 'needs-manual-review', 'release']),
};
// Build, dependency, CI and install-time surface. Code here runs on developer
// machines, in CI with secrets, or at install time, so an outsider-origin
@@ -164,12 +173,24 @@ async function outsiderOriginReasons(github, owner, repo, pr, comments = []) {
return reasons;
}
/**
* Was this ever flagged? Timeline events cannot be edited, so deleting the
* outsider content does not clear it. Only a person with write access can clear
* a wrong flag, by removing the label after it was last added (bots cannot:
* every Claude run acts as the App).
*/
async function wasLabelled(github, owner, repo, number, label) {
try {
const events = await github.paginate(github.rest.issues.listEventsForTimeline, {
const events = (await github.paginate(github.rest.issues.listEventsForTimeline, {
owner, repo, issue_number: number, per_page: 100,
});
return events.some((e) => e.event === 'labeled' && e.label?.name === label);
})).filter((e) => (e.event === 'labeled' || e.event === 'unlabeled') && e.label?.name === label);
const lastAdded = events.map((e) => e.event).lastIndexOf('labeled');
if (lastAdded < 0) return false;
const isWriter = makePermissionLookup(github, owner, repo);
for (const e of events.slice(lastAdded + 1)) {
if (e.event === 'unlabeled' && !isAutomation(e.actor?.login) && (await isWriter(e.actor.login))) return false;
}
return true;
} catch (err) {
if (err.status === 404 || err.status === 410) return false;
throw err;
@@ -195,10 +216,12 @@ async function maintainerApprovedHead(github, owner, repo, prNumber, headSha) {
}
/** Outsider authorship of an issue and its comments ([] for a PR number). */
async function issueReasons(github, owner, repo, issueNumber, isWriter, label = 'issue') {
async function issueReasons(github, owner, repo, issueNumber, isWriter, label = 'issue', { missingOk = false } = {}) {
const reasons = [];
let found = false;
try {
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: issueNumber });
found = true;
if (issue.pull_request) {
// A PR as the source: its head and author matter too.
const { data: src } = await github.rest.pulls.get({ owner, repo, pull_number: issueNumber });
@@ -214,6 +237,11 @@ async function issueReasons(github, owner, repo, issueNumber, isWriter, label =
if (outsiders.length) reasons.push(`${label} #${issueNumber} has comments from ${outsiders.map((l) => '@' + l).join(', ')}`);
} catch (err) {
if (err.status !== 404 && err.status !== 410) throw err;
// A number merely mentioned that names nothing here (e.g. another repo's #N) has no
// content to inject; a deleted issue (410) did, so it still fails closed.
// Only the issue lookup itself may come back missing; a 404 from a later call
// (comments, the PR behind it) would otherwise skip checks and fail open.
if (missingOk && err.status === 404 && !found) return reasons;
// Deleted, transferred or never existed: who wrote it is unknown, so fail closed.
reasons.push(`${label} #${issueNumber} is unavailable (HTTP ${err.status}); provenance unknown`);
}
@@ -226,16 +254,20 @@ async function issueReasons(github, owner, repo, issueNumber, isWriter, label =
* commits to it, or edited its text. Unrelated activity by people does not count,
* so flagging what an outsider run touched does not sweep up other work.
*/
async function touchedByRun(github, owner, repo, item, since, appLogins) {
async function touchedByRun(github, owner, repo, item, since, appLogins, until = new Date().toISOString()) {
const t = new Date(since).getTime();
const after = (d) => !!d && new Date(d).getTime() >= t;
const end = new Date(until).getTime();
const after = (d) => !!d && new Date(d).getTime() >= t && new Date(d).getTime() <= end;
const isApp = (login) => !!login && (appLogins.has(login) || appLogins.has(`${login}[bot]`));
if (isApp(item.user?.login) && after(item.created_at)) return true;
const events = await github.paginate(github.rest.issues.listEventsForTimeline, {
owner, repo, issue_number: item.number, per_page: 100,
});
for (const e of events) {
if (isApp(e.actor?.login || e.user?.login) && after(e.created_at || e.submitted_at || e.updated_at)) return true;
// Only actions that change what Claude or the gate reads. Label syncing by the
// pipeline also runs as the App and must not count as the outsider run.
const content = CONTENT_EVENTS.has(e.event) || !!LOOSENING_LABELS[e.event]?.has(e.label?.name);
if (content && isApp(e.actor?.login || e.user?.login) && after(e.created_at || e.submitted_at || e.updated_at)) return true;
if (e.event === 'committed' && after(e.committer?.date)) return true;
}
let data;
@@ -258,6 +290,22 @@ async function touchedByRun(github, owner, repo, item, since, appLogins) {
return edits.some((n) => after(n.editedAt) && isApp(n.editor?.login));
}
/**
* When a job of this workflow run finished (null if unknown). It bounds what counts
* as that job's activity, so work by later runs is not attributed to it.
*/
async function jobCompletedAt(github, owner, repo, runId, jobName) {
try {
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
owner, repo, run_id: runId, per_page: 100,
});
// A reusable workflow's jobs are named '<caller job> / <job>'.
return jobs.find((j) => j.name === jobName || j.name.endsWith(` / ${jobName}`))?.completed_at || null;
} catch {
return null;
}
}
/**
* Why an issue Claude is asked to work on is outsider-influenced ([] when not):
* the issue itself, anything it was flagged as before, and every issue the
@@ -267,7 +315,7 @@ async function outsiderIssueReasons(github, owner, repo, issueNumber, requestTex
const isWriter = makePermissionLookup(github, owner, repo);
const reasons = await issueReasons(github, owner, repo, issueNumber, isWriter);
for (const n of referencedIssueNumbers(requestText, owner, repo)) {
if (n !== issueNumber) reasons.push(...(await issueReasons(github, owner, repo, n, isWriter, 'referenced issue')));
if (n !== issueNumber) reasons.push(...(await issueReasons(github, owner, repo, n, isWriter, 'referenced issue', { missingOk: true })));
}
for (const n of [issueNumber, ...referencedIssueNumbers(requestText, owner, repo)]) {
if (await wasLabelled(github, owner, repo, n, OUTSIDER_ORIGIN_LABEL)) {
@@ -278,6 +326,20 @@ async function outsiderIssueReasons(github, owner, repo, issueNumber, requestTex
return reasons;
}
/** Outsider influence from issues a request points at ("implement #500"); missing numbers are ignored. */
async function referencedIssueReasons(github, owner, repo, text) {
const isWriter = makePermissionLookup(github, owner, repo);
const reasons = [];
for (const n of referencedIssueNumbers(text, owner, repo)) {
reasons.push(...(await issueReasons(github, owner, repo, n, isWriter, 'referenced issue', { missingOk: true })));
// Sticky like everywhere else: a deleted outsider comment must not clear the flag.
if (await wasLabelled(github, owner, repo, n, OUTSIDER_ORIGIN_LABEL)) {
reasons.push(`referenced issue #${n} was flagged ${OUTSIDER_ORIGIN_LABEL} earlier`);
}
}
return reasons;
}
/** Every #N, owner/repo#N (this repo) and issue/PR URL in this repo mentioned in text. */
function referencedIssueNumbers(text, owner, repo) {
const here = `${owner}/${repo}`.toLowerCase();
@@ -418,7 +480,9 @@ module.exports = {
outsiderOriginReasons,
outsiderIssueReasons,
referencedIssueNumbers,
referencedIssueReasons,
touchedByRun,
jobCompletedAt,
outsiderSensitivePaths,
riskFlags,
formatRiskFlags,
+52 -8
View File
@@ -11,9 +11,52 @@ on:
description: 'Fail if performance regresses'
required: false
default: 'true'
pull_request:
paths:
- 'src/praisonai-agents/praisonaiagents/agent/**'
- 'src/praisonai-agents/praisonaiagents/llm/**'
- 'src/praisonai-agents/benchmarks/simple_benchmark.py'
- 'src/praisonai-agents/benchmarks/thresholds.json'
- '.github/workflows/benchmark.yml'
permissions:
contents: read
jobs:
instantiation-gate:
# Fails the PR when Agent instantiation exceeds benchmarks/thresholds.json.
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install praisonaiagents
run: |
python -m pip install --upgrade pip
pip install -e src/praisonai-agents
- name: Check instantiation threshold
run: |
cd src/praisonai-agents
python benchmarks/simple_benchmark.py --skip-crewai --json --fail-over-threshold > simple.json
python - <<'PY' >> "$GITHUB_STEP_SUMMARY"
import json
data = json.load(open('simple.json'))
budget = json.load(open('benchmarks/thresholds.json'))['praisonai_instantiation_us_max']
print(f"PraisonAI instantiation: {data['results']['PraisonAI']:.2f} us (budget {budget} us)")
PY
benchmark:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -44,7 +87,8 @@ jobs:
python benchmarks/import_time.py 2>&1 | tee import_time_output.txt
# Extract import time
IMPORT_TIME=$(grep -oP 'Median:\s+\K[\d.]+' import_time_output.txt || echo "0")
IMPORT_TIME=$(grep -oP 'Median:\s+\K[\d.]+' import_time_output.txt | head -1)
[ -n "$IMPORT_TIME" ] || { echo "::error::Could not parse import time"; exit 1; }
echo "import_time=$IMPORT_TIME" >> $GITHUB_OUTPUT
# Check threshold (200ms)
@@ -66,7 +110,8 @@ jobs:
python benchmarks/memory_usage.py 2>&1 | tee memory_output.txt
# Extract peak memory
PEAK_MEMORY=$(grep -oP 'Peak:\s+\K[\d.]+' memory_output.txt || echo "0")
PEAK_MEMORY=$(grep -oP 'Peak:\s+\K[\d.]+' memory_output.txt | head -1)
[ -n "$PEAK_MEMORY" ] || { echo "::error::Could not parse peak memory"; exit 1; }
echo "peak_memory=$PEAK_MEMORY" >> $GITHUB_OUTPUT
# Check threshold (30MB)
@@ -83,10 +128,8 @@ jobs:
id: simple
run: |
cd src/praisonai-agents
python benchmarks/simple_benchmark.py 2>&1 | tee simple_output.txt
# Extract PraisonAI time
PRAISON_TIME=$(grep -oP 'PraisonAI\s+\K[\d.]+' simple_output.txt | head -1 || echo "0")
python benchmarks/simple_benchmark.py --json > simple.json
PRAISON_TIME=$(python -c "import json; print(f\"{json.load(open('simple.json'))['results']['PraisonAI']:.2f}\")")
echo "praison_time=$PRAISON_TIME" >> $GITHUB_OUTPUT
- name: Run Tools Benchmark
@@ -96,7 +139,8 @@ jobs:
python benchmarks/tools_benchmark.py 2>&1 | tee tools_output.txt
# Extract PraisonAI time with tools
PRAISON_TOOLS_TIME=$(grep -oP 'PraisonAI\s+\K[\d.]+' tools_output.txt | head -1 || echo "0")
PRAISON_TOOLS_TIME=$(grep -oP 'PraisonAI\s+\K[\d.]+' tools_output.txt | head -1)
[ -n "$PRAISON_TOOLS_TIME" ] || { echo "::error::Could not parse tools benchmark"; exit 1; }
echo "praison_tools_time=$PRAISON_TOOLS_TIME" >> $GITHUB_OUTPUT
- name: Run Parameter Impact Benchmark
@@ -133,7 +177,7 @@ jobs:
echo "|--------|-------|-----------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Import Time | ${{ steps.import_time.outputs.import_time }}ms | <200ms | ${{ steps.import_time.outputs.import_passed == 'true' && '✅' || '❌' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Peak Memory | ${{ steps.memory.outputs.peak_memory }}MB | <30MB | ${{ steps.memory.outputs.memory_passed == 'true' && '✅' || '❌' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Agent Instantiation | ${{ steps.simple.outputs.praison_time }}μs | - | ℹ️ |" >> $GITHUB_STEP_SUMMARY
echo "| Agent Instantiation | ${{ steps.simple.outputs.praison_time }}μs | see thresholds.json | ℹ️ |" >> $GITHUB_STEP_SUMMARY
echo "| Agent with Tools | ${{ steps.tools.outputs.praison_tools_time }}μs | - | ℹ️ |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Lazy Import Check" >> $GITHUB_STEP_SUMMARY
+6 -4
View File
@@ -353,9 +353,7 @@ jobs:
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number });
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: number, per_page: 100 });
origin = await o.outsiderOriginReasons(github, owner, repo, pr, comments);
for (const n of o.referencedIssueNumbers(request, owner, repo)) {
origin.push(...(await o.outsiderIssueReasons(github, owner, repo, n)));
}
origin.push(...(await o.referencedIssueReasons(github, owner, repo, request)));
} else {
origin = await o.outsiderIssueReasons(github, owner, repo, number, request);
}
@@ -560,13 +558,17 @@ jobs:
const o = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/outsider-review.js'));
const { owner, repo } = context.repo;
const since = process.env.STARTED_AT || new Date(0).toISOString();
// Claude's job is over (its token revoked, its processes killed); later
// activity belongs to other runs. Unknown: count everything up to now.
const until = (await o.jobCompletedAt(github, owner, repo, context.runId, 'claude-response')) || new Date().toISOString();
core.info(`Outsider run window: ${since} .. ${until}`);
// Issues and PRs created, edited, commented on or pushed to since then.
const touched = await github.paginate(github.rest.issues.listForRepo, { owner, repo, state: 'all', since, per_page: 100 });
const app = new Set(['praisonai-triage-agent[bot]']);
for (const item of touched) {
let flag = true;
try {
flag = await o.touchedByRun(github, owner, repo, item, since, app);
flag = await o.touchedByRun(github, owner, repo, item, since, app, until);
} catch (err) {
core.warning(`#${item.number}: ${err.message}; flagging it to be safe`);
}
+5 -5
View File
@@ -96,11 +96,11 @@ jobs:
--head "$branch" \
--title "$msg" \
--body "$(cat <<EOF
Automated sync from [\`MervinPraison/PraisonAI\`](https://github.com/MervinPraison/PraisonAI) \`${SOURCE_PATH}/\` into the npm mirror repo.
Automated sync from [\`MervinPraison/PraisonAI\`](https://github.com/MervinPraison/PraisonAI) \`${SOURCE_PATH}/\` into the npm mirror repo.
Canonical development is in the monorepo; this PR updates the public \`praisonai-js\` checkout.
Canonical development is in the monorepo; this PR updates the public \`praisonai-js\` checkout.
Excludes from rsync source: \`node_modules/\`, \`dist/\`, \`coverage/\`. \`.github/\` on praisonai-js is unchanged.
EOF
)"
Excludes from rsync source: \`node_modules/\`, \`dist/\`, \`coverage/\`. \`.github/\` on praisonai-js is unchanged.
EOF
)"
echo "Opened PR on ${TARGET_REPO} (${branch} → ${TARGET_BRANCH})."
+1 -1
View File
@@ -256,7 +256,7 @@ jobs:
"
/tmp/praisonai-browser-standalone/bin/praisonai-browser --help
bash "$GITHUB_WORKSPACE/scripts/check_c11_browser_imports.sh"
python -m pytest src/praisonai-browser/tests/browser/test_protocol_compliance.py -q -m "not network"
python -m pytest src/praisonai-browser/tests/browser/test_protocol_compliance.py src/praisonai-browser/tests/browser/test_server.py -q -m "not network"
- name: Standalone praisonai-mcp smoke (C12)
run: |
+6 -4
View File
@@ -1002,11 +1002,13 @@ agent.start('Write a movie script about a robot in Mars');
## ⚡ Performance
PraisonAI is built for speed, with agent instantiation in around 14μs. This reduces overhead, improves responsiveness, and helps multi-agent systems scale efficiently in real-world production workloads.
PraisonAI keeps framework overhead microsecond-class: creating an `Agent` (one tool, no LLM call) averaged **46 μs** in our 2026-10-15 run, so per-agent setup is negligible next to model latency in multi-agent workloads.
| Performance Metric | PraisonAI |
|--------------------|-----------|
| Avg Instantiation Time | **14 μs** |
| Performance Metric | PraisonAI | Measured |
|--------------------|-----------|----------|
| Avg Agent instantiation (1 tool, 1000 iterations) | **46 μs** | 2026-10-15 · praisonaiagents 1.7.12 |
Methodology, comparisons and how to reproduce: [benchmarks/BENCHMARK_RESULTS.md](https://github.com/MervinPraison/PraisonAI/blob/main/src/praisonai-agents/benchmarks/BENCHMARK_RESULTS.md) · [docs](https://docs.praison.ai/docs/developers/benchmarks)
---
+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.16" \
"praisonai>=4.7.18" \
"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.16" \
"praisonai>=4.7.18" \
"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.16" \
"praisonai>=4.7.18" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -5,7 +5,6 @@ FastContext provides rapid code search capabilities.
Use it directly from the context module for code search.
FastContext provides:
- 10-20x faster code search than traditional methods
- Parallel execution (8 concurrent searches)
- Automatic caching for repeated queries
+1 -1
View File
@@ -2,7 +2,7 @@
Example 1: Basic Fast Context Usage
This example demonstrates the basic usage of Fast Context for rapid code search.
Fast Context is 10-20x faster than traditional sequential search because it:
Fast Context speeds up code search because it:
- Executes up to 8 tool calls in parallel
- Limits search to 4 turns maximum
- Uses a lightweight model optimized for search
-280
View File
@@ -1,280 +0,0 @@
# Performance Benchmark CI
# Runs import time and instantiation benchmarks on push/PR
# Fails if import time exceeds 200ms threshold
name: Performance Benchmark
on:
push:
branches: [main, develop]
paths:
- 'praisonaiagents/**'
- 'benchmarks/**'
- '.github/workflows/benchmark.yml'
pull_request:
branches: [main, develop]
paths:
- 'praisonaiagents/**'
- 'benchmarks/**'
- '.github/workflows/benchmark.yml'
workflow_dispatch: # Allow manual trigger
jobs:
benchmark:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install package
run: |
pip install -e .
pip install pytest
- name: Measure Import Time
id: import_time
run: |
# Measure import time (full Agent import)
IMPORT_TIME=$(python -c "
import time
import sys
# Clear any cached modules
for key in list(sys.modules.keys()):
if 'praisonai' in key:
del sys.modules[key]
start = time.perf_counter()
from praisonaiagents import Agent
elapsed = (time.perf_counter() - start) * 1000
print(f'{elapsed:.1f}')
")
echo "import_time=$IMPORT_TIME" >> $GITHUB_OUTPUT
echo "Import time: ${IMPORT_TIME}ms"
- name: Check Import Time Threshold
run: |
IMPORT_TIME=${{ steps.import_time.outputs.import_time }}
THRESHOLD=200
echo "Import time: ${IMPORT_TIME}ms"
echo "Threshold: ${THRESHOLD}ms"
# Compare using bc for floating point
if (( $(echo "$IMPORT_TIME > $THRESHOLD" | bc -l) )); then
echo "❌ FAIL: Import time ${IMPORT_TIME}ms exceeds threshold ${THRESHOLD}ms"
exit 1
else
echo "✅ PASS: Import time ${IMPORT_TIME}ms is within threshold ${THRESHOLD}ms"
fi
- name: Measure Instantiation Time
run: |
python -c "
import time
from praisonaiagents import Agent
# Warmup
for _ in range(5):
Agent(name='Test', output='silent')
# Measure
times = []
for _ in range(50):
start = time.perf_counter()
Agent(name='Test', output='silent')
times.append((time.perf_counter() - start) * 1e6)
avg = sum(times) / len(times)
min_t = min(times)
max_t = max(times)
print(f'Instantiation time:')
print(f' Average: {avg:.2f}μs')
print(f' Min: {min_t:.2f}μs')
print(f' Max: {max_t:.2f}μs')
"
- name: Verify Lazy Imports
run: |
python -c "
import sys
# Clear any cached modules
for key in list(sys.modules.keys()):
if 'praisonai' in key or 'litellm' in key:
del sys.modules[key]
# Import package
import praisonaiagents
# Check heavy deps are NOT loaded
heavy_deps = ['litellm', 'chromadb', 'mem0', 'rich']
loaded = []
for dep in heavy_deps:
if dep in sys.modules:
loaded.append(dep)
if loaded:
print(f'⚠️ WARNING: Heavy deps loaded eagerly: {loaded}')
# Don't fail, just warn - some deps may be needed
else:
print('✅ All heavy deps are lazy loaded')
"
- name: Run Quick Benchmark (if available)
continue-on-error: true
run: |
if [ -f "benchmarks/quick_benchmark.py" ]; then
python benchmarks/quick_benchmark.py --fast || true
else
echo "Quick benchmark not found, skipping"
fi
# Real API benchmark - only runs when API keys are available
# Triggered manually or on main branch with secrets
real-api-benchmark:
runs-on: ubuntu-latest
timeout-minutes: 15
if: github.event_name == 'workflow_dispatch' || (github.ref == 'refs/heads/main' && github.event_name == 'push')
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install package
run: |
pip install -e .
pip install pytest
- name: Check API Key Availability
id: check_keys
run: |
if [ -n "${{ secrets.OPENAI_API_KEY }}" ]; then
echo "has_openai=true" >> $GITHUB_OUTPUT
echo "✅ OpenAI API key available"
else
echo "has_openai=false" >> $GITHUB_OUTPUT
echo "⚠️ OpenAI API key not available, skipping real API tests"
fi
- name: Run Real API Benchmark
if: steps.check_keys.outputs.has_openai == 'true'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
python -c "
import time
import os
# Verify API key is set
if not os.environ.get('OPENAI_API_KEY'):
print('❌ OPENAI_API_KEY not set')
exit(1)
from praisonaiagents import Agent
# Create a simple agent
agent = Agent(
name='BenchmarkAgent',
instructions='You are a helpful assistant. Be concise.',
llm='gpt-4o-mini',
output='silent'
)
# Warmup run
print('Running warmup...')
agent.chat('Say hello in one word')
# Benchmark runs
print('Running benchmark (3 iterations)...')
times = []
for i in range(3):
start = time.perf_counter()
response = agent.chat('What is 2+2? Answer with just the number.')
elapsed = time.perf_counter() - start
times.append(elapsed)
print(f' Run {i+1}: {elapsed:.2f}s')
avg = sum(times) / len(times)
print(f'')
print(f'Real API Benchmark Results:')
print(f' Average response time: {avg:.2f}s')
print(f' Min: {min(times):.2f}s')
print(f' Max: {max(times):.2f}s')
# Verify response quality
if '4' in response:
print('✅ Response quality check passed')
else:
print(f'⚠️ Unexpected response: {response}')
"
- name: Run Multi-Agent Benchmark
if: steps.check_keys.outputs.has_openai == 'true'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
python -c "
import time
import os
from praisonaiagents import Agent, Agents, Task
# Create agents
researcher = Agent(
name='Researcher',
instructions='You research topics. Be brief.',
llm='gpt-4o-mini',
output='silent'
)
writer = Agent(
name='Writer',
instructions='You write summaries. Be brief.',
llm='gpt-4o-mini',
output='silent'
)
# Create tasks
task1 = Task(
description='List 2 facts about Python programming',
agent=researcher,
expected_output='2 brief facts'
)
task2 = Task(
description='Summarize the facts in one sentence',
agent=writer,
expected_output='One sentence summary'
)
# Run workflow
print('Running multi-agent workflow...')
start = time.perf_counter()
agents = Agents(agents=[researcher, writer], tasks=[task1, task2])
result = agents.start()
elapsed = time.perf_counter() - start
print(f'')
print(f'Multi-Agent Benchmark Results:')
print(f' Total workflow time: {elapsed:.2f}s')
print(f' Agents: 2')
print(f' Tasks: 2')
print('✅ Multi-agent workflow completed successfully')
"
@@ -1,70 +1,47 @@
# PraisonAI Agents - Benchmark Results
**Generated:** 2026-06-17
**Methodology:** 10 warmup runs + 1000 measured iterations (`performance_benchmark.py` config)
**Config:** `Agent(name=..., instructions=..., model="gpt-4o-mini", tools=[get_weather], output="silent")`
**Generated:** 2026-10-15 (single run of `performance_benchmark.py`)
**Methodology:** 10 warmup runs + 1000 measured iterations per framework
**Setup (every framework):** one agent, model `gpt-4o-mini`, one `get_weather` tool, no LLM call. Tool wrapping (`function_tool`, `@tool`, ...) happens inside the timed call. Frameworks that were not installed are omitted.
## Agent Instantiation Time
| Framework | Avg Time (μs) | Min (μs) | Median (μs) | Relative |
|-----------|---------------|----------|-------------|----------|
| OpenAI Agents SDK | 5.05 | — | — | 1.00x (fastest) |
| Agno | 5.27 | — | — | 1.04x |
| **PraisonAI** | **13.62** | **12.58** | **12.96** | **2.69x** |
| PraisonAI (LiteLLM) | 14.49 | 13.37 | 13.71 | 2.86x |
### PraisonAI detail (1000 iter)
| Variant | Avg (μs) | Min (μs) | Median (μs) | Std (μs) |
|---------|----------|----------|-------------|----------|
| With tools (`gpt-4o-mini`) | 13.62 | 12.58 | 12.96 | 2.73 |
| With tools (`openai/gpt-4o-mini`) | 14.49 | 13.37 | 13.71 | 4.14 |
| Minimal, no tools | 13.40 | — | — | — |
## Historical baseline
| Source | Config | Avg (μs) | Notes |
|--------|--------|----------|-------|
| `performance_benchmark.py` (Dec 2025) | tools + 1000 iter | **3.20** | Pre-init-bloat baseline |
| `simple_benchmark.py` (Dec 2025) | no tools, 100 iter | **3.77** | Saved in prior `BENCHMARK_RESULTS.md` |
| Current (Jun 2026) | tools + 1000 iter | **13.62** | After lazy-init optimisations (was ~31 μs pre-fix) |
| Framework | Avg (μs) | Min (μs) | Median (μs) | Std (μs) | Avg memory (KiB) | Relative |
|-----------|----------|----------|-------------|----------|------------------|----------|
| Agno | 9.51 | 9.16 | 9.33 | 1.00 | 6.83 | 1.00x (fastest) |
| PraisonAI | 45.55 | 42.63 | 44.26 | 4.11 | 20.55 | 4.79x |
| PraisonAI (LiteLLM) | 46.94 | 44.65 | 45.82 | 3.26 | 20.81 | 4.94x |
| OpenAI Agents SDK | 591.02 | 529.26 | 566.69 | 60.62 | 60.19 | 62.18x |
| PydanticAI | 1,021.99 | 882.02 | 935.63 | 1,674.03 | 51.22 | 108x |
| LangGraph | 3,232.91 | 2,836.27 | 3,183.02 | 233.32 | 89.66 | 340x |
## Package Versions
| Package | Version |
|---------|---------|
| PraisonAI | 1.6.60 |
| Agno | 2.4.2 |
| OpenAI Agents SDK | 0.7.0 |
| praisonaiagents | 1.7.12 |
| agno | 3.1.3 |
| pydantic-ai-slim | 2.56.0 |
| openai-agents | 0.23.1 |
| langgraph | 1.2.15 |
## Machine
| Field | Value |
|-------|-------|
| Platform | Linux-6.17.0-1022-azure-x86_64-with-glibc2.39 |
| Processor | x86_64 |
| CPUs | 4 |
| Python | 3.12.3 |
| Load avg (1 min) at start | 0.85 |
## How to Reproduce
PraisonAI-only (fast; avoids CrewAI/LangGraph import hang):
```bash
cd praisonai-agents
PYTHONWARNINGS='ignore::DeprecationWarning' python -c "
import time, statistics
from typing import Literal
from praisonaiagents import Agent
def get_weather(city: Literal['nyc', 'sf']):
return 'cloudy' if city == 'nyc' else 'sunny'
def bench(fn, n=1000, w=10):
for _ in range(w): fn()
times = []
for _ in range(n):
s = time.perf_counter(); fn(); times.append(time.perf_counter()-s)
print(f'avg={statistics.mean(times)*1e6:.2f} μs')
bench(lambda: Agent(name='Test', instructions='Hi', model='gpt-4o-mini', tools=[get_weather], output='silent'))
"
cd src/praisonai-agents
pip install -e . agno openai-agents "pydantic-ai-slim[openai]" langgraph langchain-openai crewai
# OpenAI-backed constructors need a key to exist; no request is made
OPENAI_API_KEY=sk-dummy PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/performance_benchmark.py --save --iterations 1000 --warmup 10 --only praisonai praisonai_litellm agno pydantic openai_agents langgraph
```
Full multi-framework benchmark:
```bash
cd praisonai-agents
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/performance_benchmark.py --save
```
Older, superseded result files live in [`archive/`](archive/).
@@ -6,7 +6,7 @@
## Summary
Instantiation regressed from **~3.2 μs** (Dec 2025) to **~31 μs** (~10×). After fixes, steady-state is **~16 μs** (~2× historical). PraisonAI is competitive with Agno (~5 μs) on warmed benchmarks using `model=`.
Instantiation regressed from **~3.2 μs** (Dec 2025) to **~31 μs** (~10×). After fixes, steady-state is **~16 μs** (~2× historical). Agno measured ~5 μs on the same machine at the time, so PraisonAI was still ~3× slower; see `BENCHMARK_RESULTS.md` for current numbers.
## Before vs After Fixes
@@ -53,10 +53,45 @@ Instantiation regressed from **~3.2 μs** (Dec 2025) to **~31 μs** (~10×). Aft
The residual ~13 μs is primarily **`Agent.__init__` size** (~1200 lines of consolidated config resolution added since v0.1.5). Further optimisation requires a dedicated slim fast-path for `Agent(name=..., output="silent")` without full config object churn.
## Re-measure (2026-10-15)
Quiet machine (GitHub-hosted `ubuntu` x86_64 runner, 4 vCPU, load 0.77, Python 3.12.3). Same config as the
June run (`performance_benchmark.py`: one tool, 10–20 warmup + 1000 iterations). Both versions were run on the
same machine, so the two columns are directly comparable. The June numbers in `BENCHMARK_RESULTS.md` came from a
faster machine.
| Code | Avg (μs) | Min (μs) | Median (μs) |
|------|----------|----------|-------------|
| praisonaiagents 1.6.60 (`709a03878`) | 20.65 | 19.85 | 20.30 |
| `main` @ `a84e42aa9` (1.7.12 dev) | 47.92 | 45.71 | 47.11 |
The slowdown is real: **~2.3×** on identical hardware. Median, sampled along `main` first-parent history:
| Date | Commit | Median (μs) |
|------|--------|-------------|
| 2026-06-17 | `709a03878` (1.6.60) | 20.3 |
| 2026-06-24 | `bb2d35ba6` | 24.2 |
| 2026-07-01 | `f45c49b32` | 26.7 |
| 2026-07-15 | `b1086c275` | 30.5 |
| 2026-08-05 | `aadb62acf` | 29.7 |
| 2026-08-19 | `d32fe29b4` | 34.0 |
| 2026-08-26 | `7746520df` | **3,063** (transient ms-scale regression, since fixed) |
| 2026-09-03 | `17d8726c3` | **3,012** |
| 2026-09-10 | `8d2b4cdbc` | 48.3 |
| 2026-10-02 | `a2b52f669` | 60.2 |
| 2026-10-14 | `a84e42aa9` | 47.2 |
`git bisect` with a 33 μs cut-off has no single culprit: it lands on a wrapper-only commit, and neighbouring
commits differ by less than the noise. The cost is gradual creep. cProfile on `main` puts ~43% of construction
time in the body of `Agent.__init__`, and no callee costs more than ~10%. The fix is the slim construction path
described above, tracked in #5835. A ~3 ms regression shipped for two weeks unnoticed; the PR gate
in `.github/workflows/benchmark.yml` (`simple_benchmark.py --json --fail-over-threshold`, 150 μs budget) now
catches regressions of that size.
## How to reproduce
```bash
cd praisonai-agents
cd src/praisonai-agents
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/simple_benchmark.py
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/performance_benchmark.py
PYTHONWARNINGS='ignore::DeprecationWarning' python benchmarks/performance_benchmark.py --only praisonai
```
@@ -1,3 +1,5 @@
> **Superseded.** Historical result from 2026-01-13, kept for reference only. Package versions and methodology differ from current runs; do not cite. Current results: [../BENCHMARK_RESULTS.md](../BENCHMARK_RESULTS.md).
# PraisonAI Agents - Real Execution Benchmark
**Generated:** 2026-01-13 06:07:28
@@ -1,3 +1,5 @@
> **Superseded.** Historical result from 2026-01-13, kept for reference only. Package versions and methodology differ from current runs; do not cite. Current results: [../BENCHMARK_RESULTS.md](../BENCHMARK_RESULTS.md).
# PraisonAI Agents - Real-World Execution Benchmark
**Generated:** 2026-01-13 14:42:59
@@ -1,3 +1,5 @@
> **Superseded.** Historical result from 2025-12-18, kept for reference only. Package versions and methodology differ from current runs; do not cite. Current results: [../BENCHMARK_RESULTS.md](../BENCHMARK_RESULTS.md).
# PraisonAI Agents - Benchmark Results
**Generated:** 2025-12-18 13:23:43
@@ -1,3 +1,5 @@
> **Superseded.** Historical result from 2026-01-23, kept for reference only. Package versions and methodology differ from current runs; do not cite. Current results: [../BENCHMARK_RESULTS.md](../BENCHMARK_RESULTS.md).
# PraisonAI Agents - Tools Benchmark Results
**Generated:** 2026-01-23 11:50:18
@@ -6,6 +6,8 @@ multiple AI agent frameworks using industry-standard methodology.
Usage:
python benchmarks/performance_benchmark.py
python benchmarks/performance_benchmark.py --save # rewrite BENCHMARK_RESULTS.md
python benchmarks/performance_benchmark.py --only praisonai
"""
import gc
@@ -134,39 +136,14 @@ def run_benchmark(func: Callable, name: str, iterations: int = 1000, warmup: int
# ============================================================================
# Framework Imports
# Framework factories
#
# Each builder imports its framework lazily and returns a zero-arg callable
# that creates one agent with one tool. A missing framework raises ImportError
# and is skipped, so the script runs with only praisonaiagents installed.
# ============================================================================
from praisonaiagents import Agent as PraisonAgent
from agno.agent import Agent as AgnoAgent
from agno.models.openai import OpenAIChat
# Additional frameworks for comparison
try:
from pydantic_ai import Agent as PydanticAgent
PYDANTIC_AVAILABLE = True
except ImportError:
PYDANTIC_AVAILABLE = False
try:
from crewai.agent import Agent as CrewAgent
from crewai.tools import tool as crewai_tool
CREWAI_AVAILABLE = True
except ImportError:
CREWAI_AVAILABLE = False
try:
from langchain_core.tools import tool as langchain_tool
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
LANGGRAPH_AVAILABLE = True
except ImportError:
LANGGRAPH_AVAILABLE = False
try:
from agents import Agent as OpenAIAgent, function_tool
OPENAI_AGENTS_AVAILABLE = True
except ImportError:
OPENAI_AGENTS_AVAILABLE = False
INSTRUCTIONS = "Be concise, reply with one sentence."
def get_weather(city: Literal["nyc", "sf"]):
@@ -180,195 +157,181 @@ def get_weather(city: Literal["nyc", "sf"]):
tools = [get_weather]
def instantiate_praisonai_agent():
"""Instantiate a PraisonAI agent with one tool."""
return PraisonAgent(
name="Test Agent",
instructions="Be concise, reply with one sentence.",
model="gpt-4o-mini",
tools=tools,
output="silent"
def build_praisonai():
from praisonaiagents import Agent as PraisonAgent
return lambda: PraisonAgent(
name="Test Agent", instructions=INSTRUCTIONS, model="gpt-4o-mini",
tools=tools, output="silent",
)
def instantiate_praisonai_litellm_agent():
"""Instantiate a PraisonAI agent with LiteLLM backend."""
return PraisonAgent(
name="Test Agent",
instructions="Be concise, reply with one sentence.",
model="openai/gpt-4o-mini",
tools=tools,
output="silent"
def build_praisonai_litellm():
from praisonaiagents import Agent as PraisonAgent
return lambda: PraisonAgent(
name="Test Agent", instructions=INSTRUCTIONS, model="openai/gpt-4o-mini",
tools=tools, output="silent",
)
def instantiate_agno_agent():
"""Instantiate an Agno agent with one tool."""
return AgnoAgent(model=OpenAIChat(id="gpt-4o-mini"), tools=tools)
def build_agno():
from agno.agent import Agent as AgnoAgent
from agno.models.openai import OpenAIChat
return lambda: AgnoAgent(model=OpenAIChat(id="gpt-4o-mini"), instructions=INSTRUCTIONS, tools=tools)
def instantiate_pydantic_agent():
"""Instantiate a PydanticAI agent with one tool."""
agent = PydanticAgent("openai:gpt-4o-mini", system_prompt="Be concise, reply with one sentence.")
@agent.tool_plain
def get_weather_pydantic(city: Literal["nyc", "sf"]):
"""Use this to get weather information."""
if city == "nyc":
return "It might be cloudy in nyc"
elif city == "sf":
return "It's always sunny in sf"
return agent
def build_pydantic():
from pydantic_ai import Agent as PydanticAgent
def create():
agent = PydanticAgent("openai:gpt-4o-mini", system_prompt=INSTRUCTIONS)
agent.tool_plain(get_weather)
return agent
return create
def instantiate_openai_agents():
"""Instantiate an OpenAI Agents SDK agent with one tool."""
return OpenAIAgent(
name="Test agent",
instructions="Be concise, reply with one sentence.",
model="gpt-4o-mini",
def build_openai_agents():
from agents import Agent as OpenAIAgent, function_tool
return lambda: OpenAIAgent(
name="Test agent", instructions=INSTRUCTIONS, model="gpt-4o-mini",
tools=[function_tool(get_weather)],
)
def instantiate_langgraph_agent():
"""Instantiate a LangGraph agent with one tool."""
@langchain_tool
def get_weather_lg(city: Literal["nyc", "sf"]):
"""Use this to get weather information."""
if city == "nyc":
return "It might be cloudy in nyc"
elif city == "sf":
return "It's always sunny in sf"
return create_react_agent(model=ChatOpenAI(model="gpt-4o-mini"), tools=[get_weather_lg])
def instantiate_crewai_agent():
"""Instantiate a CrewAI agent with one tool."""
@crewai_tool("Weather Tool")
def get_weather_crew(city: Literal["nyc", "sf"]):
"""Use this to get weather information."""
if city == "nyc":
return "It might be cloudy in nyc"
elif city == "sf":
return "It's always sunny in sf"
return CrewAgent(
llm="gpt-4o-mini",
role="Test Agent",
goal="Be concise, reply with one sentence.",
tools=[get_weather_crew],
backstory="Test",
def build_langgraph():
from langchain_core.tools import tool as langchain_tool
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
return lambda: create_react_agent(
model=ChatOpenAI(model="gpt-4o-mini"), tools=[langchain_tool(get_weather)],
)
def get_package_versions():
"""Get version numbers for all benchmarked packages."""
def build_crewai():
from crewai.agent import Agent as CrewAgent
from crewai.tools import tool as crewai_tool
def create():
return CrewAgent(
llm="gpt-4o-mini", role="Test Agent", goal=INSTRUCTIONS,
tools=[crewai_tool("Weather Tool")(get_weather)], backstory="Test",
)
return create
# key -> (display name, PyPI package, builder)
FRAMEWORKS = {
'praisonai': ('PraisonAI', 'praisonaiagents', build_praisonai),
'praisonai_litellm': ('PraisonAI (LiteLLM)', 'praisonaiagents', build_praisonai_litellm),
'agno': ('Agno', 'agno', build_agno),
'pydantic': ('PydanticAI', 'pydantic-ai-slim', build_pydantic),
'openai_agents': ('OpenAI Agents SDK', 'openai-agents', build_openai_agents),
'langgraph': ('LangGraph', 'langgraph', build_langgraph),
'crewai': ('CrewAI', 'crewai', build_crewai),
}
def get_package_versions(keys=None):
"""Get version numbers for benchmarked packages."""
from importlib.metadata import version as get_version
packages = {
'PraisonAI': 'praisonaiagents',
'Agno': 'agno',
'PydanticAI': 'pydantic-ai',
'OpenAI Agents SDK': 'openai-agents',
'LangGraph': 'langgraph',
'CrewAI': 'crewai'
}
versions = {}
for display_name, pkg_name in packages.items():
for key in keys or FRAMEWORKS:
display_name, pkg_name, _ = FRAMEWORKS[key]
if pkg_name in versions:
continue
try:
versions[display_name] = get_version(pkg_name)
versions[pkg_name] = get_version(pkg_name)
except Exception:
versions[display_name] = 'not installed'
versions[pkg_name] = 'not installed'
return versions
def save_benchmark_results(results: dict, baseline):
"""Save benchmark results to BENCHMARK_RESULTS.md and update README.md"""
def get_machine_info() -> dict:
"""Describe the machine so results are traceable."""
import os
import re
from datetime import datetime
benchmarks_dir = os.path.dirname(__file__)
filepath = os.path.join(benchmarks_dir, 'BENCHMARK_RESULTS.md')
# Get package versions
versions = get_package_versions()
display_names = {
'agno': 'Agno',
'praisonai': 'PraisonAI',
'praisonai_litellm': 'PraisonAI (LiteLLM)',
'pydantic': 'PydanticAI',
'openai_agents': 'OpenAI Agents SDK',
'langgraph': 'LangGraph',
'crewai': 'CrewAI'
import platform
load = os.getloadavg()[0] if hasattr(os, 'getloadavg') else None
return {
'Platform': platform.platform(),
'Processor': platform.processor() or platform.machine(),
'CPUs': os.cpu_count(),
'Python': platform.python_version(),
'Load avg (1 min) at start': f'{load:.2f}' if load is not None else 'n/a',
}
# Build table rows
table_rows = []
sorted_results = sorted(results.items(), key=lambda x: x[1].avg_run_time)
for name, result in sorted_results:
display_name = display_names.get(name, name)
avg_us = result.avg_run_time * 1e6
ratio = result.avg_run_time / baseline.avg_run_time if baseline.avg_run_time > 0 else 0
if name == 'praisonai':
table_rows.append(f'| **{display_name}** | **{avg_us:.2f}** | **1.00x (fastest)** |')
else:
avg_str = f'{avg_us:,.2f}' if avg_us >= 1000 else f'{avg_us:.2f}'
ratio_str = f'{ratio:,.0f}x' if ratio >= 100 else f'{ratio:.2f}x'
table_rows.append(f'| {display_name} | {avg_str} | {ratio_str} |')
# Save to BENCHMARK_RESULTS.md
def rank_results(results: dict) -> list:
"""Return ``(key, result, ratio)`` sorted fastest first, ratio vs the fastest."""
ranked = sorted(results.items(), key=lambda x: x[1].avg_run_time)
fastest = ranked[0][1].avg_run_time if ranked else 0
return [(k, r, r.avg_run_time / fastest if fastest > 0 else 0) for k, r in ranked]
def save_benchmark_results(results: dict, machine: dict, iterations: int, warmup: int,
filename: str = 'BENCHMARK_RESULTS.md') -> str:
"""Write every framework's results from this single run to ``BENCHMARK_RESULTS.md``."""
import os
from datetime import datetime
filepath = os.path.join(os.path.dirname(__file__), filename)
versions = get_package_versions(results.keys())
def us(seconds):
value = seconds * 1e6
return f'{value:,.2f}' if value >= 1000 else f'{value:.2f}'
lines = [
'# PraisonAI Agents - Benchmark Results', '',
f'**Generated:** {datetime.now().strftime("%Y-%m-%d")} (single run of `performance_benchmark.py`)',
f'**Methodology:** {warmup} warmup runs + {iterations} measured iterations per framework',
f'**Setup (every framework):** one agent, model `gpt-4o-mini`, one `get_weather` tool, '
'no LLM call. Tool wrapping (`function_tool`, `@tool`, ...) happens inside the timed call. '
'Frameworks that were not installed are omitted.',
'', '## Agent Instantiation Time', '',
'| Framework | Avg (μs) | Min (μs) | Median (μs) | Std (μs) | Avg memory (KiB) | Relative |',
'|-----------|----------|----------|-------------|----------|------------------|----------|',
]
for rank, (key, r, ratio) in enumerate(rank_results(results)):
relative = '1.00x (fastest)' if rank == 0 else (f'{ratio:,.0f}x' if ratio >= 100 else f'{ratio:.2f}x')
lines.append(
f'| {FRAMEWORKS[key][0]} | {us(r.avg_run_time)} | {us(r.min_run_time)} | '
f'{us(r.median_run_time)} | {us(r.std_dev_run_time)} | '
f'{r.avg_memory_usage * 1024:.2f} | {relative} |'
)
lines += ['', '## Package Versions', '', '| Package | Version |', '|---------|---------|']
lines += [f'| {pkg} | {ver} |' for pkg, ver in versions.items()]
lines += ['', '## Machine', '', '| Field | Value |', '|-------|-------|']
lines += [f'| {k} | {v} |' for k, v in machine.items()]
lines += [
'', '## How to Reproduce', '', '```bash', 'cd src/praisonai-agents',
'pip install -e . agno openai-agents "pydantic-ai-slim[openai]" langgraph langchain-openai crewai',
"# OpenAI-backed constructors need a key to exist; no request is made",
"OPENAI_API_KEY=sk-dummy PYTHONWARNINGS='ignore::DeprecationWarning' "
f"python benchmarks/performance_benchmark.py --save --iterations {iterations} "
f"--warmup {warmup} --only {' '.join(results)}",
'```', '',
'Older, superseded result files live in [`archive/`](archive/).', '',
]
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('**Methodology:** 10 warmup runs + 1000 measured iterations\n\n')
f.write('## Agent Instantiation Time\n\n')
f.write('| Framework | Avg Time (μs) | Relative |\n')
f.write('|-----------|---------------|----------|\n')
f.write('\n'.join(table_rows) + '\n')
f.write('\n## Package Versions\n\n')
f.write('| Package | Version |\n')
f.write('|---------|--------|\n')
for pkg, ver in versions.items():
f.write(f'| {pkg} | {ver} |\n')
f.write('\n## How to Reproduce\n\n')
f.write('```bash\n')
f.write('cd praisonai-agents\n')
f.write('python benchmarks/performance_benchmark.py\n')
f.write('```\n')
f.write('\n'.join(lines))
print(f'\nResults saved to: {filepath}')
# Update README.md
readme_path = os.path.join(benchmarks_dir, '..', '..', '..', 'README.md')
if os.path.exists(readme_path):
with open(readme_path, 'r') as f:
content = f.read()
new_table = '''| Framework | Avg Time (μs) | Relative |
|-----------|---------------|----------|
''' + '\n'.join(table_rows)
pattern = r'(\| Framework \| Avg Time \(μs\) \| Relative \|\n\|[-|]+\|\n)(\|[^\n]+\|\n)+'
if re.search(pattern, content):
content = re.sub(pattern, new_table + '\n', content)
with open(readme_path, 'w') as f:
f.write(content)
print(f'README.md updated: {readme_path}')
return filepath
if __name__ == "__main__":
def main():
parser = argparse.ArgumentParser(description='PraisonAI Agents - Comprehensive Performance Benchmark')
parser.add_argument('--save', action='store_true', help='Save results to file')
parser.add_argument('--save', action='store_true', help='Save results to BENCHMARK_RESULTS.md')
parser.add_argument('--iterations', type=int, default=1000, help='Measured iterations (default: 1000)')
parser.add_argument('--warmup', type=int, default=10, help='Warmup runs (default: 10)')
parser.add_argument('--only', nargs='+', choices=list(FRAMEWORKS), help='Benchmark only these frameworks')
args = parser.parse_args()
if args.iterations <= 0 or args.warmup < 0:
parser.error('--iterations must be > 0 and --warmup must be >= 0')
machine = get_machine_info()
print("="*70)
print("PraisonAI Agents - Comprehensive Performance Benchmark")
print("="*70)
@@ -376,156 +339,48 @@ if __name__ == "__main__":
print(" 1. Agent instantiation time (how fast an agent object is created)")
print(" 2. Memory footprint (how much memory an agent uses)")
print("\nMethodology:")
print(" - 10 warmup runs (not measured)")
print(" - 1000 measured iterations")
print(f" - {args.warmup} warmup runs (not measured)")
print(f" - {args.iterations} measured iterations")
print(" - tracemalloc for memory measurement")
print(" - Baseline subtraction for accuracy")
print("\nFrameworks available:")
print(" - PraisonAI: ✅")
print(f" - Agno: ✅")
print(f" - PydanticAI: {'✅' if PYDANTIC_AVAILABLE else '❌'}")
print(f" - OpenAI Agents SDK: {'✅' if OPENAI_AGENTS_AVAILABLE else '❌'}")
print(f" - LangGraph: {'✅' if LANGGRAPH_AVAILABLE else '❌'}")
print(f" - CrewAI: {'✅' if CREWAI_AVAILABLE else '❌'}")
print("\nMachine:")
for k, v in machine.items():
print(f" - {k}: {v}")
results = {}
# Test PraisonAI first
try:
print("\n" + "="*70)
print("Testing PraisonAI Agents")
print("="*70)
results['praisonai'] = run_benchmark(
instantiate_praisonai_agent,
"PraisonAI",
iterations=1000,
warmup=10
)
except Exception as e:
print(f"Error testing PraisonAI: {e}")
try:
print("\n" + "="*70)
print("Testing PraisonAI Agents (LiteLLM)")
print("="*70)
results['praisonai_litellm'] = run_benchmark(
instantiate_praisonai_litellm_agent,
"PraisonAI (LiteLLM)",
iterations=1000,
warmup=10
)
except Exception as e:
print(f"Error testing PraisonAI (LiteLLM): {e}")
# Test Agno
try:
print("\n" + "="*70)
print("Testing Agno")
print("="*70)
results['agno'] = run_benchmark(
instantiate_agno_agent,
"Agno",
iterations=1000,
warmup=10
)
except Exception as e:
print(f"Error testing Agno: {e}")
# Test PydanticAI
if PYDANTIC_AVAILABLE:
for key in args.only or FRAMEWORKS:
display_name, _, builder = FRAMEWORKS[key]
try:
print("\n" + "="*70)
print("Testing PydanticAI")
print("="*70)
results['pydantic'] = run_benchmark(
instantiate_pydantic_agent,
"PydanticAI Agent Instantiation",
iterations=1000,
warmup=10
)
except Exception as e:
print(f"Error testing PydanticAI: {e}")
# Test OpenAI Agents SDK
if OPENAI_AGENTS_AVAILABLE:
create = builder()
except ImportError:
print(f"\nSkipping {display_name}: not installed")
continue
try:
print("\n" + "="*70)
print("Testing OpenAI Agents SDK")
print("="*70)
results['openai_agents'] = run_benchmark(
instantiate_openai_agents,
"OpenAI Agents SDK Instantiation",
iterations=1000,
warmup=10
)
results[key] = run_benchmark(create, display_name, iterations=args.iterations, warmup=args.warmup)
except Exception as e:
print(f"Error testing OpenAI Agents SDK: {e}")
# Test LangGraph
if LANGGRAPH_AVAILABLE:
try:
print("\n" + "="*70)
print("Testing LangGraph")
print("="*70)
results['langgraph'] = run_benchmark(
instantiate_langgraph_agent,
"LangGraph Agent Instantiation",
iterations=1000,
warmup=10
)
except Exception as e:
print(f"Error testing LangGraph: {e}")
# Test CrewAI
if CREWAI_AVAILABLE:
try:
print("\n" + "="*70)
print("Testing CrewAI")
print("="*70)
results['crewai'] = run_benchmark(
instantiate_crewai_agent,
"CrewAI Agent Instantiation",
iterations=1000,
warmup=10
)
except Exception as e:
print(f"Error testing CrewAI: {e}")
print(f"Error testing {display_name}: {e}")
if not results:
print("\nNo framework could be benchmarked.")
return 1
# Final Comparison Table
print("\n" + "="*70)
print("RESULTS SUMMARY")
print("="*70)
if 'praisonai' in results:
baseline = results['praisonai']
print(f"\n{'Framework':<25} {'Avg Time (μs)':<15} {'Avg Memory (KiB)':<18} {'Relative':<15}")
print("-" * 73)
# Sort by time
sorted_results = sorted(results.items(), key=lambda x: x[1].avg_run_time)
for name, result in sorted_results:
time_ratio = result.avg_run_time / baseline.avg_run_time if baseline.avg_run_time > 0 else 0
time_str = f"{time_ratio:.2f}x"
display_name = {
'agno': 'Agno',
'praisonai': 'PraisonAI',
'praisonai_litellm': 'PraisonAI (LiteLLM)',
'pydantic': 'PydanticAI',
'openai_agents': 'OpenAI Agents SDK',
'langgraph': 'LangGraph',
'crewai': 'CrewAI'
}.get(name, name)
print(f"{display_name:<25} {result.avg_run_time*1e6:<15.2f} {result.avg_memory_usage*1024:<18.2f} {time_str:<15}")
print("\n" + "="*70)
# Save results to files
if args.save:
save_benchmark_results(results, baseline)
else:
print('\nResults not saved (use --save flag to save results to file)')
print(f"\n{'Framework':<25} {'Avg Time (μs)':<15} {'Median (μs)':<13} {'Avg Memory (KiB)':<18} {'Relative':<15}")
print("-" * 86)
for key, result, ratio in rank_results(results):
print(f"{FRAMEWORKS[key][0]:<25} {result.avg_run_time*1e6:<15.2f} {result.median_run_time*1e6:<13.2f} "
f"{result.avg_memory_usage*1024:<18.2f} {ratio:.2f}x")
print("\n" + "="*70)
if args.save:
save_benchmark_results(results, machine, args.iterations, args.warmup)
else:
print('\nResults not saved (use --save flag to save results to file)')
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -188,15 +188,17 @@ def get_package_versions():
return versions
def save_results(results: dict, filename: str = 'BENCHMARK_RESULTS.md', *,
def save_results(results: dict, filename: str = 'SIMPLE_BENCHMARK_RESULTS.md', *,
iterations: int = ITERATIONS, log=print):
"""Save benchmark results to a markdown file and update README.
"""Save benchmark results to a markdown file.
Writes ``SIMPLE_BENCHMARK_RESULTS.md`` by default so the curated
``BENCHMARK_RESULTS.md`` (from ``performance_benchmark.py``) is never
overwritten.
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
filepath = os.path.join(os.path.dirname(__file__), filename)
@@ -217,7 +219,6 @@ def save_results(results: dict, filename: str = 'BENCHMARK_RESULTS.md', *,
ratio_str = f'{ratio:,.0f}x' if ratio >= 100 else f'{ratio:.2f}x'
table_rows.append(f'| {name} | {avg_str} | {ratio_str} |')
# Save to 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')
@@ -239,39 +240,9 @@ def save_results(results: dict, filename: str = 'BENCHMARK_RESULTS.md', *,
f.write('```\n')
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, log=log)
return filepath
def update_readme(readme_path: str, table_rows: list, *, log=print):
"""Update the performance section in README.md with latest results."""
import re
with open(readme_path, 'r') as f:
content = f.read()
# Build the new table
new_table = '''| Framework | Avg Time (μs) | Relative |
|-----------|---------------|----------|
''' + '\n'.join(table_rows)
# Pattern to match the performance table
pattern = r'(\| Framework \| Avg Time \(μs\) \| Relative \|\n\|[-|]+\|\n)(\|[^\n]+\|\n)+'
if re.search(pattern, content):
content = re.sub(pattern, new_table + '\n', content)
with open(readme_path, 'w') as f:
f.write(content)
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')
@@ -1,5 +1,5 @@
{
"praisonai_instantiation_us_max": 450,
"praisonai_instantiation_us_max": 150,
"iterations": 100,
"warmup": 10
}
@@ -457,11 +457,16 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
self._wire_deferred_history_callback()
def _ensure_loop_guard(self):
"""Lazy-create loop guard on first tool execution."""
"""Lazy-create loop guard on first tool execution.
Inside a chat turn, returns that turn's own guard (same config) so
concurrent turns on a shared agent do not share per-turn counters."""
if self._loop_guard is None:
from ..escalation.loop_guard import LoopGuard, LoopGuardConfig
self._loop_guard = LoopGuard(LoopGuardConfig(enabled=True))
return self._loop_guard
from .chat_mixin import _current_turn_loop_guard
turn_guard = _current_turn_loop_guard(self)
return turn_guard if turn_guard is not None else self._loop_guard
def _ensure_loop_detector(self):
"""Lazy-create the result-aware tool-loop detector on first tool call.
@@ -6524,7 +6529,8 @@ Summary:"""
# In async context - prefer async hook if available
if hasattr(self._memory_instance, 'aon_memory_write'):
# Schedule async hook without blocking (fire and forget)
asyncio.create_task(self._memory_instance.aon_memory_write(action, memory_type, content, metadata))
from ..utils.async_bridge import spawn_background
spawn_background(self._memory_instance.aon_memory_write(action, memory_type, content, metadata), "aon_memory_write")
elif hasattr(self._memory_instance, 'on_memory_write'):
self._memory_instance.on_memory_write(action, memory_type, content, metadata)
except RuntimeError:
@@ -6,6 +6,7 @@ and response processing. Extracted from agent.py for maintainability.
"""
import asyncio
import contextvars
import os
import re
import time
@@ -24,12 +25,17 @@ logger = logging.getLogger(__name__)
_turns_lock = threading.Lock()
# Per-turn loop guard: ``[agent, LoopGuard | None]`` bound by ``_enter_turn``. Copied
# into tool worker threads with the context, so each turn has its own counters and
# overlapping turns on a shared agent never exhaust each other's limits.
_turn_loop_guard = contextvars.ContextVar("praisonai_turn_loop_guard", default=None)
def _enter_turn(agent, cancel):
"""Mark a ``chat``/``achat`` turn in flight and bind its cancel token to this context."""
with _turns_lock:
agent._turns_in_flight = getattr(agent, "_turns_in_flight", 0) + 1
return _active_turn_cancel.set((agent, cancel))
return _active_turn_cancel.set((agent, cancel)), _turn_loop_guard.set([agent, None])
def _exit_turn(agent, ctx_token):
@@ -38,18 +44,36 @@ def _exit_turn(agent, ctx_token):
return
with _turns_lock:
agent._turns_in_flight = max(getattr(agent, "_turns_in_flight", 1) - 1, 0)
try:
_active_turn_cancel.reset(ctx_token)
except (ValueError, LookupError):
_active_turn_cancel.set(None)
cancel_token, guard_token = ctx_token
for var, token in ((_active_turn_cancel, cancel_token), (_turn_loop_guard, guard_token)):
try:
var.reset(token)
except (ValueError, LookupError):
var.set(None)
def _current_turn_loop_guard(agent):
"""Return this turn's loop guard for ``agent`` (lazily created, sharing the agent
guard's config), or ``None`` when not inside one of ``agent``'s chat turns."""
slot = _turn_loop_guard.get()
if slot is None or slot[0] is not agent:
return None
with _turns_lock:
if slot[1] is None:
from ..escalation.loop_guard import LoopGuard
slot[1] = LoopGuard(agent._loop_guard.config)
return slot[1]
def _reset_loop_guard_for_turn(agent):
"""Reset per-turn loop-guard limits unless another turn on this agent is still
running (the guard is shared; resetting it would restart that turn's limits).
Never force-creates the guard."""
"""Start fresh per-turn loop-guard limits. Inside a chat turn this drops the turn's
own guard; outside one it resets the agent-level guard. Never force-creates a guard."""
slot = _turn_loop_guard.get()
if slot is not None and slot[0] is agent:
slot[1] = None
return
guard = getattr(agent, "_loop_guard", None)
if guard is not None and getattr(agent, "_turns_in_flight", 0) <= 1:
if guard is not None:
guard.reset_turn()
@@ -5291,6 +5315,19 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
def _start_stream(self, prompt: str, **kwargs) -> Generator[str, None, None]:
"""Own the durable context for the complete streaming generator."""
# A stream is a turn too: give it its own loop guard (as chat/achat get
# via _enter_turn) so overlapping streams on a shared agent neither
# exhaust nor inherit each other's per-turn limits.
guard_token = _turn_loop_guard.set([self, None])
try:
yield from self._start_stream_turn(prompt, **kwargs)
finally:
try:
_turn_loop_guard.reset(guard_token)
except (ValueError, LookupError):
_turn_loop_guard.set(None)
def _start_stream_turn(self, prompt: str, **kwargs) -> Generator[str, None, None]:
# Tools go to their sandbox before the first model call, once per
# agent. Placed on chat/achat/_start_stream rather than run/start
# because AgentTeam and AgentFlow call these three directly -- an
@@ -1777,7 +1777,7 @@ Focus on creating clear, structured metadata that will help with intelligent fil
request = urllib.request.Request(api_url, headers=headers)
with urllib.request.urlopen(request) as response:
with urllib.request.urlopen(request, timeout=15) as response:
file_data = json.loads(response.read())
if file_data.get('content') and file_data.get('encoding') == 'base64':
@@ -2350,7 +2350,7 @@ This PRP provides:
import urllib.request
import json
with urllib.request.urlopen(api_url) as response:
with urllib.request.urlopen(api_url, timeout=15) as response:
data = json.loads(response.read())
file_structure = []
@@ -1970,11 +1970,13 @@ Write the complete compiled report:"""
# (a never-raised local class never matches a real exception).
class _CircuitBreakerException(Exception):
pass
_NeutralOutcome = _CircuitBreakerException
try:
from ..tools.circuit_breaker import (
get_circuit_breaker,
CircuitBreakerConfig,
CircuitBreakerException as _CircuitBreakerException,
_NeutralOutcome,
)
breaker_name = f"tool_{id(self)}_{function_name}"
breaker = get_circuit_breaker(
@@ -2002,7 +2004,17 @@ Write the complete compiled report:"""
super().__init__(error_dict.get("error", "Tool execution failed"))
async def _invoke_for_breaker():
r = await _invoke()
# Caller errors (bad input) say nothing about the tool's health:
# recorded as neither failure nor success (sync parity), so they
# can neither open the breaker for other callers nor close a
# half-open one. Unwrapped in _invoke_guarded.
try:
r = await _invoke()
except (ValueError, TypeError, AttributeError) as e:
raise _NeutralOutcome(e)
if isinstance(r, dict) and r.get("error") and \
r.get("_praison_retryable") is False:
raise _NeutralOutcome(r)
if isinstance(r, dict) and r.get("error") and \
not r.get("approval_denied") and \
not r.get("permission_denied") and \
@@ -2026,6 +2038,11 @@ Write the complete compiled report:"""
except _ToolFailure as tf:
# Failure was counted by the breaker; return the original dict.
return tf.error_dict
except _NeutralOutcome as n:
neutral = n.value
if isinstance(neutral, BaseException):
raise neutral
return neutral
def _circuit_open_result():
# Record the rejection as a failure so repeated open-circuit
@@ -70,6 +70,9 @@ class ImageAgent(Agent):
api_version=api_version
)
# Only an explicit llm=/model= names an image model; the base Agent's chat default must not leak into image calls
self._image_llm_set = llm is not None or kwargs.get("model") is not None
# Lazy load litellm
self._litellm = None
# Configure logging based on verbose level
@@ -124,15 +127,21 @@ class ImageAgent(Agent):
"Please install it with: pip install litellm"
)
return self._litellm
def _image_model(self, default: str) -> str:
"""Return the explicit ``llm=`` model name, else an image-capable ``default``."""
if not self._image_llm_set:
return default
model_info = self.llm_model
return model_info.model if hasattr(model_info, 'model') else str(model_info)
def generate_image(self, prompt: str, **kwargs) -> Dict[str, Any]:
"""Generate an image based on the provided prompt."""
# Merge default config with any provided kwargs
config = self.image_config.dict(exclude_none=True)
config.update(kwargs)
# Get the model name robustly from the parent Agent's property
model_info = self.llm_model
model_name = model_info.model if hasattr(model_info, 'model') else str(model_info)
model_name = self._image_model("dall-e-3")
# Use the model name in config
config['model'] = model_name
@@ -154,6 +163,10 @@ class ImageAgent(Agent):
# Gemini provider doesn't support response_format parameter
# Apply this filter if provider is explicitly 'gemini' or as fallback for gemini models
config.pop('response_format', None)
elif custom_llm_provider == "openai" and config.get('response_format') == "url":
# "url" is OpenAI's default for DALL-E and is rejected by models that
# don't accept response_format (e.g. gpt-image), so don't send it
config.pop('response_format', None)
from rich.progress import Progress, SpinnerColumn, TextColumn
with Progress(
SpinnerColumn(),
@@ -260,7 +273,7 @@ class ImageAgent(Agent):
import litellm
litellm.telemetry = False
model_name = self.llm or self.model or "dall-e-2"
model_name = self._image_model("dall-e-2")
config = {
"model": model_name,
@@ -335,7 +348,7 @@ class ImageAgent(Agent):
import litellm
litellm.telemetry = False
model_name = self.llm or self.model or "dall-e-2"
model_name = self._image_model("dall-e-2")
config = {
"model": model_name,
@@ -301,10 +301,9 @@ class RouterAgent(Agent):
Returns:
Task execution result
"""
# Estimate context size in tokens (rough estimate: ~4 chars per token)
# This is a simplified heuristic; actual tokenization varies by model
text_length = len(task_description) + (len(context) if context else 0)
context_size = text_length // 4 # Approximate token count
# Estimate context size in tokens (canonical heuristic, CJK-aware)
from ..context.tokens import estimate_tokens_heuristic
context_size = estimate_tokens_heuristic(task_description + (context or ""))
# Select the best model for this task
selected_model = self._select_model_for_task(
@@ -2174,22 +2174,25 @@ class ToolExecutionMixin:
Returns:
Estimated minimum cost in USD for the call.
"""
prompt_chars = 0
texts = []
if messages:
for _msg in messages:
_content = _msg.get("content") if isinstance(_msg, dict) else None
if isinstance(_content, str):
prompt_chars += len(_content)
texts.append(_content)
elif isinstance(_content, list):
for _part in _content:
if isinstance(_part, dict):
_text = _part.get("text")
if isinstance(_text, str):
prompt_chars += len(_text)
texts.append(_text)
elif _content is not None:
prompt_chars += len(str(_content))
texts.append(str(_content))
prompt_tokens = prompt_chars // 4
from ..context.tokens import estimate_tokens_heuristic
prompt_text = "".join(texts)
prompt_chars = len(prompt_text)
prompt_tokens = estimate_tokens_heuristic(prompt_text)
if max_tokens:
completion_tokens = int(max_tokens)
elif prompt_chars > 0:
@@ -3298,7 +3301,7 @@ class ToolExecutionMixin:
"""
# Import circuit breaker components first (lazy import for performance)
try:
from ..tools.circuit_breaker import get_circuit_breaker, CircuitBreakerConfig, CircuitBreakerException
from ..tools.circuit_breaker import get_circuit_breaker, CircuitBreakerConfig, CircuitBreakerException, _NeutralOutcome
except ImportError:
# Circuit breaker not available - fallback to direct execution
logging.debug("Circuit breaker not available, falling back to direct tool execution")
@@ -3327,6 +3330,13 @@ class ToolExecutionMixin:
# Execute tool through circuit breaker with failure detection wrapper
def _tool_wrapper():
result = self._execute_tool_impl(function_name, arguments)
# Caller errors (bad input: ValueError/TypeError/AttributeError,
# tagged non-retryable) say nothing about the tool's health: record
# them as neither failure nor success, so they can neither open the
# breaker for other callers nor close a half-open one.
if isinstance(result, dict) and result.get("error") and \
result.get("_praison_retryable") is False:
raise _NeutralOutcome(result)
# Convert error dicts to exceptions so circuit breaker can detect failures
# Don't treat approval/permission denials as circuit breaker failures
if isinstance(result, dict) and result.get("error") and \
@@ -3345,6 +3355,8 @@ class ToolExecutionMixin:
try:
return breaker.call(_tool_wrapper)
except _NeutralOutcome as n:
return n.value
except Exception as e:
# Check if this is our sentinel exception
if hasattr(e, 'error_dict'):
@@ -336,17 +336,27 @@ class PermissionAllowlist:
if not perm.session_only
}
}
from ..utils.atomic_io import atomic_write_json
os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True)
with open(filepath, "w") as f:
json.dump(data, f, indent=2)
atomic_write_json(filepath, data, indent=2)
@classmethod
def load(cls, filepath: str) -> "PermissionAllowlist":
allowlist = cls()
if not os.path.exists(filepath):
return allowlist
with open(filepath, "r") as f:
data = json.load(f)
try:
with open(filepath, "r") as f:
data = json.load(f)
except json.JSONDecodeError:
# Fail closed: a corrupt allowlist grants nothing.
from ..utils.atomic_io import quarantine_corrupt_file
try:
dest = quarantine_corrupt_file(filepath)
logger.warning("Corrupt permission allowlist %s moved to %s; starting empty", filepath, dest)
except OSError:
logger.warning("Corrupt permission allowlist %s; ignoring it", filepath)
return allowlist
for tool_name, tool_data in data.get("tools", {}).items():
allowlist.add_tool(tool_name, paths=tool_data.get("allowed_paths", []), session_only=tool_data.get("session_only", False))
return allowlist
@@ -91,6 +91,24 @@ PERMISSION_PRESETS = {
"off": frozenset(),
}
# Optional host hook (Issue #5912): a runner that bounds concurrent work (e.g.
# the gateway's admission gate) binds a zero-arg factory here for the duration
# of an admitted turn. The registry enters ``factory()`` — a sync *and* async
# context manager — around the backend's human wait so a turn parked on a
# person does not occupy a run slot. Unbound (the default) is a no-op.
HUMAN_WAIT_SCOPE: contextvars.ContextVar[Optional[Callable[[], object]]] = contextvars.ContextVar(
"praisonai_approval_human_wait_scope", default=None
)
def _human_wait_scope() -> object:
factory = HUMAN_WAIT_SCOPE.get()
if factory is None:
import contextlib
return contextlib.nullcontext()
return factory()
class ApprovalRegistry:
"""Per-agent approval configuration.
@@ -671,16 +689,17 @@ class ApprovalRegistry:
liveness=liveness,
)
# Prefer sync method if available
if hasattr(backend, "request_approval_sync"):
decision = backend.request_approval_sync(request)
else:
# Use shared utility for consistent async-to-sync bridging
from .utils import run_coroutine_safely
decision = run_coroutine_safely(
backend.request_approval(request),
timeout=self.timeout
)
with _human_wait_scope():
# Prefer sync method if available
if hasattr(backend, "request_approval_sync"):
decision = backend.request_approval_sync(request)
else:
# Use shared utility for consistent async-to-sync bridging
from .utils import run_coroutine_safely
decision = run_coroutine_safely(
backend.request_approval(request),
timeout=self.timeout
)
# Live-authority gate: a resolution that lands after the originating
# turn was stopped/superseded is dropped fail-closed — the tool does not
@@ -746,10 +765,11 @@ class ApprovalRegistry:
)
try:
decision = await asyncio.wait_for(
backend.request_approval(request),
timeout=self.timeout,
)
async with _human_wait_scope():
decision = await asyncio.wait_for(
backend.request_approval(request),
timeout=self.timeout,
)
except asyncio.TimeoutError:
decision = ApprovalDecision(approved=False, reason="Approval timed out")
@@ -12,6 +12,7 @@ from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
from dataclasses import dataclass, field
from concurrent.futures import CancelledError, ThreadPoolExecutor, Future
from concurrent.futures import TimeoutError as FuturesTimeoutError
from praisonaiagents._logging import get_logger
@@ -148,12 +149,18 @@ class BackgroundJobManager:
"""
if self._store is None:
return
try:
self._store.upsert(job)
except Exception as e: # noqa: BLE001 — persistence must never crash worker
logger.debug(
"Persisting job %s failed (non-fatal): %s", job.job_id, e,
)
# Check-and-write under the lock: a finished job whose id was reused
# must not overwrite the newer job's persisted record.
with self._lock:
current = self._jobs.get(job.job_id)
if current is not None and current is not job:
return
try:
self._store.upsert(job)
except Exception as e: # noqa: BLE001 — persistence must never crash worker
logger.debug(
"Persisting job %s failed (non-fatal): %s", job.job_id, e,
)
@property
def active_jobs(self) -> int:
@@ -192,14 +199,21 @@ class BackgroundJobManager:
Returns:
The job ID
Raises:
ValueError: If ``job_id`` belongs to a job that is still pending
or running. Ids of terminal jobs may be reused.
"""
job_id = job_id or str(uuid.uuid4())[:8]
job_id = job_id or uuid.uuid4().hex[:12]
# Opportunistically evict old terminal jobs so long-lived hosts
# (gateways, bots, servers) don't retain every finished result.
now = time.time()
if now - self._last_gc > self._GC_INTERVAL:
self._last_gc = now
with self._lock:
run_gc = now - self._last_gc > self._GC_INTERVAL
if run_gc:
self._last_gc = now
if run_gc:
self.cleanup_completed()
job_info = JobInfo(
@@ -208,8 +222,19 @@ class BackgroundJobManager:
origin=dict(origin) if origin else {},
)
# Result handle owned by the manager (not the executor) so cancel_job
# can wake get_result waiters even while ``func`` is still running.
done: Future = Future()
with self._lock:
existing = self._jobs.get(job_id)
if existing is not None and existing.status in (
JobStatus.PENDING, JobStatus.RUNNING,
):
raise ValueError(f"Job '{job_id}' is already active")
# Publish job and future together so a concurrent cancel_job never
# sees the new job paired with the old future.
self._jobs[job_id] = job_info
self._futures[job_id] = done
# Persist the PENDING record before it starts so a crash between spawn
# and first run still leaves a recoverable trace (reconciled as LOST).
self._persist(job_info)
@@ -224,10 +249,7 @@ class BackgroundJobManager:
a missing hooks package or a raising subscriber never affects the
job's own result.
"""
with self._lock:
info = self._jobs.get(job_id)
if info is None:
return
info = job_info
try:
from praisonaiagents.hooks import fire_hook
@@ -256,10 +278,7 @@ class BackgroundJobManager:
_emit_job_completed()
if on_complete is None:
return
with self._lock:
info = self._jobs.get(job_id)
if info is None:
return
info = job_info
try:
on_complete(info)
# Record successful deliver-back so restart reconciliation does
@@ -273,37 +292,88 @@ class BackgroundJobManager:
job_id, e,
)
def _run_job():
# The worker writes only to its own ``job_info`` (never re-looked-up by
# id) so a later job reusing the id, or eviction, cannot be corrupted.
job = job_info
def _abort(error: BaseException, cancelled: bool = False) -> None:
"""Move a still-active job to a terminal state and resolve ``done``."""
with self._lock:
job = self._jobs[job_id]
if job.status not in (JobStatus.PENDING, JobStatus.RUNNING):
return
job.status = JobStatus.CANCELLED if cancelled else JobStatus.FAILED
job.completed_at = time.time()
if not cancelled:
# Bare SystemExit()/KeyboardInterrupt() stringify to "".
job.error = str(error) or type(error).__name__
if cancelled:
done.cancel()
else:
done.set_exception(error)
self._persist(job)
# Failures must reach deliver-back/hook subscribers like any other
# failure; cancellations stay silent, matching cancel_job().
if not cancelled:
_fire_complete()
def _execute():
with self._lock:
if job.status == JobStatus.CANCELLED: # cancelled while pending
return
job.status = JobStatus.RUNNING
job.started_at = time.time()
self._persist(job)
try:
result = func()
with self._lock:
job = self._jobs[job_id]
job.status = JobStatus.COMPLETED
job.completed_at = time.time()
job.result = result
self._persist(job)
_fire_complete()
return result
except Exception as e:
with self._lock:
job = self._jobs[job_id]
if job.status == JobStatus.CANCELLED:
return
job.status = JobStatus.FAILED
job.completed_at = time.time()
job.error = str(e)
self._persist(job)
try:
_fire_complete()
finally:
done.set_exception(e)
return
with self._lock:
# Cancelled while running: discard the late outcome.
if job.status == JobStatus.CANCELLED:
return
job.status = JobStatus.COMPLETED
job.completed_at = time.time()
job.result = result
self._persist(job)
try:
_fire_complete()
finally:
# Resolve even if on_complete raised a BaseException.
done.set_result(result)
def _run_job():
try:
_execute()
except BaseException as e:
# ``func`` raised a non-Exception (e.g. SystemExit); never
# strand get_result callers.
_abort(e)
raise
future = self._executor.submit(_run_job)
with self._lock:
self._futures[job_id] = future
try:
executor_future = self._executor.submit(_run_job)
except BaseException as e:
# e.g. submit after shutdown(): fail the job so its id is reusable
# and get_result raises instead of blocking forever.
_abort(e)
raise
# A queued work item dropped by the executor never runs _run_job.
executor_future.add_done_callback(
lambda f: _abort(CancelledError(), cancelled=True) if f.cancelled() else None
)
return job_id
def get_status(self, job_id: str) -> JobStatus:
@@ -362,7 +432,10 @@ class BackgroundJobManager:
Raises:
KeyError: If job_id is not found
TimeoutError: If timeout is reached before completion
RuntimeError: If job failed
CancelledError: If the job was cancelled
Exception: If the job failed, the original exception is re-raised
(``RuntimeError`` with the stored error once evicted from
memory)
"""
with self._lock:
future = self._futures.get(job_id)
@@ -379,12 +452,18 @@ class BackgroundJobManager:
try:
return future.result(timeout=timeout)
except TimeoutError:
except FuturesTimeoutError:
# Distinct from builtin TimeoutError on Python 3.10.
raise TimeoutError(f"Job {job_id} did not complete within {timeout} seconds")
def cancel_job(self, job_id: str) -> bool:
"""
Attempt to cancel a job.
A pending job never runs. A running job is marked ``CANCELLED``
immediately and its eventual result is discarded (no ``on_complete``
delivery); Python threads cannot be killed, so the worker thread is
only released once ``func`` returns.
Args:
job_id: The job ID to cancel
@@ -393,19 +472,18 @@ class BackgroundJobManager:
True if cancellation was successful, False otherwise
"""
with self._lock:
if job_id not in self._futures:
job = self._jobs.get(job_id)
future = self._futures.get(job_id)
if future is None or job is None or job.status not in (
JobStatus.PENDING, JobStatus.RUNNING,
):
return False
future = self._futures[job_id]
cancelled = future.cancel()
if cancelled:
self._jobs[job_id].status = JobStatus.CANCELLED
self._jobs[job_id].completed_at = time.time()
job = self._jobs[job_id]
else:
job = None
if job is not None:
self._persist(job)
return cancelled
job.status = JobStatus.CANCELLED
job.completed_at = time.time()
# Wakes any get_result waiter with CancelledError right away.
future.cancel()
self._persist(job)
return True
def reconcile_on_start(
self,
@@ -560,6 +638,14 @@ class BackgroundJobManager:
if job_id in self._futures:
del self._futures[job_id]
removed += 1
# Prune the durable store with the same age policy so it stays bounded.
prune = getattr(self._store, "delete_older_than", None)
if prune is not None:
try:
prune(now - max_age)
except Exception as e: # noqa: BLE001 — pruning is best-effort
logger.debug("Pruning background job store failed (non-fatal): %s", e)
return removed
@@ -160,6 +160,29 @@ class SqliteBackgroundJobStore:
).fetchall()
return [self._row_to_job(r) for r in rows]
def delete_older_than(self, ts: float) -> int:
"""Delete terminal jobs completed before ``ts``; return rows removed.
Completed jobs with an undelivered ``origin`` are kept so restart
reconciliation can still re-deliver them.
"""
with self._lock:
cur = self._conn.execute(
"""
DELETE FROM background_jobs
WHERE completed_at IS NOT NULL AND completed_at < ?
AND status NOT IN (?, ?)
AND NOT (status = ? AND delivered = 0 AND origin IS NOT NULL)
""",
(
ts,
JobStatus.PENDING.value,
JobStatus.RUNNING.value,
JobStatus.COMPLETED.value,
),
)
return cur.rowcount
def close(self) -> None:
"""Close the underlying database connection."""
with self._lock:
@@ -307,6 +307,8 @@ def resolve_ingress_admission(
# forwards an unset policy does not silently start replying to all group
# traffic (a security-relevant regression).
policy = (group_policy or "mention_only").lower()
if policy not in _GROUP_POLICIES:
policy = "mention_only"
if policy == "command_only":
if is_command:
@@ -339,7 +341,7 @@ def resolve_ingress_admission(
gate=GATE_GROUP_POLICY,
)
# respond_all (or any unknown policy): admit everything.
# respond_all: admit everything.
return IngressDecision(
admit=True, reason_code=REASON_ALLOWED, gate=GATE_GROUP_POLICY
)
@@ -50,13 +50,15 @@ Example::
from __future__ import annotations
import asyncio
import contextlib
import re
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Literal, Optional, Union
from .protocols import PlatformCapabilities
from .protocols import HealthResult, PlatformCapabilities, ProbeResult
__all__ = [
"SendErrorKind",
@@ -469,6 +471,12 @@ class BasePlatformAdapter(ABC):
typing heartbeat, edit/delete fallbacks, formatting). Override any default
only when the platform can do better.
The base also satisfies the runtime surface ``Bot``/``BotOS`` drive
(``start`` / ``stop`` / ``send_message`` / ``probe`` / ``health``) with
defaults built on those primitives, so a minimal subclass runs under the
gateway as-is. ``Bot`` builds adapters with ``token=`` / ``agent=`` /
``config=`` keyword arguments, so a subclass ``__init__`` should accept them.
Declare platform features by setting the class attribute
:attr:`capabilities` to a :class:`PlatformCapabilities` instance. The
default delivery path reads capabilities via ``getattr`` so it degrades
@@ -550,20 +558,110 @@ class BasePlatformAdapter(ABC):
``start`` should run its inbound source (poll/listen/socket) and only
return when the channel is stopped cleanly, raising on an unexpected
drop so the supervisor can reconnect with backoff. Concrete adapters
override this with their platform run loop.
with a blocking platform loop override this.
Default: :meth:`connect` (passing ``is_reconnect`` on supervised
restarts), raise if it fails so the supervisor backs off, then hold
until :meth:`stop` — suitable for push/callback transports whose
``connect`` registers their inbound listener.
"""
raise NotImplementedError(
"adapter must implement start() to run its inbound loop"
)
is_reconnect = getattr(self, "_base_started_at", None) is not None
# Created before connecting so a stop() racing connect() is not lost.
stop_event = self._base_stop_event = asyncio.Event()
# Until connect() returns, a stop()'s disconnect may precede the
# connection, so teardown is still owed.
stopped_early = True
try:
if not await self.connect(is_reconnect=is_reconnect):
raise ConnectionError(f"{type(self).__name__}.connect() failed")
stopped_early = stop_event.is_set()
self._base_started_at = time.time()
self._base_connected = True
await stop_event.wait()
finally:
self._base_connected = False
# Cancelled by the supervisor, failed, or stopped mid-connect:
# stop() did not (effectively) disconnect, so do it here.
if stopped_early or not stop_event.is_set():
stop_event.set()
with contextlib.suppress(Exception):
await self.disconnect()
async def stop(self) -> None:
"""Signal the inbound loop to stop and tear down the connection.
Default delegates to :meth:`disconnect`. Adapters with an explicit
stop event override this to unblock their run loop first.
Default releases the default :meth:`start` wait, then delegates to
:meth:`disconnect`. Adapters with their own run loop override this to
unblock it first.
"""
event = getattr(self, "_base_stop_event", None)
if event is not None:
event.set()
await self.disconnect()
def _base_running(self) -> bool:
return getattr(self, "_base_connected", False)
async def send_message(
self,
channel_id: Any,
content: Union[str, Dict[str, Any]],
reply_to: Optional[str] = None,
thread_id: Optional[str] = None,
) -> SendResult:
"""Runtime send entry point (``Bot.send_message``).
Default routes through :meth:`deliver` so formatting, chunking, typing
and retry apply; ``thread_id`` is forwarded in ``metadata``.
"""
return await self.deliver(
channel_id,
content,
reply_to=reply_to,
metadata={"thread_id": thread_id} if thread_id else None,
)
async def probe(self) -> ProbeResult:
"""Check connectivity/credentials without processing messages.
Default: ok while the default :meth:`start` is running; otherwise a
:meth:`connect` / :meth:`disconnect` round-trip.
"""
platform = getattr(self, "platform_name", "") or ""
if self._base_running():
return ProbeResult(ok=True, platform=platform)
t0 = time.monotonic()
error: Optional[str] = None
try:
ok = bool(await self.connect())
if not ok:
error = "connect() returned False"
except Exception as exc:
ok, error = False, str(exc)
finally:
# Also on failure/cancellation (preflight timeout), so a partially
# opened client or listener is released.
with contextlib.suppress(Exception):
await self.disconnect()
return ProbeResult(
ok=ok,
platform=platform,
elapsed_ms=(time.monotonic() - t0) * 1000,
error=error,
)
async def health(self) -> HealthResult:
"""Report health. Default reflects the default run state and uptime."""
running = self._base_running()
started = getattr(self, "_base_started_at", None)
return HealthResult(
ok=running,
platform=getattr(self, "platform_name", "") or "",
is_running=running,
uptime_seconds=(time.time() - started) if running and started else None,
error=None if running else "not running",
)
# ------------------------------------------------------------------ #
# Identity canonicalization seam. #
# #
@@ -8,7 +8,7 @@ import asyncio
from praisonaiagents._logging import get_logger
import threading
from typing import Any, Callable, Dict, List, Optional, Set, Union
from ..utils.async_bridge import run_coroutine_from_any_context
from ..utils.async_bridge import run_coroutine_from_any_context, spawn_background
from dataclasses import dataclass, field
from .event import Event, EventType
@@ -235,11 +235,14 @@ class EventBus:
if sub.is_async:
# Schedule async callback safely
try:
loop = asyncio.get_running_loop()
loop.create_task(sub.callback(event))
asyncio.get_running_loop()
except RuntimeError:
# No running loop, use safe async bridge
run_coroutine_from_any_context(sub.callback(event))
else:
# Keep a strong ref and log failures under this
# subscriber's id, like the sync path below.
spawn_background(sub.callback(event), f"subscriber {sub.id}")
else:
sub.callback(event)
except Exception as e:
@@ -708,12 +708,17 @@ def _config_write_target() -> Path:
def _dump_config_file(path: Path, data: Dict[str, Any]) -> None:
"""Write ``data`` to ``path`` as YAML or TOML based on its suffix."""
from ..utils.atomic_io import atomic_write_text
# Write through a symlinked config so the shared target gets the update,
# and keep an existing file's mode (the atomic temp file is owner-only).
path = path.resolve()
mode = path.stat().st_mode & 0o7777 if path.exists() else None
path.parent.mkdir(parents=True, exist_ok=True)
if path.suffix.lower() in (".yaml", ".yml"):
import yaml
with open(path, "w", encoding="utf-8") as f:
yaml.safe_dump(data, f, sort_keys=False, default_flow_style=False)
text = yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
else:
try:
import tomli_w
@@ -727,8 +732,10 @@ def _dump_config_file(path: Path, data: Dict[str, Any]) -> None:
"Install tomli-w (pip install tomli-w), or convert the config "
"to .praisonai/config.yaml."
) from None
with open(path, "wb") as f:
tomli_w.dump(data, f)
text = tomli_w.dumps(data)
atomic_write_text(str(path), text)
if mode is not None:
os.chmod(path, mode)
def set_plugin_enabled(name: str, enabled: bool) -> Path:
@@ -16,6 +16,7 @@ from typing import Any, Callable, Dict, Optional, Set, Type, Union
import inspect
from .parse_utils import (
canonical_preset_key,
detect_url_scheme,
is_path_like,
make_preset_error,
@@ -345,12 +346,12 @@ def _resolve_string(
if presets:
# Case-insensitive lookup, whitespace-tolerant and treating -/_ as
# interchangeable so the accepted spellings match the closed-set guard
# in validate_preset_string (which uses the same normalization). Without
# this, a value that passed construction-time validation (e.g.
# " sliding_window " or "sliding-window") would be rejected here.
value_norm = value.strip().lower().replace("-", "_")
# in validate_preset_string. Both go through canonical_preset_key, the
# single source of truth for the spelling rule, so validation and
# resolution cannot disagree about what counts as the same preset.
value_norm = canonical_preset_key(value)
for preset_key in presets:
if preset_key.strip().lower().replace("-", "_") == value_norm:
if canonical_preset_key(preset_key) == value_norm:
return _apply_preset(preset_key, presets, config_class)
# Not a valid preset - raise helpful error
@@ -157,8 +157,8 @@ class FileIndex:
"entries": {k: v.to_dict() for k, v in self.entries.items()}
}
with open(self.index_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
from praisonaiagents.utils.atomic_io import atomic_write_json
atomic_write_json(self.index_path, data, indent=2)
return True
except Exception as e:
@@ -659,50 +659,52 @@ class ContextManager:
Returns:
Tuple of (token_count, metrics)
"""
# Heuristic is O(len(text)); hashing the whole text to cache it costs
# far more than recomputing it, so only cache the tiktoken path.
if not (validate or self.config.estimation_mode == EstimationMode.VALIDATED):
return estimate_tokens_heuristic(text), None
# Check cache
cache_key = hashlib.sha256(text.encode()).hexdigest()
if cache_key in self._estimation_cache:
return self._estimation_cache[cache_key], None
heuristic = estimate_tokens_heuristic(text)
metrics = None
if validate or self.config.estimation_mode == EstimationMode.VALIDATED:
try:
from .tokens import estimate_tokens_accurate
accurate = estimate_tokens_accurate(text, self.model)
error_pct = abs(heuristic - accurate) / accurate * 100 if accurate > 0 else 0
metrics = EstimationMetrics(
heuristic_estimate=heuristic,
accurate_estimate=accurate,
error_pct=error_pct,
estimator_used=EstimationMode.VALIDATED,
try:
from .tokens import estimate_tokens_accurate
accurate = estimate_tokens_accurate(text, self.model)
error_pct = abs(heuristic - accurate) / accurate * 100 if accurate > 0 else 0
metrics = EstimationMetrics(
heuristic_estimate=heuristic,
accurate_estimate=accurate,
error_pct=error_pct,
estimator_used=EstimationMode.VALIDATED,
)
self._estimation_metrics = metrics
# Log if mismatch exceeds threshold
if self.config.log_estimation_mismatch and error_pct > self.config.mismatch_threshold_pct:
import logging
get_logger(__name__).warning(
f"Token estimation mismatch: heuristic={heuristic}, accurate={accurate}, error={error_pct:.1f}%"
)
self._estimation_metrics = metrics
# Log if mismatch exceeds threshold
if self.config.log_estimation_mismatch and error_pct > self.config.mismatch_threshold_pct:
import logging
get_logger(__name__).warning(
f"Token estimation mismatch: heuristic={heuristic}, accurate={accurate}, error={error_pct:.1f}%"
)
# Use accurate if validated mode
if self.config.estimation_mode == EstimationMode.ACCURATE:
result = accurate
else:
result = heuristic
except ImportError:
# Use accurate if validated mode
if self.config.estimation_mode == EstimationMode.ACCURATE:
result = accurate
else:
result = heuristic
metrics = EstimationMetrics(
heuristic_estimate=heuristic,
estimator_used=EstimationMode.HEURISTIC,
)
else:
except ImportError:
result = heuristic
metrics = EstimationMetrics(
heuristic_estimate=heuristic,
estimator_used=EstimationMode.HEURISTIC,
)
# Cache result
if len(self._estimation_cache) < self._cache_max_size:
@@ -228,6 +228,10 @@ _LAZY_IMPORTS = {
"StopResult": ("praisonaiagents.gateway.protocols", "StopResult"),
# Schema-validated inbound frame codec (Issue #2831)
"HelloParams": ("praisonaiagents.gateway.protocols", "HelloParams"),
# Device-identity handshake contract (Issue #5998)
"DeviceAuth": ("praisonaiagents.gateway.protocols", "DeviceAuth"),
"DeviceIdentityProtocol": ("praisonaiagents.gateway.protocols", "DeviceIdentityProtocol"),
"device_id_from_public_key": ("praisonaiagents.gateway.protocols", "device_id_from_public_key"),
"HelloResult": ("praisonaiagents.gateway.protocols", "HelloResult"),
"HelloError": ("praisonaiagents.gateway.protocols", "HelloError"),
"ConnectErrorCode": ("praisonaiagents.gateway.protocols", "ConnectErrorCode"),
@@ -586,6 +590,9 @@ __all__ = [
"StopResult",
# Schema-validated inbound frame codec (Issue #2831)
"HelloParams",
"DeviceAuth",
"DeviceIdentityProtocol",
"device_id_from_public_key",
"HelloResult",
"HelloError",
"ConnectErrorCode",
@@ -11,6 +11,8 @@ The ``praisonai`` wrapper provides backward-compatible shims.
from __future__ import annotations
import base64
import hashlib
import math
import time
import uuid
@@ -347,10 +349,88 @@ class SessionObserverProtocol(Protocol):
...
# ---------------------------------------------------------------------------
# Device identity (Issue #5998)
#
# A per-device keypair signs a server-issued challenge nonce so the gateway can
# derive a stable ``device_id`` and approve/revoke devices individually instead
# of sharing one gateway-wide secret. Core only fixes the wire shape, the
# fingerprint and the exact bytes that are signed; signature verification,
# device storage and per-device tokens live in the wrapper gateway.
# ---------------------------------------------------------------------------
def device_id_from_public_key(public_key: str) -> str:
"""Return the stable device id: hex SHA-256 of the raw public-key bytes.
``public_key`` must be standard base64 (RFC 4648, padded, no whitespace) of
the raw key bytes. Hashing the decoded bytes keeps the id independent of
how a client spells the encoding. Raises ``ValueError`` on invalid base64.
"""
return hashlib.sha256(base64.b64decode(public_key, validate=True)).hexdigest()
@dataclass
class DeviceAuth:
"""Device-identity proof carried in a ``hello`` frame.
Attributes:
device_id: Fingerprint of ``public_key`` (see :func:`device_id_from_public_key`).
public_key: Standard base64 (padded) of the device's raw public key.
signature: Standard base64 (padded) signature over :meth:`signing_payload`.
signed_at: Integer Unix seconds at which the device signed the challenge
(an integer so every client language renders identical signed bytes).
nonce: The server-issued challenge nonce being answered.
"""
device_id: str
public_key: str
signature: str
signed_at: int
nonce: str
def signing_payload(self, agent_id: str) -> bytes:
"""Canonical bytes the device signs and the gateway verifies.
Binds the nonce to this device, the signing time and the target agent so
a captured proof cannot be replayed for another device or agent.
"""
return "\n".join((
"praisonai-device-v1",
self.device_id,
self.nonce,
str(int(self.signed_at)),
agent_id,
)).encode("utf-8")
@runtime_checkable
class DeviceIdentityProtocol(Protocol):
"""Seam for per-device challenge/response auth (implemented in the wrapper).
Implementations must approve only on an exact device-key match — never on
network reachability alone — and record how each device was approved.
"""
def issue_challenge(self) -> str:
"""Return a fresh single-use challenge nonce for a connecting client."""
...
def verify(self, device: DeviceAuth, *, agent_id: str) -> Optional[str]:
"""Verify ``device`` against an issued nonce; return its role, or ``None`` if rejected."""
...
def list_devices(self) -> List[Dict[str, Any]]:
"""List known devices (id, role, approval path, last seen)."""
...
def revoke(self, device_id: str) -> bool:
"""Revoke one device; return ``False`` if it was unknown."""
...
@dataclass
class HelloParams:
"""Parameters for initiating a versioned handshake.
Attributes:
agent_id: The agent to connect to
protocol_min: Minimum protocol version the client supports
@@ -358,6 +438,7 @@ class HelloParams:
capabilities: Optional list of capability tokens the client supports
session_id: Optional session to resume
since: Optional cursor for event replay
device: Optional device-identity proof (Issue #5998)
"""
agent_id: str
protocol_min: int
@@ -365,6 +446,7 @@ class HelloParams:
capabilities: List[str] = field(default_factory=list)
session_id: Optional[str] = None
since: Optional[int] = None
device: Optional[DeviceAuth] = None
type: str = field(default="hello", init=False)
@@ -897,6 +979,9 @@ def _decode_hello(data: Dict[str, Any]) -> "HelloParams":
if since is not None:
since = _coerce_int(since, field_name="since")
device_data = data.get("device")
device = _decode_device(device_data) if device_data is not None else None
return HelloParams(
agent_id=agent_id,
protocol_min=client_min,
@@ -904,6 +989,60 @@ def _decode_hello(data: Dict[str, Any]) -> "HelloParams":
capabilities=capabilities,
session_id=_as_opt_str(data.get("session_id")),
since=since,
device=device,
)
def _decode_device(data: Any) -> DeviceAuth:
"""Validate a ``hello.device`` block into :class:`DeviceAuth`.
Shape errors are configuration errors; a ``device_id`` that is not the
fingerprint of ``public_key`` is rejected as unauthorized so a client can
never claim another device's id.
"""
if not isinstance(data, dict):
raise FrameDecodeError(
HelloError(
code=ConnectErrorCode.CONFIGURATION_ERROR,
message="Field 'device' must be an object",
next_step=ConnectRecoveryStep.DO_NOT_RETRY,
)
)
public_key = _require_str(data.get("public_key"), field_name="device.public_key")
device_id = _require_str(data.get("device_id"), field_name="device.device_id")
signed_at = data.get("signed_at")
if isinstance(signed_at, bool) or not isinstance(signed_at, int):
raise FrameDecodeError(
HelloError(
code=ConnectErrorCode.CONFIGURATION_ERROR,
message="Field 'device.signed_at' must be an integer (Unix seconds)",
next_step=ConnectRecoveryStep.DO_NOT_RETRY,
)
)
try:
fingerprint = device_id_from_public_key(public_key)
except ValueError:
raise FrameDecodeError(
HelloError(
code=ConnectErrorCode.CONFIGURATION_ERROR,
message="Field 'device.public_key' must be padded standard base64",
next_step=ConnectRecoveryStep.DO_NOT_RETRY,
)
) from None
if device_id != fingerprint:
raise FrameDecodeError(
HelloError(
code=ConnectErrorCode.AUTH_UNAUTHORIZED,
message="device.device_id does not match the public key fingerprint",
next_step=ConnectRecoveryStep.REAUTHENTICATE,
)
)
return DeviceAuth(
device_id=device_id,
public_key=public_key,
signature=_require_str(data.get("signature"), field_name="device.signature"),
signed_at=signed_at,
nonce=_require_str(data.get("nonce"), field_name="device.nonce"),
)
@@ -417,9 +417,9 @@ class FileTracker:
return
try:
os.makedirs(os.path.dirname(self._state_file), exist_ok=True)
with open(self._state_file, "w", encoding="utf-8") as f:
json.dump(self._tracked, f, indent=2)
os.makedirs(os.path.dirname(self._state_file) or ".", exist_ok=True)
from ..utils.atomic_io import atomic_write_json
atomic_write_json(self._state_file, self._tracked, indent=2)
except (OSError, IOError):
pass
@@ -803,6 +803,9 @@ class Knowledge:
same path as local files. Failures are surfaced to the caller so a
single bad URL in a batch is visible, matching local-file behaviour.
"""
from ..tools.url_safety import is_safe_http_url
if not is_safe_http_url(url):
raise ValueError(f"Refusing to fetch unsafe URL: {url}")
result = self.markdown.convert(url)
content = getattr(result, 'text_content', None)
if not content:
+55 -12
View File
@@ -43,6 +43,14 @@ _TOOL_ARGUMENTS_PARSE_FAILED = object()
# Bounded shrink-and-retry steps after a provider context-overflow rejection.
_CONTEXT_OVERFLOW_SHRINK_STEPS = 3
# Default per-request timeout (seconds) when ``timeout`` is not passed. Without
# it litellm falls back to its 6000s ceiling, so a provider that accepts the
# connection but never answers blocks the turn for ~100 minutes per attempt.
# 600s matches the OpenAI SDK default used by the bare-model-name path and is
# a read-gap bound for streams. Pass ``timeout=None`` (or 0) to opt out.
_DEFAULT_LLM_TIMEOUT_S = 600
_TIMEOUT_UNSET = object()
# ``litellm.success_callback``/``_async_success_callback``/``callbacks`` are
# process-global lists shared by every LLM instance. Guard the read-modify-write
@@ -234,6 +242,19 @@ class TokenUsage:
'audio_output_tokens': self.audio_output_tokens,
}
# error_classifier category -> (text-derived kinds that may refine it, default kind).
_CATEGORY_KINDS = {
"rate_limit": ({"rate_limit", "billing"}, "rate_limit"),
"auth": ({"auth", "auth_permanent"}, "auth"),
"context_limit": ({"context_overflow"}, "context_overflow"),
"invalid_request": (
{"context_overflow", "model_not_found", "auth", "auth_permanent", "billing", "format_error"},
"format_error",
),
"transient": ({"overloaded", "idle_timeout"}, "overloaded"),
"permanent": ({"billing"}, "billing"),
}
class LLM:
"""
Easy to use wrapper for language models. Supports multiple providers like OpenAI,
@@ -493,7 +514,7 @@ Respond with ONLY a valid JSON tool call in this format:
def __init__(
self,
model: str,
timeout: Optional[int] = None,
timeout: Optional[int] = _TIMEOUT_UNSET,
temperature: Optional[float] = None,
top_p: Optional[float] = None,
n: Optional[int] = None,
@@ -535,7 +556,7 @@ Respond with ONLY a valid JSON tool call in this format:
)
self.model = model
self.timeout = timeout
self.timeout = _DEFAULT_LLM_TIMEOUT_S if timeout is _TIMEOUT_UNSET else timeout
self.temperature = temperature
self.top_p = top_p
self.n = n
@@ -886,11 +907,12 @@ Respond with ONLY a valid JSON tool call in this format:
else:
return False
def _parse_retry_delay(self, error_message: str) -> float:
"""Parse retry delay from rate limit error message.
def _parse_retry_delay(self, error_message: Union[Exception, str]) -> float:
"""Parse retry delay from a rate limit error.
Args:
error_message: The error message from the API
error_message: The provider exception (preferred, so the structured
``Retry-After`` response header is honoured) or its message
Returns:
Retry delay in seconds, or default if not found
@@ -902,7 +924,8 @@ Respond with ONLY a valid JSON tool call in this format:
from .error_classifier import extract_retry_after
delay = extract_retry_after(Exception(error_message), cap_seconds=max_delay)
error = error_message if isinstance(error_message, BaseException) else Exception(error_message)
delay = extract_retry_after(error, cap_seconds=max_delay)
return self._retry_delay if delay is None else delay
def _is_rate_limit_error(self, error: Exception) -> bool:
@@ -939,15 +962,35 @@ Respond with ONLY a valid JSON tool call in this format:
def classify_error_kind(self, error: Exception) -> AgentErrorKind:
"""
Classify error into typed AgentErrorKind instead of freeform strings.
Replaces scattered regex checks with systematic classification.
The HTTP status code / exception type (via ``error_classifier``) is
authoritative; message text only refines the kind within that category,
so e.g. a 400 whose body happens to contain "500" is never retried as
an overload.
Args:
error: Exception to classify
Returns:
AgentErrorKind: Typed error classification
"""
text_kind = self._classify_error_kind_from_text(error)
from .error_classifier import _classify_by_type_and_code, _extract_status_code
category = _classify_by_type_and_code(error)
if category is None:
return text_kind
allowed, default = _CATEGORY_KINDS[category.value]
if text_kind in allowed:
return text_kind
# A 404 from a completion endpoint means the model/deployment is missing.
if _extract_status_code(error) == 404 or type(error).__name__ == "NotFoundError":
return "model_not_found"
if default == "overloaded" and "timeout" in type(error).__name__.lower():
return "idle_timeout"
return default
def _classify_error_kind_from_text(self, error: Exception) -> AgentErrorKind:
"""Message-substring fallback used when no status code / type is known."""
error_str = str(error).lower()
error_type = type(error).__name__.lower()
@@ -969,7 +1012,7 @@ Respond with ONLY a valid JSON tool call in this format:
# Billing/quota issues (must be checked before generic 429/rate-limit)
if any(indicator in error_str for indicator in [
"insufficient quota", "quota exceeded", "billing", "credit",
"insufficient quota", "quota exceeded", "billing", "credit balance", "insufficient credit",
"payment required", "subscription required", "subscription expired",
"plan limit"
]):
@@ -1101,7 +1144,7 @@ Respond with ONLY a valid JSON tool call in this format:
# Rate limiting - extract retry delay
if error_kind == "rate_limit":
backoff = self._parse_retry_delay(str(error)) # Returns seconds
backoff = self._parse_retry_delay(error) # Returns seconds
if backoff == 0: # No specific delay found, use exponential backoff
backoff = min(2 ** (attempt - 1), 60) # 1s, 2s, 4s, ... cap at 60s
return FailoverDecision(
+25 -40
View File
@@ -154,10 +154,19 @@ def register_approval_callback(callback_fn):
add_display_callback = register_display_callback
add_approval_callback = register_approval_callback
def _filter_callback_kwargs(callback, kwargs):
"""Filter kwargs to what the callback accepts to maintain backward compatibility."""
import inspect
sig = inspect.signature(callback)
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
return kwargs
return {k: v for k, v in kwargs.items() if k in sig.parameters}
def execute_sync_callback(display_type: str, **kwargs):
"""Execute synchronous callback for a given display type without displaying anything.
This function is used to trigger callbacks even when verbose=False.
Callback errors are logged and suppressed so observers cannot abort the run.
Args:
display_type (str): Type of display event
@@ -167,61 +176,37 @@ def execute_sync_callback(display_type: str, **kwargs):
callback = sync_display_callbacks.get(display_type)
if callback:
import inspect
sig = inspect.signature(callback)
# Filter kwargs to what the callback accepts to maintain backward compatibility
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
# Callback accepts **kwargs, so pass all arguments
supported_kwargs = kwargs
else:
# Only pass arguments that the callback signature supports
supported_kwargs = {k: v for k, v in kwargs.items() if k in sig.parameters}
callback(**supported_kwargs)
try:
callback(**_filter_callback_kwargs(callback, kwargs))
except Exception:
logging.exception("Display callback for %r failed; ignored", display_type)
async def execute_callback(display_type: str, **kwargs):
"""Execute both sync and async callbacks for a given display type.
Callback errors are logged and suppressed so observers cannot abort the run.
Args:
display_type (str): Type of display event
**kwargs: Arguments to pass to the callback functions
"""
import inspect
# Execute synchronous callback if registered
with _callbacks_lock:
sync_callback = sync_display_callbacks.get(display_type)
async_callback = async_display_callbacks.get(display_type)
if sync_callback:
sig = inspect.signature(sync_callback)
# Filter kwargs to what the callback accepts to maintain backward compatibility
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
# Callback accepts **kwargs, so pass all arguments
supported_kwargs = kwargs
else:
# Only pass arguments that the callback signature supports
supported_kwargs = {k: v for k, v in kwargs.items() if k in sig.parameters}
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: sync_callback(**supported_kwargs))
try:
supported_kwargs = _filter_callback_kwargs(sync_callback, kwargs)
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lambda: sync_callback(**supported_kwargs))
except Exception:
logging.exception("Display callback for %r failed; ignored", display_type)
# Execute asynchronous callback if registered
if async_callback:
callback = async_callback
sig = inspect.signature(callback)
# Filter kwargs to what the callback accepts to maintain backward compatibility
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
# Callback accepts **kwargs, so pass all arguments
supported_kwargs = kwargs
else:
# Only pass arguments that the callback signature supports
supported_kwargs = {k: v for k, v in kwargs.items() if k in sig.parameters}
await callback(**supported_kwargs)
try:
await async_callback(**_filter_callback_kwargs(async_callback, kwargs))
except Exception:
logging.exception("Async display callback for %r failed; ignored", display_type)
def _clean_display_content(content: str, max_length: int = 20000) -> str:
"""Helper function to clean and truncate content for display."""
@@ -28,15 +28,35 @@ _LOCALHOST_HOSTS = {"localhost", "127.0.0.1", "::1"}
def _require_https(url: str) -> None:
"""Reject non-HTTPS metadata URLs (loopback HTTP is allowed for testing)."""
"""Reject OAuth URLs that are not HTTPS or that point at internal hosts.
Loopback hosts are allowed (over HTTP too) for local testing. Any other
host must pass :func:`~praisonaiagents.tools.url_safety.is_safe_http_url`,
so private, link-local (cloud metadata) and ambiguous numeric hosts are
refused even over HTTPS.
"""
parsed = urlparse(url)
if parsed.scheme == "https":
if parsed.scheme in ("http", "https") and (parsed.hostname or "") in _LOCALHOST_HOSTS:
return
if parsed.scheme == "http" and (parsed.hostname or "") in _LOCALHOST_HOSTS:
return
raise ValueError(
f"Refusing to fetch OAuth metadata over insecure transport: {url!r}"
)
if parsed.scheme != "https":
raise ValueError(
f"Refusing OAuth request over insecure transport: {url!r}"
)
from ..tools.url_safety import is_safe_http_url
if not is_safe_http_url(url):
raise ValueError(
f"Refusing OAuth request to a private/internal address: {url!r}"
)
def _require_success(resp: Any) -> None:
"""Raise unless ``resp`` is 2xx (credential POSTs don't follow redirects)."""
resp.raise_for_status()
if not 200 <= resp.status_code < 300:
raise ValueError(
f"Unexpected HTTP {resp.status_code} from OAuth endpoint {resp.url!r}"
)
def parse_www_authenticate(header: Optional[str]) -> Dict[str, str]:
@@ -62,30 +82,31 @@ def parse_www_authenticate(header: Optional[str]) -> Dict[str, str]:
return params
def _get_json(url: str, timeout: float = 10.0) -> Dict[str, Any]:
def _get_json(url: str, timeout: float = 10.0, max_redirects: int = 3) -> Dict[str, Any]:
"""Fetch and parse a JSON metadata document over HTTPS.
Redirects are followed but every hop — including the final destination — is
re-validated with :func:`_require_https` so a redirect cannot escape the
HTTPS/loopback guard to an internal or plaintext endpoint (SSRF-safe).
Redirects are followed manually and every hop is validated with
:func:`_require_https` *before* it is requested, so a redirect cannot
reach an internal or plaintext endpoint (SSRF-safe).
"""
_require_https(url)
import requests # lazy import — optional dependency
resp = requests.get(
url,
headers={"Accept": "application/json"},
timeout=timeout,
allow_redirects=True,
)
# Re-validate every redirect hop and the final URL: allow_redirects only
# checks the initial URL, so a 30x to http:// or an internal host would
# otherwise bypass the guard.
for previous in getattr(resp, "history", None) or []:
_require_https(previous.url)
_require_https(resp.url)
resp.raise_for_status()
return resp.json()
current = url
for _ in range(max_redirects + 1):
_require_https(current)
resp = requests.get(
current,
headers={"Accept": "application/json"},
timeout=timeout,
allow_redirects=False,
)
location = resp.headers.get("Location")
if resp.status_code in (301, 302, 303, 307, 308) and location:
current = urljoin(current, location)
continue
resp.raise_for_status()
return resp.json()
raise ValueError(f"Too many redirects fetching OAuth metadata: {url!r}")
def _wellknown_candidates(server_url: str) -> List[str]:
@@ -268,8 +268,11 @@ class MCPOAuthProvider:
data=data,
headers={"Accept": "application/json"},
timeout=timeout,
allow_redirects=False, # never re-send credentials to a redirect target
)
resp.raise_for_status()
from .oauth_discovery import _require_success
_require_success(resp)
return self._normalize_tokens(
resp.json(), token_endpoint=metadata["token_endpoint"]
)
@@ -285,7 +288,7 @@ class MCPOAuthProvider:
return False
try:
from .oauth_discovery import _require_https
from .oauth_discovery import _require_https, _require_success
# Defense in depth: never POST a refresh token / client secret to a
# persisted endpoint that isn't HTTPS (or loopback).
@@ -306,8 +309,9 @@ class MCPOAuthProvider:
data=data,
headers={"Accept": "application/json"},
timeout=10.0,
allow_redirects=False, # never re-send credentials to a redirect target
)
resp.raise_for_status()
_require_success(resp)
new_tokens = self._normalize_tokens(
resp.json(), token_endpoint=token_endpoint
)
@@ -10,26 +10,13 @@ returned for persistence via :meth:`MCPAuthStorage.set_client_info`.
"""
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from praisonaiagents._logging import get_logger
from .oauth_discovery import _require_https, _require_success
logger = get_logger("mcp-oauth-registration")
_LOCALHOST_HOSTS = {"localhost", "127.0.0.1", "::1"}
def _require_https(url: str) -> None:
"""Reject non-HTTPS registration URLs (loopback HTTP allowed for testing)."""
parsed = urlparse(url)
if parsed.scheme == "https":
return
if parsed.scheme == "http" and (parsed.hostname or "") in _LOCALHOST_HOSTS:
return
raise ValueError(
f"Refusing to register client over insecure transport: {url!r}"
)
def register_client(
registration_endpoint: str,
@@ -75,8 +62,9 @@ def register_client(
json=payload,
headers={"Content-Type": "application/json", "Accept": "application/json"},
timeout=timeout,
allow_redirects=False, # never re-send credentials to a redirect target
)
resp.raise_for_status()
_require_success(resp)
client_info = resp.json()
if not client_info.get("client_id"):
@@ -8,6 +8,7 @@ LAZY IMPORT: sqlite3 is only imported when this adapter is instantiated.
"""
import os
import re
import sqlite3
import json
from praisonaiagents._logging import get_logger
@@ -23,6 +24,39 @@ from ..protocols import MemoryProtocol
logger = get_logger(__name__)
_SEARCH_STOP_WORDS = frozenset({
"a", "an", "and", "are", "as", "at", "be", "by", "can", "do", "does", "for",
"from", "has", "have", "how", "i", "in", "is", "it", "me", "my", "of", "on",
"or", "the", "to", "was", "were", "what", "when", "where", "which", "who",
"why", "with", "you", "your",
})
def _execute_search(conn, table, query, limit, user_id=None):
"""Match the whole query or any significant word, best matches first.
A plain ``LIKE '%query%'`` misses natural-language queries such as
"Which mode does the user prefer?", so rows are also matched per word and
ranked by exact-phrase hit, then number of matched words, then recency.
"""
terms = list(dict.fromkeys(
w for w in re.findall(r"\w+", (query or "").lower())
if w not in _SEARCH_STOP_WORDS
))[:32] # keep well under SQLite's bound-parameter limit
patterns = [f"%{query}%"] + [f"%{t}%" for t in terms]
match = " OR ".join(["content LIKE ?"] * len(patterns))
# An empty score would leave ``ORDER BY 0``, which SQLite reads as a column index
score = " + ".join(["(content LIKE ?)"] * len(terms)) + " DESC, " if terms else ""
user_filter = " AND memory_user_id(metadata) = ?" if user_id is not None else ""
params = patterns + ([str(user_id)] if user_id is not None else []) + patterns + [limit]
return conn.execute(
f"SELECT id, content, metadata, timestamp FROM {table} "
f"WHERE ({match}){user_filter} "
f"ORDER BY (content LIKE ?) DESC, {score}timestamp DESC LIMIT ?",
params,
)
def _metadata_user_id(raw):
"""Match the existing Python JSON reader, including non-finite values."""
try:
@@ -206,14 +240,7 @@ class SqliteMemoryAdapter:
"""Search short-term memory."""
with self._write_lock if self.short_db == ":memory:" else nullcontext():
conn = self._get_stm_conn()
user_id = kwargs.get("user_id")
user_filter = " AND memory_user_id(metadata) = ?" if user_id is not None else ""
params = (f"%{query}%", str(user_id), limit) if user_id is not None else (f"%{query}%", limit)
cursor = conn.execute(
"SELECT id, content, metadata, timestamp FROM short_term_memory "
"WHERE content LIKE ?" + user_filter + " ORDER BY timestamp DESC LIMIT ?",
params
)
cursor = _execute_search(conn, "short_term_memory", query, limit, kwargs.get("user_id"))
results = []
for row in cursor.fetchall():
@@ -250,14 +277,7 @@ class SqliteMemoryAdapter:
"""Search long-term memory."""
with self._write_lock if self.long_db == ":memory:" else nullcontext():
conn = self._get_ltm_conn()
user_id = kwargs.get("user_id")
user_filter = " AND memory_user_id(metadata) = ?" if user_id is not None else ""
params = (f"%{query}%", str(user_id), limit) if user_id is not None else (f"%{query}%", limit)
cursor = conn.execute(
"SELECT id, content, metadata, timestamp FROM long_term_memory "
"WHERE content LIKE ?" + user_filter + " ORDER BY timestamp DESC LIMIT ?",
params
)
cursor = _execute_search(conn, "long_term_memory", query, limit, kwargs.get("user_id"))
results = []
for row in cursor.fetchall():
@@ -35,6 +35,7 @@ import os
import logging
from praisonaiagents._logging import get_logger
from pathlib import Path
from ..utils.safe_path import safe_child
from typing import Any, Dict, List, Optional, Literal
from dataclasses import dataclass, field
@@ -366,7 +367,7 @@ class DocsManager:
docs_dir = self.workspace_path / self.DOCS_DIR_NAME.replace("/", os.sep)
docs_dir.mkdir(parents=True, exist_ok=True)
file_path = docs_dir / f"{name}.md"
file_path = safe_child(docs_dir, name, ".md")
# Build frontmatter
import yaml
@@ -23,6 +23,7 @@ import sys
import hashlib
import logging
from praisonaiagents._logging import get_logger
from praisonaiagents.utils.safe_path import safe_child
import threading
import weakref
from collections import Counter
@@ -142,6 +143,15 @@ class FileMemory:
raise ValueError("user_id must not contain path separators or parent references")
safe = user_id.strip()
return safe or "default"
def _episodic_file(self, date: str) -> Path:
"""Map a YYYY-MM-DD date to its episodic file, rejecting anything else."""
try:
# Normalise so "2026-3-5" lands in the same file recent reads look for
date = datetime.strptime(date, "%Y-%m-%d").strftime("%Y-%m-%d")
except (TypeError, ValueError):
raise ValueError(f"date must be YYYY-MM-DD, got {date!r}") from None
return safe_child(self.episodic_path, date, ".json")
def __init__(
self,
@@ -753,10 +763,7 @@ class FileMemory:
if date is None:
date = datetime.now().strftime("%Y-%m-%d")
episodic_file = self.episodic_path / f"{date}.json"
# Load existing episodic memories for this date
episodes = self._read_json(episodic_file, [])
episodic_file = self._episodic_file(date)
# Create new item
item = MemoryItem(
@@ -765,8 +772,11 @@ class FileMemory:
metadata=metadata or {}
)
episodes.append(item.to_dict())
self._write_json(episodic_file, episodes)
# Read-modify-write under the store lock so concurrent adds are not lost
with self._lock:
episodes = self._read_json(episodic_file, [])
episodes.append(item.to_dict())
self._write_json(episodic_file, episodes)
self._log(f"Added episodic memory for {date}: {content[:50]}...")
@@ -788,7 +798,7 @@ class FileMemory:
List of episodic memories
"""
if date:
episodic_file = self.episodic_path / f"{date}.json"
episodic_file = self._episodic_file(date)
data = self._read_json(episodic_file, [])
return [MemoryItem.from_dict(item) for item in data]
@@ -813,7 +823,8 @@ class FileMemory:
try:
file_date = datetime.strptime(episodic_file.stem, "%Y-%m-%d")
if file_date < cutoff_date:
episodic_file.unlink()
with self._lock:
episodic_file.unlink(missing_ok=True)
removed += 1
except ValueError:
continue
@@ -1193,16 +1204,17 @@ class FileMemory:
for i in range(30)
]
for check_date in dates_to_check:
episodic_file = self.episodic_path / f"{check_date}.json"
if episodic_file.exists():
data = self._read_json(episodic_file, [])
for i, item in enumerate(data):
if item.get("id") == memory_id:
del data[i]
self._write_json(episodic_file, data)
self._log(f"Deleted episodic memory: {memory_id}")
return True
with self._lock:
for check_date in dates_to_check:
episodic_file = self._episodic_file(check_date)
if episodic_file.exists():
data = self._read_json(episodic_file, [])
for i, item in enumerate(data):
if item.get("id") == memory_id:
del data[i]
self._write_json(episodic_file, data)
self._log(f"Deleted episodic memory: {memory_id}")
return True
return False
@@ -1440,7 +1452,7 @@ class FileMemory:
sessions_path = self.user_path / "sessions"
sessions_path.mkdir(parents=True, exist_ok=True)
session_file = sessions_path / f"{name}.json"
session_file = safe_child(sessions_path, name, ".json")
# Take snapshots under lock for consistent session data
with self._lock:
@@ -1479,7 +1491,7 @@ class FileMemory:
Session data including conversation history
"""
sessions_path = self.user_path / "sessions"
session_file = sessions_path / f"{name}.json"
session_file = safe_child(sessions_path, name, ".json")
if not session_file.exists():
raise FileNotFoundError(f"Session '{name}' not found")
@@ -1531,7 +1543,7 @@ class FileMemory:
def delete_session(self, name: str) -> bool:
"""Delete a saved session."""
sessions_path = self.user_path / "sessions"
session_file = sessions_path / f"{name}.json"
session_file = safe_child(sessions_path, name, ".json")
if session_file.exists():
session_file.unlink()
@@ -1687,7 +1699,7 @@ Summary:"""
from uuid import uuid4
checkpoint_id = name or f"checkpoint_{int(time.time())}_{uuid4().hex}"
checkpoint_file = checkpoints_path / f"{checkpoint_id}.json"
checkpoint_file = safe_child(checkpoints_path, checkpoint_id, ".json")
checkpoint_data = {
"id": checkpoint_id,
@@ -1724,7 +1736,7 @@ Summary:"""
True if restored successfully
"""
checkpoints_path = self.user_path / "checkpoints"
checkpoint_file = checkpoints_path / f"{checkpoint_id}.json"
checkpoint_file = safe_child(checkpoints_path, checkpoint_id, ".json")
if not checkpoint_file.exists():
self._log(f"Checkpoint '{checkpoint_id}' not found", logging.ERROR)
@@ -1775,7 +1787,7 @@ Summary:"""
def delete_checkpoint(self, checkpoint_id: str) -> bool:
"""Delete a checkpoint."""
checkpoints_path = self.user_path / "checkpoints"
checkpoint_file = checkpoints_path / f"{checkpoint_id}.json"
checkpoint_file = safe_child(checkpoints_path, checkpoint_id, ".json")
if checkpoint_file.exists():
checkpoint_file.unlink()
@@ -35,6 +35,7 @@ import json
import logging
from praisonaiagents._logging import get_logger
from pathlib import Path
from ..utils.safe_path import safe_child
from typing import Any, Dict, List, Optional
from dataclasses import dataclass, field
@@ -272,7 +273,7 @@ class MCPConfigManager:
mcp_dir = self.workspace_path / self.MCP_DIR_NAME.replace("/", os.sep)
mcp_dir.mkdir(parents=True, exist_ok=True)
file_path = mcp_dir / f"{name}.json"
file_path = safe_child(mcp_dir, name, ".json")
# Build config
config = MCPConfig(
@@ -44,6 +44,7 @@ import logging
from praisonaiagents._logging import get_logger
import subprocess
from pathlib import Path
from ..utils.safe_path import safe_child
from typing import Any, Dict, List, Optional, Literal, Callable
from dataclasses import dataclass, field
@@ -515,9 +516,13 @@ class RulesManager:
dir_prefix = rules_dir_name.split("/")[0].strip(".")
self._rules[f"gitroot_{dir_prefix}:{rule.name}"] = rule
# 6. Load subdirectory rules (walk up from cwd to workspace/git root)
stop_at = git_root if git_root else self.workspace_path
current = Path.cwd()
# 6. Load subdirectory rules (walk up from cwd to workspace/git root).
# Skipped when cwd is outside that tree, so ancestors such as the
# user's home directory are never scanned.
stop_at = (git_root if git_root else self.workspace_path).resolve()
current = Path.cwd().resolve()
if not current.is_relative_to(stop_at):
current = stop_at
while current != stop_at and current != current.parent:
# Check .praisonai/rules/
subdir_rules_dir = current / self.RULES_DIR_NAME.replace("/", os.sep)
@@ -808,7 +813,7 @@ class RulesManager:
rules_dir = self.workspace_path / self.RULES_DIR_NAME.replace("/", os.sep)
rules_dir.mkdir(parents=True, exist_ok=True)
file_path = rules_dir / f"{name}.md"
file_path = safe_child(rules_dir, name, ".md")
# Build frontmatter
import yaml
@@ -231,7 +231,14 @@ def load_plugin(filepath: str) -> Optional[Dict[str, Any]]:
except PluginParseError as e:
logger.error(f"Invalid plugin header: {e}")
return None
# Refuse to exec a plugin built for an incompatible core.
from .plugin import core_incompatibility
reason = core_incompatibility(metadata.get("dependencies", []))
if reason:
logger.warning(f"Plugin '{metadata.get('name')}' disabled: {reason}")
return None
# Generate unique module name to avoid conflicts
module_name = f"praison_plugin_{path.stem}_{id(path)}"
@@ -13,7 +13,7 @@ import threading
from pathlib import Path
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
from .plugin import Plugin, PluginHook, PluginInfo, FunctionPlugin
from .plugin import Plugin, PluginHook, PluginInfo, FunctionPlugin, core_incompatibility
if TYPE_CHECKING:
from ..hooks.registry import HookRegistry
@@ -143,6 +143,9 @@ class PluginManager:
def __init__(self, disabled: Optional[bool] = None):
self._plugins: Dict[str, Plugin] = {}
self._enabled: Dict[str, bool] = {}
# Names whose on_init is running and that have not been explicitly
# enabled/disabled yet; register() applies the default only to these.
self._initializing: set = set()
self._single_file_plugins: Dict[str, Dict[str, Any]] = {} # WordPress-style plugins
# Per-plugin option maps sourced from the unified project config,
# delivered to each plugin's on_config hook. {plugin_name: options}.
@@ -262,11 +265,10 @@ class PluginManager:
if self._enabled.get(name, False) and name in self._plugin_options
]
for name, plugin, options in targets:
on_config = getattr(plugin, "on_config", None)
if not callable(on_config):
if not callable(getattr(plugin, "on_config", None)):
continue
try:
on_config(options)
_ResolvingPlugin(plugin).on_config(options)
delivered += 1
except Exception as e:
logger.warning(f"Plugin '{name}' on_config failed: {e}")
@@ -288,26 +290,54 @@ class PluginManager:
if info.name in self._plugins:
logger.warning(f"Plugin '{info.name}' already registered")
return False
self._plugins[info.name] = plugin
self._enabled[info.name] = self._default_enabled_for_registration(info.name)
# Initialize plugin
plugin.on_init({})
# Make the initialization observable to the hook system
# too. ``plugin.on_init`` is the plugin's *own* callback;
# ON_INIT is the runtime event other listeners
# (metrics/audit plugins, gateways) subscribe to. Without
# this emit the enum member was declared but never fired.
_emit_plugin_init(plugin)
logger.info(f"Registered plugin: {info.name} v{info.version}")
return True
reason = core_incompatibility(info.dependencies)
if reason:
logger.warning(f"Plugin '{info.name}' disabled: {reason}")
return False
# Reserve the name but keep it out of dispatch until on_init succeeds.
self._plugins[info.name] = plugin
self._enabled[info.name] = False
self._initializing.add(info.name)
default_enabled = self._default_enabled_for_registration(info.name)
except Exception as e:
logger.error(f"Failed to register plugin: {e}")
return False
# Initialize outside the lock: an ``async def on_init`` may run on
# another thread and call back into this manager.
try:
_ResolvingPlugin(plugin).on_init({})
with self._lock:
if self._plugins.get(info.name) is not plugin:
logger.warning(f"Plugin '{info.name}' was unregistered during on_init")
return False
# Publish; an explicit enable()/disable() made during on_init is kept.
if info.name in self._initializing:
self._initializing.discard(info.name)
self._enabled[info.name] = default_enabled
# Make the initialization observable to the hook system
# too. ``plugin.on_init`` is the plugin's *own* callback;
# ON_INIT is the runtime event other listeners
# (metrics/audit plugins, gateways) subscribe to. Without
# this emit the enum member was declared but never fired.
_emit_plugin_init(plugin)
logger.info(f"Registered plugin: {info.name} v{info.version}")
return True
except Exception as e:
logger.error(f"Failed to register plugin: {e}")
# Roll back so a failed plugin is not left half-registered.
with self._lock:
if self._plugins.get(info.name) is plugin:
del self._plugins[info.name]
self._enabled.pop(info.name, None)
self._initializing.discard(info.name)
return False
def unregister(self, name: str) -> bool:
"""
Unregister a plugin.
@@ -324,9 +354,10 @@ class PluginManager:
return False
del self._plugins[name]
self._enabled.pop(name, None)
self._initializing.discard(name)
try:
plugin.on_shutdown()
_ResolvingPlugin(plugin).on_shutdown()
except Exception as e:
logger.warning(f"Error during plugin shutdown: {e}")
@@ -342,6 +373,7 @@ class PluginManager:
with self._lock:
if name in self._enabled:
self._enabled[name] = True
self._initializing.discard(name)
return True
return False
@@ -356,6 +388,7 @@ class PluginManager:
if name not in self._enabled:
return False
self._enabled[name] = False
self._initializing.discard(name)
try:
self.unwire_from_hook_registry(name)
except Exception as e:
@@ -537,7 +570,8 @@ class PluginManager:
continue
try:
method = getattr(plugin, hook.value, None)
# Resolving view: an ``async def`` hook is awaited, not dropped.
method = getattr(_ResolvingPlugin(plugin), hook.value, None)
if method:
if hook == PluginHook.BEFORE_TOOL:
result = method(args[0], args[1] if len(args) > 1 else {})
@@ -839,21 +873,20 @@ class PluginManager:
return 0
loaded = 0
# Use lock to ensure thread-safe discovery of multiple entry points.
# register() also acquires the lock but that's fine (RLock allows re-entry).
with self._lock:
for ep in entry_points:
try:
plugin_cls = ep.load()
if callable(plugin_cls):
plugin = plugin_cls()
if self.register(plugin):
loaded += 1
logger.info(f"Loaded entry_point plugin: {ep.name}")
else:
logger.warning(f"Entry point {ep.name} is not callable")
except Exception as e:
logger.error(f"Failed to load plugin from entry point {ep.name}: {e}")
# register() is thread-safe on its own; holding the lock here would
# keep it held while a plugin's (possibly async) on_init runs.
for ep in entry_points:
try:
plugin_cls = ep.load()
if callable(plugin_cls):
plugin = plugin_cls()
if self.register(plugin):
loaded += 1
logger.info(f"Loaded entry_point plugin: {ep.name}")
else:
logger.warning(f"Entry point {ep.name} is not callable")
except Exception as e:
logger.error(f"Failed to load plugin from entry point {ep.name}: {e}")
return loaded
@@ -1008,6 +1041,44 @@ def _emit_plugin_lifecycle(event: "HookEvent", plugin: Plugin) -> None:
logger.debug("Plugin lifecycle hook failed", exc_info=True)
# Deadline for an ``async def`` plugin hook; matches HookRunner's default
# per-hook timeout so a hung hook is cancelled and frees its worker thread.
_ASYNC_HOOK_TIMEOUT = 60.0
def _resolve_awaitable(value: Any) -> Any:
"""Drive a coroutine returned by an ``async def`` plugin hook to completion.
Hook adapters run on the HookRunner's worker thread (no running loop), so
the coroutine runs on a private loop there; from a thread with a running
loop the shared bridge offloads it instead of deadlocking. The coroutine is
bounded by ``_ASYNC_HOOK_TIMEOUT``. Sync hook return values pass through
unchanged.
"""
if asyncio.iscoroutine(value):
from ..utils.async_bridge import run_coroutine_from_any_context
return run_coroutine_from_any_context(value, timeout=_ASYNC_HOOK_TIMEOUT)
return value
class _ResolvingPlugin:
"""View of a plugin whose method calls resolve ``async def`` hooks."""
__slots__ = ("_plugin",)
def __init__(self, plugin: Plugin):
self._plugin = plugin
def __getattr__(self, name: str) -> Any:
attr = getattr(self._plugin, name)
if not callable(attr):
return attr
def call(*args, **kwargs):
return _resolve_awaitable(attr(*args, **kwargs))
return call
def _write_back(data, attr: str, new_value: dict) -> None:
"""Write a plugin's returned dict back onto the hook payload.
@@ -1044,6 +1115,9 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
from .plugin import GuardrailBlocked, PluginDecision
base = Plugin
# Adapters call the plugin through this view so ``async def`` hooks are
# awaited instead of returning an unawaited coroutine.
resolving_plugin = _ResolvingPlugin(plugin)
def _as_decision(result: Any) -> Optional[HookResult]:
"""Coerce a plugin's return value into a denying ``HookResult``.
@@ -1081,7 +1155,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
return HookResult.allow()
if _overrides("before_agent"):
def before_agent_hook(data, _p=plugin):
def before_agent_hook(data, _p=resolving_plugin):
try:
new_prompt = _p.before_agent(getattr(data, "prompt", ""),
{"agent_name": getattr(data, "agent_name", None)})
@@ -1096,7 +1170,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
yield HookEvent.BEFORE_AGENT, before_agent_hook
if _overrides("after_agent"):
def after_agent_hook(data, _p=plugin):
def after_agent_hook(data, _p=resolving_plugin):
new_resp = _p.after_agent(getattr(data, "response", ""),
{"agent_name": getattr(data, "agent_name", None)})
if isinstance(new_resp, str) and hasattr(data, "response"):
@@ -1105,7 +1179,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
yield HookEvent.AFTER_AGENT, after_agent_hook
if _overrides("before_llm"):
def before_llm_hook(data, _p=plugin):
def before_llm_hook(data, _p=resolving_plugin):
messages = getattr(data, "messages", [])
try:
result = _p.before_llm(messages, {"model": getattr(data, "model", "")})
@@ -1121,7 +1195,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
yield HookEvent.BEFORE_LLM, before_llm_hook
if _overrides("after_llm"):
def after_llm_hook(data, _p=plugin):
def after_llm_hook(data, _p=resolving_plugin):
new_resp = _p.after_llm(getattr(data, "response", ""),
{"tokens_used": getattr(data, "tokens_used", 0)})
if isinstance(new_resp, str) and hasattr(data, "response"):
@@ -1130,7 +1204,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
yield HookEvent.AFTER_LLM, after_llm_hook
if _overrides("before_tool"):
def before_tool_hook(data, _p=plugin):
def before_tool_hook(data, _p=resolving_plugin):
args = getattr(data, "tool_input", {}) or {}
try:
new_args = _p.before_tool(getattr(data, "tool_name", ""), args)
@@ -1146,7 +1220,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
yield HookEvent.BEFORE_TOOL, before_tool_hook
if _overrides("after_tool"):
def after_tool_hook(data, _p=plugin):
def after_tool_hook(data, _p=resolving_plugin):
try:
new_result = _p.after_tool(getattr(data, "tool_name", ""),
getattr(data, "tool_output", None))
@@ -1161,7 +1235,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
yield HookEvent.AFTER_TOOL, after_tool_hook
if _overrides("before_tool_definitions"):
def before_tool_definitions_hook(data, _p=plugin):
def before_tool_definitions_hook(data, _p=resolving_plugin):
defs = getattr(data, "tool_definitions", []) or []
try:
new_defs = _p.before_tool_definitions(defs)
@@ -1193,7 +1267,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
return payload
if _overrides("before_message"):
def before_message_hook(data, _p=plugin):
def before_message_hook(data, _p=resolving_plugin):
try:
new = _p.before_message(_message_payload(data))
except GuardrailBlocked as e:
@@ -1207,7 +1281,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
yield HookEvent.MESSAGE_RECEIVED, before_message_hook
if _overrides("after_message"):
def after_message_hook(data, _p=plugin):
def after_message_hook(data, _p=resolving_plugin):
try:
new = _p.after_message(_message_payload(data))
except GuardrailBlocked as e:
@@ -1224,14 +1298,14 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
yield HookEvent.MESSAGE_SENDING, after_message_hook
if _overrides("message_sent"):
def message_sent_hook(data, _p=plugin):
def message_sent_hook(data, _p=resolving_plugin):
payload = _message_payload(data)
_p.message_sent(payload)
return HookResult.allow()
yield HookEvent.MESSAGE_SENT, message_sent_hook
if _overrides("message_undelivered"):
def message_undelivered_hook(data, _p=plugin):
def message_undelivered_hook(data, _p=resolving_plugin):
payload = _message_payload(data)
payload["error"] = getattr(data, "error", "")
payload["notice_delivered"] = getattr(data, "notice_delivered", False)
@@ -1240,14 +1314,14 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
yield HookEvent.MESSAGE_UNDELIVERED, message_undelivered_hook
if _overrides("on_permission_ask"):
def on_permission_ask_hook(data, _p=plugin):
def on_permission_ask_hook(data, _p=resolving_plugin):
target = getattr(data, "tool_name", "") or getattr(data, "target", "")
reason = getattr(data, "reason", "") or "Permission requested"
return _permission_result(_p.on_permission_ask(target, reason), reason)
yield HookEvent.ON_PERMISSION_ASK, on_permission_ask_hook
if _overrides("on_config"):
def on_config_hook(data, _p=plugin):
def on_config_hook(data, _p=resolving_plugin):
config = getattr(data, "config", None)
if not isinstance(config, dict):
config = getattr(data, "extra", {}) or {}
@@ -1258,7 +1332,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
yield HookEvent.ON_CONFIG, on_config_hook
if _overrides("on_auth"):
def on_auth_hook(data, _p=plugin):
def on_auth_hook(data, _p=resolving_plugin):
auth_type = getattr(data, "auth_type", "") or ""
credentials = getattr(data, "credentials", None) or {}
new_creds = _p.on_auth(auth_type, credentials)
@@ -1268,7 +1342,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
yield HookEvent.ON_AUTH, on_auth_hook
if _overrides("session_start"):
def session_start_hook(data, _p=plugin):
def session_start_hook(data, _p=resolving_plugin):
_p.session_start({
"source": getattr(data, "source", None),
"session_name": getattr(data, "session_name", None),
@@ -1278,7 +1352,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
yield HookEvent.SESSION_START, session_start_hook
if _overrides("session_end"):
def session_end_hook(data, _p=plugin):
def session_end_hook(data, _p=resolving_plugin):
_p.session_end({
"reason": getattr(data, "reason", None),
"total_turns": getattr(data, "total_turns", None),
@@ -1289,7 +1363,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
yield HookEvent.SESSION_END, session_end_hook
if _overrides("on_error"):
def on_error_hook(data, _p=plugin):
def on_error_hook(data, _p=resolving_plugin):
_p.on_error(
getattr(data, "error_type", "") or "",
getattr(data, "error_message", "") or "",
@@ -1303,7 +1377,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
yield HookEvent.ON_ERROR, on_error_hook
if _overrides("cli_backend_execute"):
def cli_backend_execute_hook(data, _p=plugin):
def cli_backend_execute_hook(data, _p=resolving_plugin):
from ..cli_backend.debug import redact_command
_p.cli_backend_execute({
"agent_name": getattr(data, "agent_name", None),
@@ -1361,7 +1435,7 @@ def _adapt_plugin_hooks(plugin: Plugin) -> Iterator[Tuple["HookEvent", Callable]
# ``kanban_task_created``) are treated as genuine overrides.
if parent is not None and own is parent:
continue
yield event, _make_observe_hook(method)
yield event, _make_observe_hook(getattr(resolving_plugin, event.value))
# Global plugin manager instance
@@ -111,14 +111,75 @@ class PluginInfo:
"plugin_type": self.plugin_type.value,
}
def core_incompatibility(dependencies: List[str]) -> Optional[str]:
"""Check a plugin's ``dependencies`` against the running core.
Only a ``praisonaiagents`` entry (e.g. ``"praisonaiagents>=0.9,<2"``) is
checked; other entries are ignored. Returns an actionable reason when the
installed core does not satisfy it, else ``None`` (permissive: no entry,
an unparseable entry, or ``packaging`` unavailable all mean compatible).
"""
if not dependencies:
return None
try:
from packaging.requirements import InvalidRequirement, Requirement
from packaging.utils import canonicalize_name
from packaging.version import InvalidVersion, Version
from .._version import __version__ as core_version
except ImportError:
return None
if core_version == "unknown":
# Wheel installs ship no pyproject.toml; read the installed metadata.
try:
from importlib.metadata import version
core_version = version("praisonaiagents")
except Exception:
return None
# Comma-separated manifest headers split "pkg>=1,<2" into ["pkg>=1", "<2"];
# rejoin bare specifier fragments onto the preceding requirement.
merged: List[str] = []
for dep in dependencies:
dep = str(dep).strip()
if merged and dep[:1] in "<>=!~":
merged[-1] = f"{merged[-1]},{dep}"
elif dep:
merged.append(dep)
for dep in merged:
try:
req = Requirement(dep)
except InvalidRequirement:
continue
if canonicalize_name(req.name) != "praisonaiagents" or not req.specifier:
continue
try:
if req.marker and not req.marker.evaluate({"extra": ""}):
continue # e.g. "; sys_platform == 'win32'" on another OS
except Exception:
continue
try:
ok = req.specifier.contains(Version(core_version), prereleases=True)
except InvalidVersion:
return None
if not ok:
return (
f"requires praisonaiagents{req.specifier}, installed {core_version}. "
"Update the plugin or praisonaiagents."
)
return None
class Plugin(ABC):
"""
Base class for PraisonAI plugins.
Plugins can extend agent functionality by implementing
hook methods that are called at various points in the
agent lifecycle.
agent lifecycle. Hook methods may be ``def`` or ``async def``;
coroutines returned by async hooks are awaited by the dispatcher.
Example:
class MyPlugin(Plugin):
@property
@@ -115,11 +115,8 @@ class PolicyConfig:
rules_dir = os.path.dirname(self.rules_file)
os.makedirs(rules_dir, exist_ok=True)
# Atomic write: tmp file + rename
tmp_file = self.rules_file + ".tmp"
with open(tmp_file, "w") as f:
json.dump({"rules": existing}, f, indent=2)
os.replace(tmp_file, self.rules_file)
from ..utils.atomic_io import atomic_write_json
atomic_write_json(self.rules_file, {"rules": existing}, indent=2)
logger.debug(f"Saved learned rule: {resource} -> {action}")
return True
@@ -11,6 +11,7 @@ from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
from ..context.budgeter import MODEL_LIMITS as _CANONICAL_LIMITS
from ..context.tokens import estimate_tokens_heuristic
# RAG-specific context windows not present in the canonical budgeter table
@@ -285,11 +286,28 @@ def estimate_tokens(text: str) -> int:
Returns:
Estimated token count
"""
if not text:
return 0
# Average of 4 characters per token for English
# This is a conservative estimate
return len(text) // 4 + 1
# Delegate to the canonical estimator so dense (CJK/non-ASCII) text is not
# under-counted ~5x and over-filled past the budget.
return estimate_tokens_heuristic(text)
def _truncate_to_tokens(text: str, max_tokens: int, estimator=None) -> str:
"""Return the longest prefix of ``text`` whose estimate fits ``max_tokens``.
Binary-searches on the (monotonic) estimator instead of assuming
~4 chars/token, which overshoots the budget for dense (CJK) text.
"""
estimator = estimator or estimate_tokens
if max_tokens <= 0 or not text:
return ""
lo, hi = 0, len(text)
while lo < hi:
mid = (lo + hi + 1) // 2
if estimator(text[:mid]) <= max_tokens:
lo = mid
else:
hi = mid - 1
return text[:lo]
class DefaultBudgetEnforcer:
@@ -340,9 +358,12 @@ class DefaultBudgetEnforcer:
# Try to fit partial chunk if there's remaining space
remaining = available - used_tokens
if remaining > 100: # Only if meaningful space remains
# Truncate text to fit
chars_to_keep = remaining * 4 # Approximate
truncated_text = text[:chars_to_keep]
# Truncate text to fit, leaving room for the "..." suffix
truncated_text = _truncate_to_tokens(
text,
remaining - self._token_estimator("..."),
self._token_estimator,
)
if truncated_text:
truncated_chunk = {
**chunk,
@@ -12,7 +12,7 @@ No heavy imports at module level.
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
from .budget import estimate_tokens
from .budget import _truncate_to_tokens, estimate_tokens
@runtime_checkable
@@ -219,8 +219,9 @@ class ContextCompressor:
# Partial fit
remaining = target_tokens - used_tokens
if remaining > 50: # Only if meaningful space
chars_to_keep = remaining * 4
truncated_text = text[:chars_to_keep] + "..."
truncated_text = _truncate_to_tokens(
text, remaining - estimate_tokens("...")
) + "..."
result.append({
**chunk,
'text': truncated_text,
@@ -8,7 +8,7 @@ No heavy imports - only stdlib.
import hashlib
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union
from .budget import estimate_tokens as _budget_estimate_tokens
from .budget import _truncate_to_tokens, estimate_tokens as _budget_estimate_tokens
if TYPE_CHECKING:
from ..knowledge.models import SearchResultItem
@@ -156,10 +156,10 @@ def truncate_context(
# Calculate target character count
suffix_tokens = _estimate_tokens(suffix)
target_tokens = max_tokens - suffix_tokens
target_chars = target_tokens * 4
truncated = _truncate_to_tokens(text, target_tokens, _estimate_tokens)
target_chars = len(truncated)
# Truncate at word boundary
truncated = text[:target_chars]
last_space = truncated.rfind(" ")
if last_space > target_chars * 0.8:
truncated = truncated[:last_space]
@@ -119,10 +119,15 @@ class ScheduleRunner:
job_name=job.name,
)
if job.should_retire(job.last_run_at):
self._store.remove(job.id)
else:
self._store.update(job)
# The run already happened (its claim was persisted), so a failed write
# here is reported, not raised: one bad write must not stop the loop.
try:
if job.should_retire(job.last_run_at):
self._store.remove(job.id)
else:
self._store.update(job)
except OSError as e:
logger.error("Failed to persist run state for job %r: %s", job.id, e)
# ── job-to-job context chaining ───────────────────────────────────
@@ -16,6 +16,7 @@ from typing import Dict, Iterator, List, Optional
from .models import ScheduleJob, RunRecord
from .due import is_due as _is_due, is_misfire as _is_misfire
from .hook_emit import emit_schedule_add, emit_schedule_remove
from ..utils.atomic_io import atomic_write_json
logger = get_logger(__name__)
@@ -41,6 +42,8 @@ class FileScheduleStore:
self._lock = threading.RLock()
self._jobs: Dict[str, ScheduleJob] = {}
self._history: List[RunRecord] = []
# Run records whose history write failed; merged into the next save.
self._unsaved: List[RunRecord] = []
# In-memory record of leases we currently hold, so ``complete`` can
# release them. Cross-process leases live in the persisted job
# (``lease_until`` / ``lease_owner``) which is the source of truth.
@@ -60,7 +63,7 @@ class FileScheduleStore:
if job.id in self._jobs:
raise ValueError(f"Job '{job.id}' already exists")
self._jobs[job.id] = job
self._save()
self._save_or_rollback(job.id)
# Emit SCHEDULE_ADD outside the store locks: a hook must never be able
# to deadlock the cross-process file lock by calling back in.
emit_schedule_add(job)
@@ -120,7 +123,7 @@ class FileScheduleStore:
job._lease_until = getattr(existing, "_lease_until", 0.0) or 0.0
job._lease_owner = getattr(existing, "_lease_owner", None)
self._jobs[job.id] = job
self._save()
self._save_or_rollback(job.id)
def remove(self, job_id: str) -> bool:
removed = None
@@ -128,7 +131,7 @@ class FileScheduleStore:
self._reload_locked()
if job_id in self._jobs:
removed = self._jobs.pop(job_id)
self._save()
self._save_or_rollback(job_id)
if removed is None:
return False
emit_schedule_remove(removed)
@@ -150,7 +153,7 @@ class FileScheduleStore:
if principal is not None and job.principal != principal:
continue
removed = self._jobs.pop(jid)
self._save()
self._save_or_rollback(jid)
break
if removed is None:
return False
@@ -255,7 +258,7 @@ class FileScheduleStore:
emit_schedule_remove(job)
# Record suppressed occurrences so a missed run is visible in history
# rather than silently dropped. Logged outside the file lock (log_run
# takes only the thread lock) to keep the critical section short.
# takes it itself; flock is not re-entrant across file handles).
for job_id, job_name in missed:
self.log_run(
job_id=job_id,
@@ -301,12 +304,26 @@ class FileScheduleStore:
delivered=delivered,
timestamp=time.time(),
)
with self._lock:
self._history.insert(0, record)
# Prune to max_history
if len(self._history) > self._max_history:
self._history = self._history[:self._max_history]
self._save_history()
with self._lock, self._file_lock():
# Reload under the cross-process lock so concurrent writers (e.g.
# the scheduler daemon and a CLI process) don't drop each other's
# records (last-writer-wins). Records whose earlier save failed are
# carried forward so they are retried, not discarded.
pending = ([record] + self._unsaved)[:self._max_history]
on_disk = self._read_history()
if on_disk is None:
# Unreadable history: never overwrite saved records with a
# partial view. Keep the new record in memory for a retry.
self._history = ([record] + self._history)[:self._max_history]
self._unsaved = pending
return
# Retried records may be older than ones other processes wrote
# since; sort so history stays newest-first before trimming.
merged = sorted(
pending + on_disk, key=lambda r: r.timestamp, reverse=True
)
self._history = merged[:self._max_history]
self._unsaved = [] if self._save_history() else pending
def get_history(
self,
@@ -392,6 +409,17 @@ class FileScheduleStore:
except Exception as e:
logger.warning("Failed to load schedule store from %s: %s", self._path, e)
def _save_or_rollback(self, job_id: str) -> None:
"""Persist jobs or restore on-disk state and raise (file lock held).
Keeps memory consistent with disk so a failed write never reports
success (or fires lifecycle hooks) for a change that will vanish on
restart.
"""
if not self._save():
self._reload_locked()
raise OSError(f"Failed to persist schedule job '{job_id}'")
def _save(self) -> bool:
"""Persist jobs to disk.
@@ -401,10 +429,7 @@ class FileScheduleStore:
try:
os.makedirs(self._dir, exist_ok=True)
data = [j.to_dict() for j in self._jobs.values()]
tmp = self._path + ".tmp"
with open(tmp, "w") as f:
json.dump(data, f, indent=2)
os.replace(tmp, self._path)
atomic_write_json(self._path, data, indent=2)
return True
except Exception as e:
logger.warning("Failed to save schedule store to %s: %s", self._path, e)
@@ -412,25 +437,27 @@ class FileScheduleStore:
def _load_history(self) -> None:
"""Load execution history from history.json."""
self._history = self._read_history() or []
def _read_history(self) -> Optional[List[RunRecord]]:
"""Read history.json; ``[]`` if absent, ``None`` if unreadable."""
if not os.path.exists(self._history_path):
return
return []
try:
with open(self._history_path, "r") as f:
data = json.load(f)
for d in data:
record = RunRecord.from_dict(d)
self._history.append(record)
return [RunRecord.from_dict(d) for d in data]
except Exception as e:
logger.warning("Failed to load history from %s: %s", self._history_path, e)
return None
def _save_history(self) -> None:
"""Save execution history to history.json."""
def _save_history(self) -> bool:
"""Save execution history to history.json; ``True`` on success."""
try:
os.makedirs(self._dir, exist_ok=True)
data = [r.to_dict() for r in self._history]
tmp = self._history_path + ".tmp"
with open(tmp, "w") as f:
json.dump(data, f, indent=2)
os.replace(tmp, self._history_path)
atomic_write_json(self._history_path, data, indent=2)
return True
except Exception as e:
logger.warning("Failed to save history to %s: %s", self._history_path, e)
return False
@@ -23,6 +23,8 @@ logger = logging.getLogger(__name__)
CACHE_SUBDIR = "remote-skills"
_CURRENT_LINK = "current"
# Deadline for git calls so a stalled remote falls back to the cached copy.
_GIT_TIMEOUT = 120
def get_remote_cache_root() -> Path:
@@ -89,7 +91,10 @@ class GitRemoteSkillSource:
if self.ref:
cmd += ["--branch", self.ref]
cmd += [self.url, str(clone_path)]
proc = subprocess.run(cmd, capture_output=True, text=True)
proc = subprocess.run(
cmd, capture_output=True, text=True, timeout=_GIT_TIMEOUT,
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
)
if proc.returncode != 0:
logger.warning(
"Remote skill sync failed for %s: %s; using cached copy.",
@@ -123,7 +128,7 @@ class GitRemoteSkillSource:
def _resolve_version(self, clone_path: Path) -> str:
proc = subprocess.run(
["git", "-C", str(clone_path), "rev-parse", "HEAD"],
capture_output=True, text=True,
capture_output=True, text=True, timeout=_GIT_TIMEOUT,
)
if proc.returncode == 0 and proc.stdout.strip():
return proc.stdout.strip()[:16]
@@ -120,6 +120,17 @@ class CircuitBreakerException(Exception):
super().__init__(f"Circuit breaker open for service '{service_name}', state: {state.value}")
class _NeutralOutcome(Exception):
"""Raised by a guarded callable whose outcome says nothing about the service's
health (e.g. a caller's bad input): recorded as neither success nor failure,
so it can neither open the circuit nor close a half-open one. Carries the
real outcome in ``value`` for the caller to unwrap."""
def __init__(self, value):
super().__init__("neutral outcome")
self.value = value
class CircuitBreaker:
"""Lightweight circuit breaker implementation.
@@ -226,6 +237,8 @@ class CircuitBreaker:
result = func(*args, **kwargs)
self._on_success()
return result
except _NeutralOutcome:
raise
except Exception as e:
self._on_failure()
raise
@@ -269,6 +282,8 @@ class CircuitBreaker:
result = func(*args, **kwargs)
self._on_success()
return result
except _NeutralOutcome:
raise
except Exception as e:
self._on_failure()
raise
@@ -331,8 +331,15 @@ class OpenAPIOperation:
# Returned, not raised, for the same reason as the transport errors
# below: a raising tool ends the turn.
return f"{self.name} failed: {exc}"
from .url_safety import is_safe_http_url
if not is_safe_http_url(request["url"]):
# The host comes from an untrusted spec's `servers:` entry; keep it
# off loopback/private/metadata addresses (ALLOW_LOCAL_CRAWL=true
# opts out for local development).
return (f"{self.name} failed: refusing unsafe URL "
f"{request['url']!r}")
try:
with httpx.Client(timeout=self.timeout) as client:
with httpx.Client(timeout=self.timeout, follow_redirects=False) as client:
response = client.request(**request)
response.raise_for_status()
try:
@@ -391,7 +398,11 @@ class OpenAPIToolset:
text = spec_str
else:
import httpx
with httpx.Client(timeout=30) as client:
from .url_safety import is_safe_http_url
if not is_safe_http_url(spec_url):
raise ValueError(
f"OpenAPIToolset refuses unsafe spec_url {spec_url!r}")
with httpx.Client(timeout=30, follow_redirects=False) as client:
response = client.get(spec_url)
response.raise_for_status()
text = response.text
@@ -7,6 +7,7 @@ This module provides a safe interface for executing shell commands with:
- Resource limits
"""
import re
import subprocess
import shlex
import logging
@@ -89,6 +90,48 @@ def _find_shell_syntax(command: str):
return None
# Credential-looking variable names are withheld from model-driven commands:
# a gateway serving untrusted chat would otherwise hand `env` / `echo $X` every
# LLM key, channel token and its own GATEWAY_AUTH_TOKEN. Operators who need one
# inside a command name it in PRAISONAI_SHELL_ENV_ALLOWLIST (comma-separated).
_SECRET_NAME_PARTS = frozenset({"TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", "CREDENTIALS", "APIKEY"})
_ENV_REF = re.compile(r"\$(\w+|\{[^}]*\})")
_ENV_REF_NT = re.compile(r"\$(\w+|\{[^}]*\})|%(\w+)%")
def _is_secret_name(name: str) -> bool:
upper = name.upper()
return upper.endswith("_KEY") or any(part in _SECRET_NAME_PARTS for part in upper.split("_"))
def _build_subprocess_env(extra_env: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""Parent environment minus secret-looking names, plus explicit *extra_env*."""
allow = {n.strip() for n in os.environ.get("PRAISONAI_SHELL_ENV_ALLOWLIST", "").split(",") if n.strip()}
process_env = {k: v for k, v in os.environ.items() if k in allow or not _is_secret_name(k)}
if extra_env:
process_env.update(extra_env)
return process_env
def _expandvars(value: str, env: Dict[str, str]) -> str:
"""``os.path.expandvars`` against *env* rather than ``os.environ``.
On Windows ``%VAR%`` is expanded too and names are case-insensitive,
matching ``ntpath.expandvars``.
"""
windows = platform.system() == 'Windows'
if windows:
env = {k.upper(): v for k, v in env.items()}
def _sub(match):
name = (match.group(1) or match.group(2)).strip("{}")
return env.get(name.upper() if windows else name, match.group(0))
if windows:
return _ENV_REF_NT.sub(_sub, value) if ("$" in value or "%" in value) else value
return _ENV_REF.sub(_sub, value) if "$" in value else value
class ShellTools:
"""Tools for executing shell commands safely."""
@@ -187,24 +230,23 @@ class ShellTools:
if not command:
return {"error": "Empty command", "stdout": "", "stderr": "", "exit_code": 1}
# Set up process environment (secrets stripped, explicit env kept)
process_env = _build_subprocess_env(env)
# Expand tilde and environment variables in command arguments
# (shell=False means the shell won't do this for us)
command = [os.path.expanduser(os.path.expandvars(arg)) for arg in command]
# (shell=False means the shell won't do this for us). Expansion
# uses the scrubbed env so `echo $OPENAI_API_KEY` cannot leak it.
command = [os.path.expanduser(_expandvars(arg, process_env)) for arg in command]
# Expand tilde in cwd (subprocess doesn't do this)
if cwd:
cwd = os.path.expanduser(cwd)
cwd = os.path.expandvars(cwd) # Also expand $HOME, $USER, etc.
cwd = _expandvars(cwd, process_env) # Also expand $HOME, $USER, etc.
if not os.path.isdir(cwd):
# Fallback: try home directory, then current working directory
fallback = os.path.expanduser("~") if os.path.isdir(os.path.expanduser("~")) else os.getcwd()
logging.warning(f"Working directory '{cwd}' does not exist, using '{fallback}'")
cwd = fallback
# Set up process environment
process_env = os.environ.copy()
if env:
process_env.update(env)
if run_in_background:
return self._start_background(command, cwd, process_env)
@@ -256,43 +298,38 @@ class ShellTools:
result['stderr_artifact'] = stderr_path
return result
except _CommandCancelled:
except _CommandCancelled as e:
# A cooperative interrupt (/stop, Ctrl-C) fired while the child
# was still running. Kill the whole process group so descendants
# do not keep mutating state, then report an interrupted outcome.
self._kill_process_tree(process)
return {
'stdout': '',
'stderr': 'Command interrupted by user',
'exit_code': -1,
'success': False,
'interrupted': True,
'execution_time': time.time() - start_time
}
result = self._partial_result(
e, 'Command interrupted by user', max_output_size, spill, spill_dir
)
result.update(interrupted=True, execution_time=time.time() - start_time)
return result
except _StreamDrainTimeout:
except _StreamDrainTimeout as e:
# The direct child already exited; only an inherited pipe kept a
# reader blocked. Report a timeout but do NOT kill — the direct
# child's PID is gone (and may have been recycled), so killing it
# would be unsafe and misleading.
return {
'stdout': '',
'stderr': f'Command output streaming timed out after {timeout} seconds',
'exit_code': -1,
'success': False,
'execution_time': timeout
}
result = self._partial_result(
e, f'Command output streaming timed out after {timeout} seconds',
max_output_size, spill, spill_dir,
)
result.update(timed_out=True, execution_time=timeout)
return result
except subprocess.TimeoutExpired:
except subprocess.TimeoutExpired as e:
# Kill process on timeout
self._kill_process_tree(process)
return {
'stdout': '',
'stderr': f'Command timed out after {timeout} seconds',
'exit_code': -1,
'success': False,
'execution_time': timeout
}
result = self._partial_result(
e, f'Command timed out after {timeout} seconds',
max_output_size, spill, spill_dir,
)
result.update(timed_out=True, execution_time=timeout)
return result
except Exception as e:
# Name the exception type and log the traceback. Some exceptions
@@ -314,6 +351,28 @@ class ShellTools:
'execution_time': 0
}
def _partial_result(self, exc, reason, max_output_size, spill, spill_dir):
"""Build a failed result that keeps output captured before ``exc``."""
collect = getattr(exc, 'collect_output', None)
stdout, stderr = collect() if collect else ('', '')
stdout, stdout_path = self._handle_overflow(
stdout, "stdout", max_output_size, spill, spill_dir
)
stderr, stderr_path = self._handle_overflow(
stderr, "stderr", max_output_size, spill, spill_dir
)
result: Dict[str, Union[str, int, bool]] = {
'stdout': stdout,
'stderr': f"{stderr.rstrip()}\n{reason}" if stderr.strip() else reason,
'exit_code': -1,
'success': False,
}
if stdout_path:
result['stdout_artifact'] = stdout_path
if stderr_path:
result['stderr_artifact'] = stderr_path
return result
def _start_background(self, command, cwd, process_env):
"""Start *command* detached, writing stdout+stderr to a log file."""
import tempfile
@@ -560,18 +619,31 @@ class ShellTools:
t_out.start()
t_err.start()
def _with_partial(exc, drain=True):
# Keep whatever the command already printed: on a timeout/interrupt
# the real error usually lives in the output produced so far. The
# caller collects only AFTER killing the child, so the pipes close
# and readline() returns trailing text written without a newline.
def _collect():
if drain:
for reader in (t_out, t_err):
reader.join(timeout=_DRAIN_GRACE_SECONDS)
return "".join(stdout_chunks), "".join(stderr_chunks)
exc.collect_output = _collect
return exc
deadline = None if timeout is None else time.monotonic() + timeout
if cancel_event is None:
try:
process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
except subprocess.TimeoutExpired as exc:
# The direct child is still running. Disable any further emission
# and re-raise TimeoutExpired so the caller's timeout path KILLS
# the process before returning — a recorded read error must not
# short-circuit that cleanup (otherwise the still-running child
# leaks with its pipes open).
stop_emitting.set()
raise
raise _with_partial(exc)
else:
# Poll in short slices so a cooperative interrupt aborts the child
# promptly (within the poll interval) rather than only on timeout.
@@ -579,12 +651,12 @@ class ShellTools:
while True:
if cancel_event.is_set():
stop_emitting.set()
raise _CommandCancelled()
raise _with_partial(_CommandCancelled())
if deadline is not None:
remaining = deadline - time.monotonic()
if remaining <= 0:
stop_emitting.set()
raise subprocess.TimeoutExpired(process.args, timeout)
raise _with_partial(subprocess.TimeoutExpired(process.args, timeout))
wait_slice = min(_POLL, remaining)
else:
wait_slice = _POLL
@@ -616,7 +688,7 @@ class ShellTools:
# Stop emitting and signal a drain-timeout so the caller reports a
# timeout WITHOUT trying to kill an already-exited process.
stop_emitting.set()
raise _StreamDrainTimeout()
raise _with_partial(_StreamDrainTimeout(), drain=False)
if read_errors:
raise read_errors[0]
@@ -395,6 +395,24 @@ def _html_to_markdown(html: str, base_url: Optional[str] = None) -> tuple:
return markdown, unescape(parser.title).strip()
# Raw bytes read per page. Only 50,000 characters are returned, so a few MB is
# ample and keeps memory (and HTML parsing) bounded for oversized responses.
_MAX_CRAWL_BYTES = 5 * 1024 * 1024
def _read_capped_httpx(response) -> str:
"""Read at most ``_MAX_CRAWL_BYTES`` from a streamed httpx response."""
buf = bytearray()
for chunk in response.iter_bytes(chunk_size=64 * 1024):
buf += chunk
if len(buf) >= _MAX_CRAWL_BYTES:
break
try:
return bytes(buf[:_MAX_CRAWL_BYTES]).decode(response.charset_encoding or "utf-8", errors="ignore")
except LookupError:
return bytes(buf[:_MAX_CRAWL_BYTES]).decode("utf-8", errors="ignore")
def _crawl_with_httpx(urls: List[str]) -> List[Dict[str, Any]]:
"""Crawl URLs using basic HTTP fetch with httpx or urllib."""
import urllib.parse
@@ -409,31 +427,49 @@ def _crawl_with_httpx(urls: List[str]) -> List[Dict[str, Any]]:
try:
import httpx
current = url
response = None
with httpx.Client(follow_redirects=False, timeout=30.0) as client:
for _ in range(max_redirects + 1):
if not _is_safe_crawl_url(current):
raise ValueError("Redirect target failed SSRF validation")
response = client.get(current)
if response.status_code not in redirect_statuses:
break
location = response.headers.get("Location")
if not location:
break
# Stream so the body is never buffered past the byte cap.
with client.stream("GET", current) as response:
location = response.headers.get("Location")
if response.status_code not in redirect_statuses or not location:
response.raise_for_status()
content = _read_capped_httpx(response)
base_url = current
break
current = urllib.parse.urljoin(current, location)
else:
raise ValueError("Too many redirects")
if response is not None:
response.raise_for_status()
content = response.text
base_url = current
except ImportError:
# Fallback to urllib
# Fallback to urllib, walking redirects by hand so every hop
# is SSRF-validated before it is requested (as above).
import urllib.error
import urllib.request
if not _is_safe_crawl_url(url):
raise ValueError("URL blocked by SSRF policy")
with urllib.request.urlopen(url, timeout=30) as response:
content = response.read().decode('utf-8', errors='ignore')
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None # surface 30x as HTTPError
opener = urllib.request.build_opener(_NoRedirect)
current = url
for _ in range(max_redirects + 1):
if not _is_safe_crawl_url(current):
raise ValueError("Redirect target failed SSRF validation")
try:
with opener.open(current, timeout=30) as response:
content = response.read(_MAX_CRAWL_BYTES).decode('utf-8', errors='ignore')
break
except urllib.error.HTTPError as e:
location = e.headers.get("Location")
if e.code not in redirect_statuses or not location:
raise
e.close() # release the redirect response's socket
current = urllib.parse.urljoin(current, location)
else:
raise ValueError("Too many redirects")
base_url = current
# Convert HTML to structured Markdown using the standard library so
# headings, lists, links, and code blocks survive on this path.
@@ -6,11 +6,11 @@ Exposes PraisonAI Agents via the A2A (Agent2Agent) protocol.
import asyncio
from praisonaiagents._logging import get_logger
from typing import List, Optional, TYPE_CHECKING
from typing import Dict, List, Optional, TYPE_CHECKING
from praisonaiagents.ui.a2a.types import AgentCard
from praisonaiagents.ui.a2a.agent_card import generate_agent_card
from praisonaiagents.ui.a2a.task_store import TaskStore
from praisonaiagents.ui.a2a.task_store import TaskStore, TERMINAL_STATES
if TYPE_CHECKING:
from praisonaiagents import Agent, AgentTeam
@@ -98,6 +98,8 @@ class A2A:
# Initialize task store
self.task_store = TaskStore()
# Strong refs to background tasks so they aren't GC'd and can be cancelled
self._running: Dict[str, asyncio.Task] = {}
# Router cache
self._router: Optional["APIRouter"] = None
@@ -290,12 +292,17 @@ class A2A:
self.task_store.add_to_history(task_id, response_msg)
self.task_store.add_artifact(task_id, artifact)
self.task_store.update_status(task_id, TaskState.COMPLETED)
except asyncio.CancelledError:
self.task_store.update_status(task_id, TaskState.CANCELLED)
raise
except Exception as e:
logger.error(f"Background agent error: {e}")
self.task_store.update_status(task_id, TaskState.FAILED)
user_input = extract_user_input([message])
asyncio.create_task(_background_process(task.id, user_input))
bg = asyncio.create_task(_background_process(task.id, user_input))
self._running[task.id] = bg
bg.add_done_callback(lambda _t, tid=task.id: self._running.pop(tid, None))
return JSONResponse(content={
"jsonrpc": "2.0",
@@ -305,6 +312,9 @@ class A2A:
# Update to working
self.task_store.update_status(task.id, TaskState.WORKING)
# Keep the task while we wait on the agent: a cancel can make it
# terminal (and evictable) before we build the response.
self.task_store._pinned.add(task.id)
try:
# Extract user input text
@@ -333,6 +343,8 @@ class A2A:
except Exception as e:
logger.error(f"Agent error in message/send: {e}")
updated_task = self.task_store.update_status(task.id, TaskState.FAILED)
finally:
self.task_store._pinned.discard(task.id)
# Return JSON-RPC response
return JSONResponse(content={
@@ -410,9 +422,19 @@ class A2A:
if not task_id:
return _jsonrpc_error(request_id, -32602, "Invalid params: 'id' required")
task = self.task_store.cancel_task(task_id)
if not task:
existing = self.task_store.get_task(task_id)
if not existing:
return _jsonrpc_error(request_id, -32000, f"Task not found: {task_id}")
if existing.status.state in TERMINAL_STATES:
return _jsonrpc_error(
request_id, -32002,
f"Task not cancelable: already {existing.status.state.value}",
)
task = self.task_store.cancel_task(task_id)
running = self._running.get(task_id)
if running:
running.cancel()
return JSONResponse(content={
"jsonrpc": "2.0",
@@ -6,7 +6,7 @@ In-memory storage for A2A Task lifecycle management.
import uuid
from datetime import datetime, timezone
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Set
from praisonaiagents.ui.a2a.types import (
Task,
@@ -16,6 +16,12 @@ from praisonaiagents.ui.a2a.types import (
Artifact,
)
# Terminal states are final: once reached, status can no longer change.
TERMINAL_STATES = frozenset({TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED})
# Upper bound on stored tasks; oldest terminal tasks are evicted beyond this.
_MAX_TASKS = 10_000
class TaskStore:
"""
@@ -28,6 +34,8 @@ class TaskStore:
def __init__(self):
"""Initialize empty task store."""
self._tasks: Dict[str, Task] = {}
# Ids of tasks a request is still waiting on; never evicted.
self._pinned: Set[str] = set()
def create_task(self, message: Message, context_id: Optional[str] = None) -> Task:
"""
@@ -59,8 +67,22 @@ class TaskStore:
)
self._tasks[task_id] = task
self._evict()
return task
def _evict(self, keep: Optional[str] = None):
"""Drop the oldest terminal tasks (except *keep*) once over the cap."""
excess = len(self._tasks) - _MAX_TASKS
if excess <= 0:
return
old = [
t.id for t in self._tasks.values()
if t.status.state in TERMINAL_STATES
and t.id != keep and t.id not in self._pinned
]
for tid in old[:excess]:
del self._tasks[tid]
def get_task(self, task_id: str) -> Optional[Task]:
"""
Retrieve a task by its ID.
@@ -88,11 +110,14 @@ class TaskStore:
message: Optional status message
Returns:
Updated Task if found, None otherwise
Updated Task if found, None otherwise. A task already in a
terminal state is returned unchanged.
"""
task = self._tasks.get(task_id)
if not task:
return None
if task.status.state in TERMINAL_STATES:
return task
# Create new status
new_status = TaskStatus(
@@ -112,6 +137,8 @@ class TaskStore:
)
self._tasks[task_id] = updated
if state in TERMINAL_STATES:
self._evict(keep=task_id)
return updated
def add_artifact(self, task_id: str, artifact: Artifact) -> Optional[Task]:
@@ -123,11 +150,14 @@ class TaskStore:
artifact: Artifact to add
Returns:
Updated Task if found, None otherwise
Updated Task if found, None otherwise. A task in a terminal
state is returned unchanged.
"""
task = self._tasks.get(task_id)
if not task:
return None
if task.status.state in TERMINAL_STATES:
return task
# Get existing artifacts or create new list
artifacts = list(task.artifacts) if task.artifacts else []
@@ -155,11 +185,14 @@ class TaskStore:
message: Message to add to history
Returns:
Updated Task if found, None otherwise
Updated Task if found, None otherwise. A task in a terminal
state is returned unchanged.
"""
task = self._tasks.get(task_id)
if not task:
return None
if task.status.state in TERMINAL_STATES:
return task
# Get existing history or create new list
history = list(task.history) if task.history else []
@@ -120,4 +120,28 @@ async def run_sync_in_executor(func, *args, **kwargs) -> Any:
return func(*args, **kwargs)
return await loop.run_in_executor(None, wrapper)
else:
return await loop.run_in_executor(None, func, *args)
return await loop.run_in_executor(None, func, *args)
# Strong refs to fire-and-forget tasks: the event loop only keeps weak refs,
# so an unreferenced task can be garbage-collected before it finishes.
_background_tasks: "set[asyncio.Task]" = set()
def spawn_background(coro: Awaitable[Any], label: str = "background task") -> "asyncio.Task":
"""
Schedule *coro* on the running loop without awaiting it.
Keeps a strong reference until the task finishes and logs any exception
with *label*, instead of leaving it to asyncio's "Task exception was never
retrieved" warning at GC time. Must be called with a running loop.
"""
task = asyncio.get_running_loop().create_task(coro)
_background_tasks.add(task)
def _done(t: "asyncio.Task") -> None:
_background_tasks.discard(t)
if not t.cancelled() and t.exception() is not None:
logger.error("Error in %s: %s", label, t.exception(), exc_info=t.exception())
task.add_done_callback(_done)
return task
@@ -0,0 +1,26 @@
"""
Resolve a caller-supplied name to a file directly inside a base directory.
Used wherever a store builds ``base / f"{name}{suffix}"`` from a session,
checkpoint, rule, doc or config name, so the name cannot climb out of the
store via ``..`` or path separators.
"""
from pathlib import Path
__all__ = ["safe_child"]
def safe_child(base: Path, name: str, suffix: str = "") -> Path:
"""Return ``base / f"{name}{suffix}"``, refusing names that escape ``base``."""
if (
not isinstance(name, str)
or not name.strip()
or name in (".", "..")
or any(sep in name for sep in ("/", "\\", "\0"))
):
raise ValueError(f"invalid name: {name!r}")
path = Path(base) / f"{name}{suffix}"
if path.resolve().parent != Path(base).resolve():
raise ValueError(f"name escapes {base}: {name!r}")
return path
@@ -27,6 +27,7 @@ import time
import logging
import collections
from praisonaiagents._logging import get_logger
from praisonaiagents.utils.safe_path import safe_child
from pathlib import Path
from typing import Any, Dict, List, Optional, Callable, Tuple, Union
from dataclasses import dataclass, field
@@ -6564,7 +6565,7 @@ class WorkflowManager:
import json
from datetime import datetime
checkpoint_file = self._get_checkpoints_dir() / f"{name}.json"
checkpoint_file = safe_child(self._get_checkpoints_dir(), name, ".json")
checkpoint_data = {
"name": name,
@@ -6599,7 +6600,7 @@ class WorkflowManager:
"""
import json
checkpoint_file = self._get_checkpoints_dir() / f"{name}.json"
checkpoint_file = safe_child(self._get_checkpoints_dir(), name, ".json")
if not checkpoint_file.exists():
self._log(f"Checkpoint '{name}' not found")
@@ -6647,7 +6648,7 @@ class WorkflowManager:
Returns:
True if deleted successfully
"""
checkpoint_file = self._get_checkpoints_dir() / f"{name}.json"
checkpoint_file = safe_child(self._get_checkpoints_dir(), name, ".json")
if checkpoint_file.exists():
checkpoint_file.unlink()
+3 -3
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.7.14"
version = "1.7.16"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -51,7 +51,7 @@ parallel-search = [
memory = [
"chromadb>=1.0.0",
"litellm>=1.83.14",
"litellm>=1.83.14,<1.106", # 1.106 requires mcp>=2.2; mcp is pinned <2
]
knowledge = [
@@ -69,7 +69,7 @@ graph = [
# Add LLM dependencies
llm = [
"litellm>=1.83.14",
"litellm>=1.83.14,<1.106", # 1.106 requires mcp>=2.2; mcp is pinned <2
"pydantic>=2.10.0"
]
+9 -3
View File
@@ -388,7 +388,7 @@ class TestA2AJsonRpc:
assert data["error"]["code"] == -32000
def test_post_a2a_tasks_cancel(self):
"""tasks/cancel → cancels existing task."""
"""tasks/cancel on a completed task → -32002 TaskNotCancelable."""
from fastapi.testclient import TestClient
app, a2a = _make_app()
@@ -421,8 +421,14 @@ class TestA2AJsonRpc:
assert cancel_resp.status_code == 200
data = cancel_resp.json()
assert "result" in data
assert data["result"]["status"]["state"] == "cancelled"
assert data["error"]["code"] == -32002
# The completed task keeps its terminal state
get_resp = client.post("/a2a", json={
"jsonrpc": "2.0", "method": "tasks/get", "id": "3",
"params": {"id": task_id},
})
assert get_resp.json()["result"]["status"]["state"] == "completed"
def test_post_a2a_message_stream(self):
"""message/stream → returns SSE streaming response."""
@@ -0,0 +1,139 @@
"""tasks/cancel must stop background work and stay final (issue #5990)."""
import asyncio
import threading
import pytest
from praisonaiagents.ui.a2a.task_store import TaskStore
from praisonaiagents.ui.a2a.types import Message, Role, TextPart, TaskState
def _msg():
return Message(message_id="m1", role=Role.USER, parts=[TextPart(text="hi")])
def test_terminal_states_are_final():
store = TaskStore()
t = store.create_task(_msg())
store.update_status(t.id, TaskState.WORKING)
store.cancel_task(t.id)
store.update_status(t.id, TaskState.COMPLETED)
assert store.get_task(t.id).status.state == TaskState.CANCELLED
t2 = store.create_task(_msg())
store.update_status(t2.id, TaskState.COMPLETED)
store.cancel_task(t2.id)
assert store.get_task(t2.id).status.state == TaskState.COMPLETED
def test_store_evicts_oldest_terminal_tasks(monkeypatch):
from praisonaiagents.ui.a2a import task_store as ts
monkeypatch.setattr(ts, "_MAX_TASKS", 3)
store = TaskStore()
done = store.create_task(_msg())
store.update_status(done.id, TaskState.COMPLETED)
live = [store.create_task(_msg()) for _ in range(3)]
assert store.get_task(done.id) is None
assert all(store.get_task(t.id) for t in live)
@pytest.mark.asyncio
async def test_cancel_stops_background_task_and_is_not_overwritten():
from praisonaiagents import Agent
from praisonaiagents.ui.a2a import A2A
release = threading.Event()
agent = Agent(name="Cancel Test", role="Tester", goal="Test")
agent.chat = lambda _text: (release.wait(5), "late result")[1]
a2a = A2A(agent=agent, url="http://localhost:8000/a2a")
resp = await a2a._handle_message_send("1", {
"message": {"messageId": "m1", "role": "user", "parts": [{"text": "Hi"}]},
"configuration": {"returnImmediately": True},
})
import json
task_id = json.loads(resp.body)["result"]["id"]
await asyncio.sleep(0.05)
running = a2a._running[task_id]
a2a._handle_tasks_cancel("2", {"id": task_id})
release.set()
with pytest.raises(asyncio.CancelledError):
await running
task = a2a.task_store.get_task(task_id)
assert task.status.state == TaskState.CANCELLED
assert not task.artifacts
assert task_id not in a2a._running
def test_store_evicts_when_tasks_finish(monkeypatch):
from praisonaiagents.ui.a2a import task_store as ts
monkeypatch.setattr(ts, "_MAX_TASKS", 2)
store = TaskStore()
live = [store.create_task(_msg()) for _ in range(4)]
for t in live:
store.update_status(t.id, TaskState.COMPLETED)
assert len(store.list_tasks()) == 2
# The task that just finished is kept for the caller's response.
assert store.get_task(live[-1].id) is not None
@pytest.mark.asyncio
async def test_cancel_during_sync_send_discards_late_result():
import json
from praisonaiagents import Agent
from praisonaiagents.ui.a2a import A2A
started, release = threading.Event(), threading.Event()
agent = Agent(name="Cancel Test", role="Tester", goal="Test")
agent.chat = lambda _text: (started.set(), release.wait(5), "late result")[2]
a2a = A2A(agent=agent, url="http://localhost:8000/a2a")
send = asyncio.create_task(a2a._handle_message_send("1", {
"message": {"messageId": "m1", "role": "user", "parts": [{"text": "Hi"}]},
}))
await asyncio.to_thread(started.wait, 5)
task_id = a2a.task_store.list_tasks()[0].id
a2a._handle_tasks_cancel("2", {"id": task_id})
release.set()
result = json.loads((await send).body)["result"]
task = a2a.task_store.get_task(task_id)
assert result["status"]["state"] == TaskState.CANCELLED.value
assert task.status.state == TaskState.CANCELLED
assert not task.artifacts
assert all(m.role == Role.USER for m in task.history or [])
@pytest.mark.asyncio
async def test_cancelled_sync_send_survives_eviction(monkeypatch):
import json
from praisonaiagents import Agent
from praisonaiagents.ui.a2a import A2A
from praisonaiagents.ui.a2a import task_store as ts
monkeypatch.setattr(ts, "_MAX_TASKS", 2)
started, release = threading.Event(), threading.Event()
agent = Agent(name="Cancel Test", role="Tester", goal="Test")
agent.chat = lambda _text: (started.set(), release.wait(5), "late result")[2]
a2a = A2A(agent=agent, url="http://localhost:8000/a2a")
send = asyncio.create_task(a2a._handle_message_send("1", {
"message": {"messageId": "m1", "role": "user", "parts": [{"text": "Hi"}]},
}))
await asyncio.to_thread(started.wait, 5)
task_id = a2a.task_store.list_tasks()[0].id
a2a._handle_tasks_cancel("2", {"id": task_id})
# Other tasks finish and push the store over the cap while we wait.
for _ in range(3):
other = a2a.task_store.create_task(_msg())
a2a.task_store.update_status(other.id, TaskState.COMPLETED)
assert a2a.task_store.get_task(task_id) is not None
release.set()
result = json.loads((await send).body)["result"]
assert result["id"] == task_id
assert result["status"]["state"] == TaskState.CANCELLED.value
assert task_id not in a2a.task_store._pinned
@@ -0,0 +1,159 @@
"""Tool guards on a shared Agent must not punish the wrong caller (issue #5860).
1. Each chat turn gets its own loop guard, so overlapping turns never exhaust
each other's per-turn limits.
2. Caller errors (ValueError/TypeError/AttributeError from bad input) do not
open the tool circuit breaker for every other caller; real failures still do.
"""
import asyncio
import threading
import pytest
@pytest.fixture
def agent_factory(monkeypatch):
from praisonaiagents import Agent
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
def make(tools):
return Agent(name="shared", instructions="x", tools=tools, llm="gpt-4o-mini")
return make
def _lookup(path: str) -> str:
"""Read something (distinct path each call)."""
return f"read {path}"
def test_overlapping_turns_get_independent_loop_guards(agent_factory):
from praisonaiagents.agent.chat_mixin import _enter_turn, _exit_turn
agent = agent_factory([_lookup])
agent_guard = agent._ensure_loop_guard()
outer = _enter_turn(agent, None) # a long-running turn stays in flight
try:
outer_guard = agent._ensure_loop_guard()
assert outer_guard is not agent_guard
assert outer_guard.config is agent_guard.config
outer_guard.record("lookup", {"q": "x"}, True, "same")
seen = {}
def other_turn():
tok = _enter_turn(agent, None)
try:
seen["guard"] = agent._ensure_loop_guard()
finally:
_exit_turn(agent, tok)
th = threading.Thread(target=other_turn)
th.start()
th.join(5)
assert seen["guard"] is not outer_guard
assert seen["guard"]._tool_counts == {}
assert outer_guard._tool_counts == {"lookup": 1}
finally:
_exit_turn(agent, outer)
# Outside a turn the agent-level guard is used, as before.
assert agent._ensure_loop_guard() is agent_guard
def test_many_overlapping_turns_are_not_halted(agent_factory):
from praisonaiagents.agent.chat_mixin import _enter_turn, _exit_turn
agent = agent_factory([_lookup])
agent._ensure_loop_guard()
tokens = []
try:
for i in range(20): # 20 concurrent turns, one tool call each
tokens.append(_enter_turn(agent, None))
result = agent.execute_tool("_lookup", {"path": f"/tmp/{i}"})
assert result == f"read /tmp/{i}"
finally:
for tok in reversed(tokens):
_exit_turn(agent, tok)
def _get_order(order_id: str) -> str:
"""Look up an order. Raises for unknown ids."""
if not order_id.startswith("A"):
raise ValueError(f"unknown order {order_id}")
return f"order {order_id}: shipped"
def _down(x: str) -> str:
"""A tool whose dependency is down."""
raise RuntimeError("dependency down")
def _sync_run(agent, name, args):
try:
return agent.execute_tool(name, args)
except Exception as e: # surfaced ToolExecutionError
return str(e)
def _async_run(agent, name, args):
try:
return asyncio.run(agent.execute_tool_async(name, args))
except Exception as e:
return str(e)
@pytest.mark.parametrize("run", [_sync_run, _async_run], ids=["sync", "async"])
def test_bad_input_does_not_open_circuit_breaker(agent_factory, run):
agent = agent_factory([_get_order])
agent._ensure_loop_guard().config.enabled = False
for i in range(6):
run(agent, "_get_order", {"order_id": f"bad{i}"})
assert run(agent, "_get_order", {"order_id": "A100"}) == "order A100: shipped"
@pytest.mark.parametrize("run", [_sync_run, _async_run], ids=["sync", "async"])
def test_real_failures_still_open_circuit_breaker(agent_factory, run):
agent = agent_factory([_down])
agent._ensure_loop_guard().config.enabled = False
for _ in range(6):
result = run(agent, "_down", {"x": "1"})
assert "circuit breaker open" in str(result)
@pytest.mark.parametrize("run", [_sync_run, _async_run], ids=["sync", "async"])
def test_bad_input_does_not_close_half_open_breaker(agent_factory, run):
from praisonaiagents.tools.circuit_breaker import CircuitState, _get_global_registry
agent = agent_factory([_get_order])
agent._ensure_loop_guard().config.enabled = False
run(agent, "_get_order", {"order_id": "A1"}) # create the breaker
breaker = _get_global_registry().get(f"tool_{id(agent)}__get_order")
with breaker._lock:
breaker._stats.state = CircuitState.HALF_OPEN
breaker._stats.success_count = 0
for i in range(breaker.config.success_threshold + 1):
run(agent, "_get_order", {"order_id": f"bad{i}"})
assert breaker.state == CircuitState.HALF_OPEN
assert breaker.stats.success_count == 0
def test_streams_get_their_own_loop_guard(agent_factory, monkeypatch):
agent = agent_factory([_lookup])
agent_guard = agent._ensure_loop_guard()
seen = []
def fake_impl(prompt, **kwargs):
guard = agent._ensure_loop_guard()
guard.record("lookup", {"q": prompt}, True, "same")
seen.append(guard)
yield prompt
monkeypatch.setattr(agent, "_start_stream_impl", fake_impl)
assert list(agent._start_stream("a")) == ["a"]
assert list(agent._start_stream("b")) == ["b"]
assert seen[0] is not seen[1] and agent_guard not in seen
assert seen[1]._tool_counts == {"lookup": 1}
assert agent._ensure_loop_guard() is agent_guard
assert agent_guard._tool_counts == {}
@@ -497,3 +497,22 @@ class TestEventType:
bus.publish(EventType.SESSION_CREATED, {})
assert len(received) == 1
class TestAsyncSubscriberErrors:
"""In-loop async subscriber failures are logged by the bus (issue #5990)."""
@pytest.mark.asyncio
async def test_async_subscriber_error_is_logged(self, caplog):
import logging
bus = EventBus()
async def boom(event):
raise ValueError("boom")
sub_id = bus.subscribe(boom)
with caplog.at_level(logging.ERROR):
bus.publish("test.event", {})
for _ in range(5):
await asyncio.sleep(0)
assert any(sub_id in r.getMessage() and "boom" in r.getMessage() for r in caplog.records)
@@ -259,15 +259,31 @@ class TestContextManager:
manager = ContextManager(model="gpt-4o-mini")
text = "Hello world"
# First call
tokens1, _ = manager.estimate_tokens(text)
# Second call should use cache
tokens2, _ = manager.estimate_tokens(text)
with patch(
"praisonaiagents.context.tokens.estimate_tokens_accurate", return_value=3
) as accurate:
# First validated call computes and caches
tokens1, _ = manager.estimate_tokens(text, validate=True)
# Second call should use cache
tokens2, _ = manager.estimate_tokens(text, validate=True)
assert tokens1 == tokens2
assert accurate.call_count == 1
def test_estimate_tokens_heuristic_skips_hashing(self):
"""Heuristic path must not hash or cache the text."""
manager = ContextManager(model="gpt-4o-mini")
with patch("praisonaiagents.context.manager.hashlib.sha256") as sha:
tokens, metrics = manager.estimate_tokens("Hello world")
assert tokens > 0
assert metrics is None
sha.assert_not_called()
assert manager._estimation_cache == {}
def test_reset(self):
"""Test manager reset."""
manager = ContextManager(model="gpt-4o-mini")
@@ -0,0 +1,32 @@
"""An unset LLM timeout is bounded; an explicit None/0 opts out (Issue #5968)."""
import pytest
from praisonaiagents.llm.llm import LLM, _DEFAULT_LLM_TIMEOUT_S
def _params(llm):
return (
llm._build_completion_params(),
llm._build_responses_params([{'role': 'user', 'content': 'hi'}]),
)
def test_unset_timeout_uses_finite_default():
llm = LLM(model='gpt-4o-mini')
assert llm.timeout == _DEFAULT_LLM_TIMEOUT_S
for params in _params(llm):
assert params['timeout'] == _DEFAULT_LLM_TIMEOUT_S
@pytest.mark.parametrize('value', [None, 0])
def test_explicit_opt_out_omits_timeout(value):
llm = LLM(model='gpt-4o-mini', timeout=value)
for params in _params(llm):
assert 'timeout' not in params
def test_explicit_timeout_is_forwarded():
llm = LLM(model='gpt-4o-mini', timeout=30)
for params in _params(llm):
assert params['timeout'] == 30
@@ -7,6 +7,14 @@ do not require live network access or a browser.
import pytest
@pytest.fixture
def public_dns(monkeypatch):
"""Treat example.com hosts as public without needing live DNS."""
monkeypatch.setattr(
"praisonaiagents.tools.url_safety.is_safe_http_url", lambda url: True
)
class TestParseWWWAuthenticate:
def test_none_returns_empty(self):
from praisonaiagents.mcp.oauth_discovery import parse_www_authenticate
@@ -31,7 +39,7 @@ class TestHttpsGuard:
with pytest.raises(ValueError):
_require_https("http://evil.example.com/.well-known/x")
def test_allows_https(self):
def test_allows_https(self, public_dns):
from praisonaiagents.mcp.oauth_discovery import _require_https
_require_https("https://api.example.com/.well-known/x")
@@ -50,7 +58,7 @@ class TestWellKnownCandidates:
class TestEndpointHttpsValidation:
def test_rejects_plaintext_advertised_endpoints(self, monkeypatch):
def test_rejects_plaintext_advertised_endpoints(self, monkeypatch, public_dns):
from praisonaiagents.mcp import oauth_discovery
monkeypatch.setattr(
@@ -64,10 +72,10 @@ class TestEndpointHttpsValidation:
"token_endpoint": "http://auth.example.com/token",
},
)
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="insecure transport"):
oauth_discovery.discover_oauth_metadata("https://api.example.com/mcp")
def test_accepts_all_https_endpoints(self, monkeypatch):
def test_accepts_all_https_endpoints(self, monkeypatch, public_dns):
from praisonaiagents.mcp import oauth_discovery
monkeypatch.setattr(
@@ -87,32 +95,52 @@ class TestEndpointHttpsValidation:
class TestRedirectRevalidation:
def test_rejects_redirect_to_plaintext(self, monkeypatch):
def test_rejects_redirect_before_requesting_it(self, monkeypatch):
from praisonaiagents.mcp import oauth_discovery
class _Hop:
url = "http://internal.local/.well-known/x"
requested = []
class _Resp:
url = "http://internal.local/.well-known/x"
history = [_Hop()]
def raise_for_status(self):
pass
def json(self):
return {}
status_code = 302
headers = {"Location": "http://internal.local/.well-known/x"}
class _Requests:
@staticmethod
def get(*a, **k):
def get(url, **k):
requested.append(url)
assert k["allow_redirects"] is False
return _Resp()
monkeypatch.setitem(
__import__("sys").modules, "requests", _Requests()
)
with pytest.raises(ValueError):
oauth_discovery._get_json("https://api.example.com/.well-known/x")
oauth_discovery._get_json("http://127.0.0.1:8080/.well-known/x")
# The redirect target was refused without ever being fetched.
assert requested == ["http://127.0.0.1:8080/.well-known/x"]
class TestPrivateHostGuard:
@pytest.mark.parametrize("url", [
"https://169.254.169.254/latest/meta-data/",
"https://10.0.0.5/admin",
"https://[::ffff:169.254.169.254]/",
"https://0177.0.0.1/",
])
def test_rejects_internal_https_hosts(self, url, monkeypatch):
from praisonaiagents.mcp.oauth_discovery import _require_https
monkeypatch.delenv("ALLOW_LOCAL_CRAWL", raising=False)
with pytest.raises(ValueError):
_require_https(url)
def test_registration_rejects_internal_endpoint(self, monkeypatch):
from praisonaiagents.mcp.oauth_registration import register_client
monkeypatch.delenv("ALLOW_LOCAL_CRAWL", raising=False)
with pytest.raises(ValueError):
register_client(
"https://10.0.0.5/register",
redirect_uris=["http://127.0.0.1:19876/mcp/oauth/callback"],
)
class TestRegistrationGuard:
@@ -145,6 +173,29 @@ class TestProviderTokenReuse:
assert provider.get_valid_token() == "tok123"
assert provider.ensure_authenticated() == "tok123"
def test_refresh_redirect_keeps_saved_tokens(self, storage, monkeypatch, public_dns):
import requests
from praisonaiagents.mcp.oauth_provider import MCPOAuthProvider
url = "https://api.example.com/mcp"
tokens = {
"access_token": "old",
"refresh_token": "r1",
"token_endpoint": "https://auth.example.com/token",
}
storage.set_tokens("example", tokens, server_url=url)
storage.set_client_info("example", {"client_id": "c1"}, server_url=url)
resp = requests.Response()
resp.status_code = 307
resp.url = tokens["token_endpoint"]
resp._content = b'{"detail": "redirect"}'
monkeypatch.setattr(requests, "post", lambda *a, **k: resp)
provider = MCPOAuthProvider("example", url, storage=storage)
assert provider._try_refresh(storage.get_for_url("example", url)) is False
assert storage.get_for_url("example", url)["tokens"]["access_token"] == "old"
def test_expired_token_without_refresh_returns_none(self, storage):
import time
from praisonaiagents.mcp.oauth_provider import MCPOAuthProvider
@@ -0,0 +1,59 @@
"""Caller-supplied names and dates must not escape the memory store (#5942)."""
import threading
import pytest
from praisonaiagents.memory import FileMemory
from praisonaiagents.memory.rules_manager import RulesManager
@pytest.fixture
def memory(tmp_path):
return FileMemory(user_id="u", base_path=str(tmp_path / "store"))
@pytest.mark.parametrize("date", ["../../../escaped", "2026-01-01/../x", "not-a-date"])
def test_episodic_date_must_be_a_plain_date(memory, tmp_path, date):
with pytest.raises(ValueError):
memory.add_episodic("pwn", date=date)
assert not list(tmp_path.glob("**/escaped.json"))
@pytest.mark.parametrize("name", ["../../../../esc", "a/b", ".."])
def test_session_and_checkpoint_names_cannot_traverse(memory, name):
with pytest.raises(ValueError):
memory.save_session(name)
with pytest.raises(ValueError):
memory.create_checkpoint(name)
def test_plain_names_still_work(memory):
memory.add_episodic("event", date="2026-01-01")
assert [e.content for e in memory.get_episodic(date="2026-01-01")] == ["event"]
assert memory.save_session("my session").endswith("my session.json")
assert memory.create_checkpoint("ckpt-1") == "ckpt-1"
def test_unpadded_episodic_date_is_normalised(memory):
memory.add_episodic("event", date="2026-3-5")
assert [e.content for e in memory.get_episodic(date="2026-03-05")] == ["event"]
assert (memory.episodic_path / "2026-03-05.json").exists()
def test_rule_name_cannot_traverse(tmp_path):
with pytest.raises(ValueError):
RulesManager(workspace_path=str(tmp_path)).create_rule("../../esc_rule", "x")
assert not (tmp_path.parent / "esc_rule.md").exists()
def test_concurrent_episodic_adds_are_not_lost(memory):
threads = [
threading.Thread(target=memory.add_episodic, args=(f"event {i}",), kwargs={"date": "2026-01-01"})
for i in range(40)
]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(memory.get_episodic(date="2026-01-01")) == 40
@@ -0,0 +1,46 @@
"""Step-6 subdirectory walk stays inside the workspace tree (#5937)."""
import pytest
from praisonaiagents.memory.rules_manager import RulesManager
def _write_rule(directory, name, body):
directory.mkdir(parents=True)
(directory / f"{name}.md").write_text(
f"---\nactivation: always\n---\n{body}\n", encoding="utf-8"
)
@pytest.fixture(autouse=True)
def no_git_root(monkeypatch):
monkeypatch.setattr(RulesManager, "_find_git_root", lambda self: None)
def test_cwd_outside_workspace_does_not_load_ancestor_rules(tmp_path, monkeypatch):
outside = tmp_path / "home"
_write_rule(outside / ".claude" / "rules", "stray", "Stray instruction")
cwd = outside / "repo"
cwd.mkdir()
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.chdir(cwd)
manager = RulesManager(workspace_path=str(workspace), global_rules_path=str(tmp_path / "global"))
assert manager.get_rule_by_name("stray") is None
assert manager.get_active_rules() == []
def test_cwd_inside_workspace_still_loads_subdir_rules(tmp_path, monkeypatch):
workspace = tmp_path / "workspace"
nested = workspace / "pkg" / "sub"
_write_rule(workspace / "pkg" / ".claude" / "rules", "pkgrule", "Package instruction")
nested.mkdir(parents=True)
_write_rule(tmp_path / ".claude" / "rules", "above", "Above workspace")
monkeypatch.chdir(nested)
manager = RulesManager(workspace_path=str(workspace), global_rules_path=str(tmp_path / "global"))
assert manager.get_rule_by_name("pkgrule") is not None
assert manager.get_rule_by_name("above") is None
@@ -0,0 +1,32 @@
"""Natural-language queries must recall facts from the SQLite adapter (#5942)."""
import pytest
from praisonaiagents.memory.adapters.sqlite_adapter import SqliteMemoryAdapter
@pytest.fixture
def adapter(tmp_path):
a = SqliteMemoryAdapter(short_db=str(tmp_path / "s.db"), long_db=str(tmp_path / "l.db"))
yield a
a.close_connections()
@pytest.mark.parametrize("query", [
"dark mode",
"user prefers Python",
"Which mode does the user prefer?",
"What language does the user work in?",
])
def test_long_term_matches_on_words(adapter, query):
adapter.store_long_term("The user prefers dark mode and works in Python.")
adapter.store_long_term("The weather is sunny.")
results = adapter.search_long_term(query)
assert results and "dark mode" in results[0]["text"]
def test_exact_phrase_ranks_first_and_unrelated_query_misses(adapter):
adapter.store_short_term("python tips")
adapter.store_short_term("I write python code daily")
assert adapter.search_short_term("write python")[0]["text"] == "I write python code daily"
assert adapter.search_short_term("zebra") == []
@@ -0,0 +1,268 @@
"""``async def`` plugin hooks are awaited, not silently dropped (#5964)."""
import asyncio
import os
import time
import warnings
from praisonaiagents.plugins.plugin import (
GuardrailBlocked,
Plugin,
PluginHook,
PluginInfo,
)
from praisonaiagents.plugins.manager import PluginManager
from praisonaiagents.hooks.registry import HookRegistry
from praisonaiagents.hooks.runner import HookRunner
from praisonaiagents.hooks.types import HookEvent
from praisonaiagents.hooks.events import BeforeToolInput, SessionStartInput
class AsyncToolPlugin(Plugin):
def __init__(self):
self.sessions = []
@property
def info(self):
return PluginInfo(
name="async_tool",
hooks=[PluginHook.BEFORE_TOOL, PluginHook.SESSION_START],
)
async def before_tool(self, tool_name, args):
await asyncio.sleep(0)
if tool_name == "rm":
raise GuardrailBlocked("rm is not allowed")
return {**args, "injected": True}
async def session_start(self, context):
await asyncio.sleep(0)
self.sessions.append(context["source"])
def _tool_input(tool_name="bash"):
return BeforeToolInput(
session_id="s", cwd=os.getcwd(), event_name=HookEvent.BEFORE_TOOL,
timestamp=str(time.time()), agent_name="a",
tool_name=tool_name, tool_input={"cmd": "ls"},
)
def _runner(plugin):
reg = HookRegistry()
mgr = PluginManager()
mgr.register(plugin)
mgr.wire_into_hook_registry(reg)
return HookRunner(registry=reg, cwd=os.getcwd())
def test_async_transforming_hook_is_awaited_via_runner():
runner = _runner(AsyncToolPlugin())
data = _tool_input()
with warnings.catch_warnings():
warnings.simplefilter("error", RuntimeWarning)
runner.execute_sync(HookEvent.BEFORE_TOOL, data)
assert data.tool_input == {"cmd": "ls", "injected": True}
def test_async_hook_guardrail_block_propagates():
runner = _runner(AsyncToolPlugin())
results = runner.execute_sync(HookEvent.BEFORE_TOOL, _tool_input("rm"))
assert any(r.output and r.output.is_denied() for r in results)
def test_async_observer_hook_runs_inside_running_loop():
plugin = AsyncToolPlugin()
runner = _runner(plugin)
data = SessionStartInput(
session_id="s", cwd=os.getcwd(), event_name=HookEvent.SESSION_START,
timestamp=str(time.time()), source="startup",
)
asyncio.run(runner.execute(HookEvent.SESSION_START, data))
assert plugin.sessions == ["startup"]
def test_execute_hook_awaits_async_hook_sync_and_async_paths():
mgr = PluginManager()
mgr.register(AsyncToolPlugin())
assert mgr.execute_hook(PluginHook.BEFORE_TOOL, "bash", {"a": 1}) == {
"a": 1, "injected": True,
}
out = asyncio.run(mgr.async_execute_hook(PluginHook.BEFORE_TOOL, "bash", {"a": 1}))
assert out == {"a": 1, "injected": True}
class AsyncLifecyclePlugin(Plugin):
def __init__(self):
self.calls = []
@property
def info(self):
return PluginInfo(name="async_lifecycle")
async def on_init(self, context):
await asyncio.sleep(0)
self.calls.append("init")
async def on_config(self, config):
await asyncio.sleep(0)
self.calls.append(("config", config))
return config
async def on_shutdown(self):
await asyncio.sleep(0)
self.calls.append("shutdown")
def test_async_lifecycle_callbacks_are_awaited():
plugin = AsyncLifecyclePlugin()
mgr = PluginManager()
with warnings.catch_warnings():
warnings.simplefilter("error", RuntimeWarning)
assert mgr.register(plugin)
mgr.enable("async_lifecycle")
mgr.set_plugin_options({"async_lifecycle": {"k": 1}})
assert mgr.apply_plugin_options() == 1
assert mgr.unregister("async_lifecycle")
assert plugin.calls == ["init", ("config", {"k": 1}), "shutdown"]
def test_hung_async_hook_is_cancelled_at_deadline(monkeypatch):
from praisonaiagents.plugins import manager as manager_mod
monkeypatch.setattr(manager_mod, "_ASYNC_HOOK_TIMEOUT", 0.05)
cancelled = []
async def hang():
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
cancelled.append(True)
raise
start = time.time()
try:
manager_mod._resolve_awaitable(hang())
except (TimeoutError, asyncio.TimeoutError):
pass
assert time.time() - start < 2
assert cancelled == [True]
def test_async_on_init_can_call_back_into_manager_from_running_loop(monkeypatch):
"""register() must not hold the manager lock while async on_init runs."""
from praisonaiagents.plugins import manager as manager_mod
# A deadlock would otherwise only surface after the 60s deadline.
monkeypatch.setattr(manager_mod, "_ASYNC_HOOK_TIMEOUT", 2.0)
mgr = PluginManager()
class ReentrantInitPlugin(Plugin):
seen = None
@property
def info(self):
return PluginInfo(name="reentrant_init")
async def on_init(self, context):
await asyncio.sleep(0)
# Bridged onto another thread when called from a running loop.
# enable() takes the manager lock.
self.seen = mgr.enable("reentrant_init")
plugin = ReentrantInitPlugin()
async def main():
start = time.monotonic()
assert mgr.register(plugin) is True
return time.monotonic() - start
elapsed = asyncio.run(main())
assert plugin.seen is True
assert elapsed < 1.5
def test_failed_async_on_init_rolls_back_registration():
"""A plugin whose on_init fails is not left half-registered."""
mgr = PluginManager()
class FailingInitPlugin(Plugin):
@property
def info(self):
return PluginInfo(name="failing_init")
async def on_init(self, context):
raise RuntimeError("boom")
assert mgr.register(FailingInitPlugin()) is False
assert mgr.get_plugin("failing_init") is None
class FixedInitPlugin(FailingInitPlugin):
async def on_init(self, context):
return None
# A retry is not rejected as "already registered".
assert mgr.register(FixedInitPlugin()) is True
def test_plugin_not_dispatched_until_async_on_init_finishes():
"""Concurrent dispatch during a slow on_init must not reach the plugin."""
import threading
mgr = PluginManager()
init_started = threading.Event()
release_init = threading.Event()
class SlowInitPlugin(Plugin):
def __init__(self):
self.ready = False
self.calls_before_ready = 0
@property
def info(self):
return PluginInfo(name="slow_init", hooks=[PluginHook.BEFORE_TOOL])
async def on_init(self, context):
init_started.set()
await asyncio.get_running_loop().run_in_executor(None, release_init.wait, 5)
self.ready = True
def before_tool(self, tool_name, args):
if not self.ready:
self.calls_before_ready += 1
return {**args, "seen": True}
plugin = SlowInitPlugin()
results = {}
t = threading.Thread(target=lambda: results.setdefault("ok", mgr.register(plugin)))
t.start()
assert init_started.wait(5)
# During on_init: neither direct dispatch nor registry wiring sees it
# (with no enabled plugins execute_hook passes its first arg through).
assert mgr.execute_hook(PluginHook.BEFORE_TOOL, "bash", {"a": 1}) == "bash"
assert mgr.wire_into_hook_registry(HookRegistry()) == 0
assert mgr.is_enabled("slow_init") is False
release_init.set()
t.join(5)
assert results["ok"] is True
assert mgr.is_enabled("slow_init") is True
assert mgr.execute_hook(PluginHook.BEFORE_TOOL, "bash", {"a": 1}) == {"a": 1, "seen": True}
assert plugin.calls_before_ready == 0
def test_disable_during_on_init_is_kept():
"""A plugin that disables itself in on_init stays disabled after register."""
mgr = PluginManager()
class SelfDisablingPlugin(Plugin):
@property
def info(self):
return PluginInfo(name="self_disabling", hooks=[PluginHook.BEFORE_TOOL])
async def on_init(self, context):
mgr.disable("self_disabling")
assert mgr.register(SelfDisablingPlugin()) is True
assert mgr.is_enabled("self_disabling") is False
@@ -0,0 +1,66 @@
"""Load-time praisonaiagents compatibility gate for plugins (#5951)."""
from praisonaiagents._version import __version__
from praisonaiagents.plugins.discovery import load_plugin
from praisonaiagents.plugins.manager import PluginManager
from praisonaiagents.plugins.plugin import Plugin, PluginInfo, core_incompatibility
class _P(Plugin):
def __init__(self, deps):
self._deps = deps
@property
def info(self):
return PluginInfo(name="compat_probe", dependencies=self._deps)
def test_no_core_entry_is_compatible():
assert core_incompatibility([]) is None
assert core_incompatibility(["requests>=2", "praisonaiagents"]) is None
assert core_incompatibility(["not a valid req!!"]) is None
def test_satisfied_and_unsatisfied_ranges():
assert core_incompatibility([f"praisonaiagents>={__version__}"]) is None
reason = core_incompatibility(["praisonaiagents>=999"])
assert "praisonaiagents>=999" in reason and __version__ in reason
def test_comma_split_header_fragments_are_rejoined():
assert core_incompatibility(["praisonaiagents>=0.0.1", "<999"]) is None
assert core_incompatibility(["praisonaiagents>=0.0.1", "<0.0.2"]) is not None
def test_environment_markers_are_honoured():
import sys
other = "win32" if sys.platform != "win32" else "linux"
assert core_incompatibility([f"praisonaiagents>=999; sys_platform == '{other}'"]) is None
assert core_incompatibility([f"praisonaiagents>=999; sys_platform == '{sys.platform}'"]) is not None
def test_unknown_source_version_falls_back_to_installed_metadata(monkeypatch):
import importlib.metadata
from praisonaiagents import _version
monkeypatch.setattr(_version, "__version__", "unknown")
monkeypatch.setattr(importlib.metadata, "version", lambda name: "1.0.0")
assert core_incompatibility(["praisonaiagents>=999"]) is not None
assert core_incompatibility(["praisonaiagents>=1,<2"]) is None
def test_register_skips_incompatible_plugin():
pm = PluginManager()
assert pm.register(_P(["praisonaiagents>=999"])) is False
assert pm.get_plugin("compat_probe") is None
assert pm.register(_P([])) is True
def test_single_file_plugin_not_executed_when_incompatible(tmp_path):
marker = tmp_path / "ran"
plugin = tmp_path / "p.py"
plugin.write_text(
'"""\nPlugin Name: Compat\nDependencies: praisonaiagents>=999\n"""\n'
f"open({str(marker)!r}, 'w').close()\n"
)
assert load_plugin(str(plugin)) is None
assert not marker.exists()
@@ -100,5 +100,5 @@ class TestEstimateTokens:
from praisonaiagents.rag.compressor import estimate_tokens
assert estimate_tokens("") == 0
assert estimate_tokens("hello") == 2 # 5/4 + 1 = 2
assert estimate_tokens("a" * 100) == 26 # 100/4 + 1 = 26
assert estimate_tokens("hello") == 1 # canonical: 5 * 0.25, min 1
assert estimate_tokens("a" * 100) == 25
@@ -136,4 +136,4 @@ class TestEstimateTokensSummarizer:
from praisonaiagents.rag.summarizer import estimate_tokens
assert estimate_tokens("") == 0
assert estimate_tokens("test") == 2
assert estimate_tokens("test") == 1
@@ -355,3 +355,99 @@ class TestDeliverAggregatesOutcome:
bot = QueuingBot()
result = asyncio.run(bot.deliver("c1", "hello"))
assert result.status == "sent"
class TestRuntimeContractDefaults:
"""Issue #5984: a minimal subclass satisfies what Bot/BotOS drive."""
def test_runtime_methods_present(self):
bot = RecordingBot()
for name in ("start", "stop", "send_message", "probe", "health"):
assert callable(getattr(bot, name))
def test_start_runs_until_stop(self):
async def scenario():
bot = RecordingBot()
task = asyncio.ensure_future(bot.start())
await asyncio.sleep(0.01)
assert bot.connected and not task.done()
health = await bot.health()
assert health.ok and health.is_running
assert (await bot.probe()).ok
await bot.stop()
await asyncio.wait_for(task, 1)
assert not bot.connected
assert not (await bot.health()).is_running
run(scenario())
def test_start_raises_when_connect_fails(self):
class NoConnect(RecordingBot):
async def connect(self, *, is_reconnect=False):
return False
with pytest.raises(ConnectionError):
run(NoConnect().start())
def test_stop_during_connect_is_honoured(self):
async def scenario():
gate = asyncio.Event()
class SlowConnect(RecordingBot):
async def connect(self, *, is_reconnect=False):
await gate.wait()
return await super().connect(is_reconnect=is_reconnect)
bot = SlowConnect()
task = asyncio.ensure_future(bot.start())
await asyncio.sleep(0.01)
await bot.stop()
gate.set()
await asyncio.wait_for(task, 1)
assert not bot.connected
run(scenario())
def test_cancelled_start_disconnects(self):
async def scenario():
bot = RecordingBot()
task = asyncio.ensure_future(bot.start())
await asyncio.sleep(0.01)
assert bot.connected
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert not bot.connected
assert not (await bot.health()).is_running
run(scenario())
def test_send_message_routes_through_deliver(self):
bot = RecordingBot()
res = run(bot.send_message("c1", "hello", reply_to="r1", thread_id="t1"))
assert res.ok and res.status == "sent"
assert bot.sends == [("c1", "hello", "r1")]
def test_probe_round_trips_when_not_running(self):
bot = RecordingBot()
res = run(bot.probe())
assert res.ok and not bot.connected
class BadCreds(RecordingBot):
async def connect(self, *, is_reconnect=False):
raise RuntimeError("401 unauthorized")
res = run(BadCreds().probe())
assert not res.ok and "401" in res.error
def test_probe_timeout_still_disconnects(self):
class Hangs(RecordingBot):
async def connect(self, *, is_reconnect=False):
self.connected = True # partially opened resource
await asyncio.sleep(10)
return True
bot = Hangs()
with pytest.raises(asyncio.TimeoutError):
run(asyncio.wait_for(bot.probe(), 0.05))
assert not bot.connected
@@ -0,0 +1,122 @@
"""Device-identity handshake contract (Issue #5998)."""
import base64
import pytest
from praisonaiagents.gateway import (
ConnectErrorCode,
DeviceAuth,
DeviceIdentityProtocol,
FrameDecodeError,
HelloParams,
decode_client_frame,
device_id_from_public_key,
)
PUB = "dGVzdC1wdWJsaWMta2V5"
def _hello(**device):
block = {
"device_id": device_id_from_public_key(PUB),
"public_key": PUB,
"signature": "sig",
"signed_at": 1700000000,
"nonce": "n-1",
}
block.update(device)
return {"type": "hello", "agent_id": "a", "device": block}
def test_hello_without_device_is_unchanged():
frame = decode_client_frame({"type": "hello", "agent_id": "a"})
assert isinstance(frame, HelloParams)
assert frame.device is None
def test_hello_decodes_device():
frame = decode_client_frame(_hello())
assert isinstance(frame.device, DeviceAuth)
assert frame.device.device_id == device_id_from_public_key(PUB)
assert frame.device.nonce == "n-1"
assert frame.device.signed_at == 1700000000
def test_spoofed_device_id_is_unauthorized():
with pytest.raises(FrameDecodeError) as exc:
decode_client_frame(_hello(device_id="0" * 64))
assert exc.value.error.code == ConnectErrorCode.AUTH_UNAUTHORIZED
@pytest.mark.parametrize("bad", [
{"signature": ""},
{"nonce": None},
{"signed_at": True},
{"signed_at": "now"},
{"signed_at": float("nan")},
{"signed_at": 1700000000.5},
{"public_key": "not base64!"},
{"public_key": "dGVzdC1wdWJsaWMta2V5\n"},
])
def test_malformed_device_rejected(bad):
with pytest.raises(FrameDecodeError) as exc:
decode_client_frame(_hello(**bad))
assert exc.value.error.code == ConnectErrorCode.CONFIGURATION_ERROR
def test_device_must_be_object():
with pytest.raises(FrameDecodeError):
decode_client_frame({"type": "hello", "agent_id": "a", "device": "x"})
def test_wire_format_is_pinned():
# Literal vectors: changing the fingerprint or signed bytes breaks clients.
assert device_id_from_public_key(PUB) == (
"c88e80202a4d650841e48649b4c3f553e48131b415db680476fd0d65632ff2b0"
)
d = DeviceAuth(device_id_from_public_key(PUB), PUB, "sig", 1700000000, "n-1")
assert d.signing_payload("a") == (
b"praisonai-device-v1\n"
b"c88e80202a4d650841e48649b4c3f553e48131b415db680476fd0d65632ff2b0\n"
b"n-1\n1700000000\na"
)
def test_signing_payload_binds_agent_and_nonce():
d = decode_client_frame(_hello()).device
assert d.signing_payload("a") != d.signing_payload("b")
other = DeviceAuth(d.device_id, d.public_key, d.signature, d.signed_at, "n-2")
assert d.signing_payload("a") != other.signing_payload("a")
def test_ed25519_round_trip():
ed = pytest.importorskip("cryptography.hazmat.primitives.asymmetric.ed25519")
from cryptography.hazmat.primitives import serialization
key = ed.Ed25519PrivateKey.generate()
raw = key.public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw
)
pub = base64.b64encode(raw).decode()
unsigned = DeviceAuth(device_id_from_public_key(pub), pub, "", 1700000000, "nonce")
sig = base64.b64encode(key.sign(unsigned.signing_payload("a"))).decode()
frame = decode_client_frame({
"type": "hello", "agent_id": "a",
"device": {**unsigned.__dict__, "signature": sig},
})
ed.Ed25519PublicKey.from_public_bytes(base64.b64decode(frame.device.public_key)).verify(
base64.b64decode(frame.device.signature),
frame.device.signing_payload(frame.agent_id),
)
def test_protocol_is_runtime_checkable():
class Store:
def issue_challenge(self): return "n"
def verify(self, device, *, agent_id): return None
def list_devices(self): return []
def revoke(self, device_id): return False
assert isinstance(Store(), DeviceIdentityProtocol)
@@ -0,0 +1,78 @@
"""The TypeScript client's ``GatewayEventType`` must mirror Python's ``EventType``.
The gateway wire event vocabulary is defined once in
``praisonaiagents/gateway/protocols.py`` and re-declared in
``praisonai-ts/src/gateway/index.ts``. Nothing else ties the two together, so an
event type added (or renamed) on the Python side would otherwise reach the TS
client only as silently ignored or misrouted frames at runtime.
This is a lockstep test: it asserts that the two independently-maintained lists
agree member-for-member (name and wire value), so drift fails CI instead.
"""
import re
from pathlib import Path
import pytest
from praisonaiagents.gateway.protocols import EventType
_TS_GATEWAY = (
Path(__file__).resolve().parents[3]
/ "praisonai-ts"
/ "src"
/ "gateway"
/ "index.ts"
)
_TS_MEMBER = re.compile(r"""([A-Za-z_$][\w$]*)\s*=\s*(['"])(.*?)\2""", re.S)
def _parse_ts_enum(source: str) -> dict[str, str]:
"""Parse ``export enum GatewayEventType { NAME = 'value', ... }`` from TS source.
Every enum member must be a ``NAME = 'value'`` (or double-quoted) pair; any
other entry fails loudly rather than being silently skipped.
"""
body = re.search(r"export enum GatewayEventType\s*\{(.*?)\n\}", source, re.S)
assert body, "GatewayEventType enum not found"
text = re.sub(r"/\*.*?\*/|//[^\n]*", "", body.group(1), flags=re.S)
members = {}
for entry in filter(None, (e.strip() for e in text.split(","))):
match = _TS_MEMBER.fullmatch(entry)
assert match, f"Unparseable GatewayEventType entry: {entry!r}"
members[match.group(1)] = match.group(3)
return members
def _ts_event_types() -> dict[str, str]:
return _parse_ts_enum(_TS_GATEWAY.read_text(encoding="utf-8"))
def test_parse_ts_enum_rejects_or_captures_every_entry():
src = """export enum GatewayEventType {
// comment
A = 'a',
MESSAGE_V2 = 'message_v2',
B = "b",
}"""
assert _parse_ts_enum(src) == {"A": "a", "MESSAGE_V2": "message_v2", "B": "b"}
with pytest.raises(AssertionError, match="Unparseable"):
_parse_ts_enum("export enum GatewayEventType {\n A = 'a',\n C,\n}")
@pytest.mark.skipif(not _TS_GATEWAY.exists(), reason="TypeScript SDK not present")
def test_ts_gateway_event_type_matches_python():
python = {member.name: member.value for member in EventType}
ts = _ts_event_types()
missing_in_ts = sorted(set(python) - set(ts))
extra_in_ts = sorted(set(ts) - set(python))
mismatched = sorted(n for n in set(python) & set(ts) if python[n] != ts[n])
assert not (missing_in_ts or extra_in_ts or mismatched), (
"praisonai-ts GatewayEventType drifted from praisonaiagents EventType: "
f"missing in TS={missing_in_ts}, extra in TS={extra_in_ts}, "
f"value mismatch={mismatched}. Update src/praisonai-ts/src/gateway/index.ts."
)
@@ -0,0 +1,39 @@
"""ImageAgent must not send the base Agent's chat default model to image APIs (#5944)."""
import sys
import types
import pytest
from praisonaiagents import ImageAgent
@pytest.mark.parametrize("model_kwargs,expected", [
({}, {"generate": "dall-e-3", "edit": "dall-e-2", "variation": "dall-e-2"}),
({"llm": "openai/gpt-image-1"}, dict.fromkeys(("generate", "edit", "variation"), "openai/gpt-image-1")),
({"model": "openai/gpt-image-1"}, dict.fromkeys(("generate", "edit", "variation"), "openai/gpt-image-1")),
])
def test_image_model_resolution(monkeypatch, tmp_path, model_kwargs, expected):
calls = {}
def record(name):
def call(**kwargs):
calls[name] = kwargs
return {"data": []}
return call
agent = ImageAgent(instructions="Create images", verbose=False, **model_kwargs)
monkeypatch.setattr(agent, "_litellm", record("generate"))
monkeypatch.setitem(sys.modules, "litellm", types.SimpleNamespace(
image_edit=record("edit"),
image_variation=record("variation"),
get_llm_provider=lambda model: (model, "openai", None, None),
))
image = tmp_path / "source.png"
image.write_bytes(b"png")
agent.generate("A red circle")
agent.edit(str(image), "Add clouds")
agent.variation(str(image))
assert {name: kwargs["model"] for name, kwargs in calls.items()} == expected
@@ -0,0 +1,27 @@
"""OpenAI image calls must not forward the default response_format="url"."""
import pytest
from praisonaiagents import ImageAgent
@pytest.mark.parametrize("llm,kwargs,expected", [
("openai/dall-e-2", {}, None),
("openai/gpt-image-1", {}, None),
("openai/dall-e-3", {"response_format": "b64_json"}, "b64_json"),
("gemini/imagen-3.0-generate-002", {}, None),
])
def test_response_format_forwarding(llm, kwargs, expected):
agent = ImageAgent(llm=llm, verbose=False)
received = {}
def fake_image_generation(**call_kwargs):
received.update(call_kwargs)
return {"data": [{"url": "https://example.com/image.png"}]}
agent._litellm = fake_image_generation
try:
agent.generate("A minimal red circle icon", **kwargs)
assert received.get("response_format") == expected
finally:
agent.close()
@@ -302,3 +302,11 @@ def test_expand_access_groups_cycle_rejected():
with pytest.raises(ValueError, match="cycle"):
expand_access_groups(["group:a"], {"a": ["group:b"], "b": ["group:a"]})
def test_unknown_group_policy_fails_closed_to_mention_only():
"""Issue #5962: an unrecognised policy is mention-gated, not respond_all."""
from praisonaiagents.bots.admission import resolve_ingress_admission
d = resolve_ingress_admission(chat_type="group", sender_id="u", group_policy="bogus")
assert d.admit is False and d.reason_code == "group_mention_only"
@@ -0,0 +1,125 @@
"""Hot paths use the shared error classifier, token estimator and atomic writer (#5838)."""
import json
import os
import pytest
class _E(Exception):
def __init__(self, msg, status_code=None, headers=None):
super().__init__(msg)
self.status_code = status_code
class _R:
pass
self.response = _R()
self.response.headers = headers or {}
@pytest.fixture
def llm():
from praisonaiagents.llm.llm import LLM
inst = LLM.__new__(LLM)
inst._rate_limiter = None
inst._retry_delay = 1.0
inst._max_retries = 3
return inst
@pytest.mark.parametrize("error, kind", [
(_E("max_tokens must be <= 4096, got 8500 (request req_5003abc)", 400), "format_error"),
(_E("Error code: 401 - Incorrect credentials", 401), "auth"),
(_E("Error code: 403 - region not supported", 403), "auth"),
(_E("Service Error", 503), "overloaded"),
(_E("Error code: 404 - The model `gpt-9` does not exist", 404), "model_not_found"),
(_E("Error code: 400 - invalid image: credit card detected", 400), "format_error"),
(_E("You exceeded your quota: insufficient quota", 429), "billing"),
(_E("Your credit balance is too low", 400), "billing"),
(_E("Rate limit reached for API key sk-...", 429), "rate_limit"),
(Exception("503 service unavailable"), "overloaded"),
])
def test_status_code_drives_error_kind(llm, error, kind):
assert llm.classify_error_kind(error) == kind
def test_retry_after_header_is_honoured(llm):
err = _E("Error code: 429", 429, {"retry-after": "37"})
assert llm._parse_retry_delay(err) == 37.0
decision = llm.resolve_failover_decision(err, {"attempt": 1, "max_retries": 3})
assert decision.action == "retry"
assert decision.backoff_ms == 37000
def test_budget_estimators_count_cjk_densely():
from praisonaiagents.context.tokens import estimate_tokens_heuristic
from praisonaiagents.rag.budget import estimate_tokens
text = "今天天气很好,我们一起去公园散步。" * 100
assert estimate_tokens(text) == estimate_tokens_heuristic(text)
assert estimate_tokens(text) > len(text)
def test_file_tracker_failed_save_keeps_previous_state(tmp_path):
from praisonaiagents.knowledge.indexing import FileTracker
state = str(tmp_path / "tracker.json")
tracker = FileTracker(state)
tracker._tracked = {"a.md": {"hash": "1", "mtime": 1, "memory_ids": ["m1"]}}
tracker.save()
tracker._tracked["b.md"] = {"hash": "2", "mtime": 2, "memory_ids": {"m3"}}
with pytest.raises(TypeError):
tracker.save()
reloaded = FileTracker(state)
reloaded.load()
assert list(reloaded._tracked) == ["a.md"]
def test_permission_allowlist_failed_save_and_corrupt_load(tmp_path):
from praisonaiagents.approval import PermissionAllowlist
path = str(tmp_path / "perm.json")
allowlist = PermissionAllowlist()
allowlist.add_tool("shell", paths=["/tmp"])
allowlist.save(path)
allowlist._tools["shell"].allowed_paths = [object()]
with pytest.raises(TypeError):
allowlist.save(path)
assert PermissionAllowlist.load(path).list_tools() == ["shell"]
with open(path, "w") as f:
f.write("{truncated")
assert PermissionAllowlist.load(path).is_empty()
assert not os.path.exists(path)
def test_file_index_save_is_atomic(tmp_path):
from praisonaiagents.context.fast.index_manager import FileIndex
index = FileIndex(workspace_path=str(tmp_path))
assert index.save()
with open(index.index_path) as f:
assert json.load(f)["workspace"] == str(tmp_path)
def test_cjk_truncation_stays_within_token_budget():
from praisonaiagents.rag.budget import DefaultBudgetEnforcer, TokenBudget, estimate_tokens
from praisonaiagents.rag.compressor import ContextCompressor
from praisonaiagents.rag.context import truncate_context
cjk = "汉" * 1000
out = truncate_context(cjk, 200)
assert out.endswith("[Context truncated...]")
assert estimate_tokens(out) <= 200
budget = TokenBudget(model_max_tokens=1200, reserved_response_tokens=0)
available = budget.dynamic_budget()
kept = DefaultBudgetEnforcer().enforce([{"text": cjk}, {"text": cjk}], budget)
assert sum(estimate_tokens(c["text"]) for c in kept) <= available
assert kept[-1]["metadata"]["_truncated"]
kept = ContextCompressor()._truncate_to_budget([{"text": cjk}], 200)
assert estimate_tokens(kept[0]["text"]) <= 200
@@ -0,0 +1,233 @@
"""Regression tests for issue #5900 (BackgroundJobManager reliability)."""
import threading
import time
from concurrent.futures import CancelledError
import pytest
from praisonaiagents.background.job_manager import BackgroundJobManager, JobInfo, JobStatus
from praisonaiagents.background.sqlite_store import SqliteBackgroundJobStore
def _wait_status(m, job_id, status, timeout=5.0):
deadline = time.time() + timeout
while time.time() < deadline:
if m.get_status(job_id) == status:
return
time.sleep(0.01)
raise AssertionError(f"{job_id} never reached {status}")
def test_reusing_active_job_id_raises_and_terminal_id_reusable():
m = BackgroundJobManager(max_workers=1)
gate = threading.Event()
try:
m.start_job(gate.wait, job_id="x")
_wait_status(m, "x", JobStatus.RUNNING)
with pytest.raises(ValueError):
m.start_job(lambda: "B", job_id="x")
gate.set()
m.get_result("x", timeout=5)
m.start_job(lambda: "B", job_id="x")
assert m.get_result("x", timeout=5) == "B"
finally:
gate.set()
m.shutdown()
def test_auto_job_id_is_longer():
m = BackgroundJobManager(max_workers=1)
try:
assert len(m.start_job(lambda: 1)) >= 12
finally:
m.shutdown()
def test_running_job_can_be_cancelled_and_result_discarded():
m = BackgroundJobManager(max_workers=1)
gate = threading.Event()
delivered = []
try:
m.start_job(lambda: gate.wait() and "late", job_id="r", on_complete=delivered.append)
_wait_status(m, "r", JobStatus.RUNNING)
assert m.cancel_job("r") is True
assert m.get_status("r") == JobStatus.CANCELLED
with pytest.raises(CancelledError):
m.get_result("r", timeout=0.1)
finally:
gate.set()
m.shutdown(wait=True)
assert m.get_status("r") == JobStatus.CANCELLED
assert m.get_job_info("r").result is None
assert delivered == []
def test_cancel_wakes_untimed_result_waiter():
m = BackgroundJobManager(max_workers=1)
gate = threading.Event()
outcome = []
def waiter():
try:
m.get_result("w")
except CancelledError:
outcome.append("cancelled")
try:
m.start_job(gate.wait, job_id="w")
_wait_status(m, "w", JobStatus.RUNNING)
t = threading.Thread(target=waiter, daemon=True)
t.start()
time.sleep(0.05)
assert m.cancel_job("w") is True
t.join(timeout=2)
assert outcome == ["cancelled"]
finally:
gate.set()
m.shutdown()
def test_cancel_pending_job_never_runs():
m = BackgroundJobManager(max_workers=1)
gate = threading.Event()
ran = []
try:
m.start_job(gate.wait, job_id="blocker")
m.start_job(lambda: ran.append(1), job_id="p")
assert m.cancel_job("p") is True
with pytest.raises(CancelledError):
m.get_result("p")
finally:
gate.set()
m.shutdown(wait=True)
assert ran == []
assert m.cancel_job("p") is False
def test_stale_callback_does_not_overwrite_reused_id_in_store():
store = SqliteBackgroundJobStore(":memory:")
m = BackgroundJobManager(max_workers=2, store=store)
in_callback = threading.Event()
release = threading.Event()
gate = threading.Event()
def slow_callback(info):
in_callback.set()
release.wait(5)
try:
m.start_job(lambda: "old", job_id="id", on_complete=slow_callback, origin={"chat_id": "1"})
assert in_callback.wait(5)
m.start_job(gate.wait, job_id="id")
_wait_status(m, "id", JobStatus.RUNNING)
release.set()
time.sleep(0.1)
persisted = store.get("id")
assert persisted.status == JobStatus.RUNNING
assert persisted.result is None
finally:
release.set()
gate.set()
m.shutdown()
def test_get_result_timeout_raises_builtin_timeout_error():
m = BackgroundJobManager(max_workers=1)
gate = threading.Event()
try:
m.start_job(gate.wait, job_id="t")
with pytest.raises(TimeoutError, match="did not complete"):
m.get_result("t", timeout=0.05)
finally:
gate.set()
m.shutdown()
def test_cleanup_prunes_durable_store():
store = SqliteBackgroundJobStore(":memory:")
old = time.time() - 7200
store.upsert(JobInfo(job_id="old", status=JobStatus.COMPLETED, completed_at=old))
store.upsert(JobInfo(
job_id="undelivered", status=JobStatus.COMPLETED, completed_at=old,
origin={"chat_id": "1"},
))
store.upsert(JobInfo(job_id="fresh", status=JobStatus.FAILED, completed_at=time.time()))
store.upsert(JobInfo(job_id="running", status=JobStatus.RUNNING))
m = BackgroundJobManager(max_workers=1, store=store)
try:
m.cleanup_completed(max_age=3600)
assert store.get("old") is None
assert store.get("undelivered") is not None
assert store.get("fresh") is not None
assert store.get("running") is not None
finally:
m.shutdown()
def test_submit_after_shutdown_fails_job_and_frees_id():
m = BackgroundJobManager(max_workers=1)
m.shutdown()
seen = []
with pytest.raises(RuntimeError):
m.start_job(lambda: "A", job_id="x", on_complete=seen.append)
assert m.get_status("x") == JobStatus.FAILED
assert [i.status for i in seen] == [JobStatus.FAILED] # failure not silent
with pytest.raises(RuntimeError):
m.get_result("x") # resolved, not blocking forever
def test_callback_base_exception_still_resolves_result():
class Stop(BaseException):
pass
def boom(_info):
raise Stop()
m = BackgroundJobManager(max_workers=1)
try:
m.start_job(lambda: "A", job_id="x", on_complete=boom)
assert m.get_result("x", timeout=5) == "A"
finally:
m.shutdown()
def test_func_base_exception_fails_job_and_resolves_result():
m = BackgroundJobManager(max_workers=1)
delivered = threading.Event()
seen = []
def on_complete(info):
seen.append(info.status)
delivered.set()
try:
m.start_job(
lambda: (_ for _ in ()).throw(SystemExit()), job_id="x",
on_complete=on_complete,
)
with pytest.raises(SystemExit):
m.get_result("x", timeout=5)
assert m.get_status("x") == JobStatus.FAILED
assert m.list_jobs()["x"].error == "SystemExit" # never an empty error
assert delivered.wait(5) and seen == [JobStatus.FAILED] # failure not silent
finally:
m.shutdown()
def test_queued_job_dropped_at_shutdown_is_cancelled():
m = BackgroundJobManager(max_workers=1)
gate = threading.Event()
try:
m.start_job(gate.wait, job_id="a")
_wait_status(m, "a", JobStatus.RUNNING)
seen = []
m.start_job(lambda: "B", job_id="b", on_complete=seen.append)
m._executor.shutdown(wait=False, cancel_futures=True)
assert m.get_status("b") == JobStatus.CANCELLED
with pytest.raises(CancelledError):
m.get_result("b")
assert seen == [] # cancellations stay silent, like cancel_job()
finally:
gate.set()
m.shutdown()
@@ -0,0 +1,104 @@
"""Regression tests for issue #5870: observer isolation, atomic state writes, git deadlines."""
import asyncio
import json
import subprocess
import pytest
from praisonaiagents import main as _main
from praisonaiagents.approval import PermissionAllowlist
def test_raising_sync_callback_does_not_propagate():
def boom(**kw):
raise RuntimeError("callback bug")
_main.register_display_callback("interaction", boom)
_main.execute_sync_callback("interaction", message="hi") # must not raise
def test_raising_callbacks_do_not_propagate_async():
seen = []
def boom(**kw):
raise RuntimeError("sync bug")
async def aboom(message):
seen.append(message)
raise RuntimeError("async bug")
_main.register_display_callback("interaction", boom)
_main.register_display_callback("interaction", aboom, is_async=True)
asyncio.run(_main.execute_callback("interaction", message="hi", extra=1))
assert seen == ["hi"]
def test_allowlist_failed_save_keeps_previous_file(tmp_path):
p = str(tmp_path / "allow.json")
a = PermissionAllowlist()
a.add_tool("read_file", paths=["/ok"])
a.save(p)
good = open(p).read()
a._tools["read_file"].allowed_paths = ["/ok", object()]
with pytest.raises(TypeError):
a.save(p)
assert open(p).read() == good
assert PermissionAllowlist.load(p).list_tools() == ["read_file"]
def test_allowlist_load_quarantines_corrupt_file(tmp_path):
p = tmp_path / "allow.json"
p.write_text('{"tools": {')
assert PermissionAllowlist.load(str(p)).list_tools() == []
assert not p.exists()
assert list(tmp_path.glob("allow.json.*.corrupt"))
def _timeout_run(calls):
def fake_run(cmd, **kwargs):
calls.append(kwargs)
raise subprocess.TimeoutExpired(cmd, kwargs.get("timeout"))
return fake_run
def test_git_fetch_timeout_without_cache_returns_empty(tmp_path, monkeypatch):
from praisonaiagents.skills import remote
calls = []
monkeypatch.setattr(remote.subprocess, "run", _timeout_run(calls))
src = remote.GitRemoteSkillSource("http://127.0.0.1:1/org/skills.git")
assert src.fetch(tmp_path) == []
assert calls[0]["timeout"] == remote._GIT_TIMEOUT
assert calls[0]["env"]["GIT_TERMINAL_PROMPT"] == "0"
def test_git_fetch_timeout_falls_back_to_cache(tmp_path, monkeypatch):
from praisonaiagents.skills import remote
src = remote.GitRemoteSkillSource("http://127.0.0.1:1/org/skills.git")
skill = src._cache_base(tmp_path) / remote._CURRENT_LINK / "my-skill"
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text("---\nname: my-skill\n---\n")
monkeypatch.setattr(remote.subprocess, "run", _timeout_run([]))
result = src.fetch(tmp_path)
assert len(result) == 1
assert (result[0] / "my-skill" / "SKILL.md").exists()
def test_config_dump_preserves_symlink_and_mode(tmp_path):
import os
from praisonaiagents.config.loader import _dump_config_file
shared = tmp_path / "shared.yaml"
shared.write_text("plugins: {}\n")
os.chmod(shared, 0o644)
link = tmp_path / "proj" / "config.yaml"
link.parent.mkdir()
link.symlink_to(shared)
_dump_config_file(link, {"plugins": {"enabled": ["x"]}})
assert link.is_symlink()
assert "x" in shared.read_text()
assert shared.stat().st_mode & 0o777 == 0o644
@@ -0,0 +1,154 @@
"""
Regression tests for FileScheduleStore persistence (issue #5899).
- Run history from two stores on the same dir is merged, not last-writer-wins.
- A failed save from add/update/remove raises, keeps memory consistent with
disk, and does not fire lifecycle hooks.
"""
import os
from unittest import mock
import pytest
from praisonaiagents.scheduler import store as store_mod
from praisonaiagents.scheduler.models import Schedule, ScheduleJob
from praisonaiagents.scheduler.store import FileScheduleStore
def _make_job(name="job"):
return ScheduleJob(
name=name,
schedule=Schedule(kind="every", every_seconds=60),
message="hello",
)
def test_history_not_lost_across_stores(tmp_path):
d = str(tmp_path)
a = FileScheduleStore(store_dir=d)
b = FileScheduleStore(store_dir=d)
a.log_run("j1", "succeeded", job_name="n1")
b.log_run("j2", "succeeded", job_name="n2")
ids = [r.job_id for r in FileScheduleStore(store_dir=d).get_history()]
assert ids == ["j2", "j1"]
def test_history_respects_max_after_reload(tmp_path):
d = str(tmp_path)
a = FileScheduleStore(store_dir=d, max_history=2)
b = FileScheduleStore(store_dir=d, max_history=2)
a.log_run("j1", "succeeded")
b.log_run("j2", "succeeded")
a.log_run("j3", "succeeded")
assert [r.job_id for r in a.get_history()] == ["j3", "j2"]
def _fail_replace(*args, **kwargs):
raise OSError("disk full")
def test_add_failed_save_raises_and_rolls_back(tmp_path):
d = str(tmp_path)
s = FileScheduleStore(store_dir=d)
with mock.patch("praisonaiagents.utils.atomic_io.os.replace", _fail_replace), \
mock.patch.object(store_mod, "emit_schedule_add") as emit:
with pytest.raises(OSError):
s.add(_make_job())
emit.assert_not_called()
assert s.list() == []
assert FileScheduleStore(store_dir=d).list() == []
assert not [f for f in os.listdir(d) if f.endswith(".tmp")]
def test_update_failed_save_restores_previous(tmp_path):
d = str(tmp_path)
s = FileScheduleStore(store_dir=d)
job = _make_job()
s.add(job)
changed = ScheduleJob.from_dict(job.to_dict())
changed.message = "changed"
with mock.patch("praisonaiagents.utils.atomic_io.os.replace", _fail_replace):
with pytest.raises(OSError):
s.update(changed)
assert s.get(job.id).message == "hello"
def test_remove_failed_save_keeps_job(tmp_path):
d = str(tmp_path)
s = FileScheduleStore(store_dir=d)
job = _make_job()
s.add(job)
with mock.patch("praisonaiagents.utils.atomic_io.os.replace", _fail_replace), \
mock.patch.object(store_mod, "emit_schedule_remove") as emit:
with pytest.raises(OSError):
s.remove(job.id)
with pytest.raises(OSError):
s.remove_by_name(job.name)
emit.assert_not_called()
assert s.get(job.id) is not None
def _log_many(d, prefix, n):
s = FileScheduleStore(store_dir=d, max_history=1000)
for i in range(n):
s.log_run(f"{prefix}{i}", "succeeded")
def test_history_overlapping_processes_keep_all_records(tmp_path):
import multiprocessing as mp
d = str(tmp_path)
ctx = mp.get_context("spawn")
procs = [ctx.Process(target=_log_many, args=(d, p, 20)) for p in ("a", "b")]
for p in procs:
p.start()
for p in procs:
p.join(60)
assert p.exitcode == 0
ids = {r.job_id for r in FileScheduleStore(store_dir=d, max_history=1000).get_history()}
assert ids == {f"{p}{i}" for p in ("a", "b") for i in range(20)}
def test_unreadable_history_is_not_overwritten(tmp_path):
d = str(tmp_path)
s = FileScheduleStore(store_dir=d)
s.log_run("j1", "succeeded")
with mock.patch.object(FileScheduleStore, "_read_history", return_value=None):
s.log_run("j2", "succeeded")
assert [r.job_id for r in FileScheduleStore(store_dir=d).get_history()] == ["j1"]
# The unsaved record is retried once history is readable again.
s.log_run("j3", "succeeded")
assert [r.job_id for r in FileScheduleStore(store_dir=d).get_history()] == ["j3", "j2", "j1"]
def test_failed_history_save_is_retried(tmp_path):
d = str(tmp_path)
s = FileScheduleStore(store_dir=d)
with mock.patch("praisonaiagents.utils.atomic_io.os.replace", _fail_replace):
s.log_run("j1", "succeeded")
s.log_run("j2", "succeeded")
assert [r.job_id for r in FileScheduleStore(store_dir=d).get_history()] == ["j2", "j1"]
def test_retried_record_does_not_hide_newer_records(tmp_path):
d = str(tmp_path)
a = FileScheduleStore(store_dir=d, max_history=2)
b = FileScheduleStore(store_dir=d, max_history=2)
with mock.patch.object(store_mod.time, "time", return_value=100.0):
with mock.patch("praisonaiagents.utils.atomic_io.os.replace", _fail_replace):
a.log_run("old", "succeeded")
with mock.patch.object(store_mod.time, "time", return_value=200.0):
b.log_run("newer", "succeeded")
with mock.patch.object(store_mod.time, "time", return_value=300.0):
a.log_run("newest", "succeeded")
ids = [r.job_id for r in FileScheduleStore(store_dir=d).get_history()]
assert ids == ["newest", "newer"]
def test_mark_run_contains_store_write_error(tmp_path):
from praisonaiagents.scheduler.runner import ScheduleRunner
s = FileScheduleStore(store_dir=str(tmp_path))
job = _make_job()
s.add(job)
with mock.patch("praisonaiagents.utils.atomic_io.os.replace", _fail_replace):
ScheduleRunner(s).mark_run(job) # must not raise
@@ -33,6 +33,54 @@ def test_web_crawl_rejects_redirect_to_loopback():
assert _is_safe_crawl_url("http://example.com/") is True
def test_web_crawl_urllib_fallback_does_not_follow_redirect_to_internal(monkeypatch):
import http.server
import sys
import threading
from praisonaiagents.tools import web_crawl_tools as w
monkeypatch.setitem(sys.modules, "httpx", None) # force the urllib fallback
hits = []
class Internal(http.server.BaseHTTPRequestHandler):
def do_GET(self):
hits.append(self.path)
self.send_response(200)
self.end_headers()
self.wfile.write(b"<html>internal-metadata</html>")
def log_message(self, *a):
pass
internal = http.server.HTTPServer(("127.0.0.1", 0), Internal)
class Redir(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(302)
self.send_header("Location", f"http://127.0.0.1:{internal.server_port}/meta")
self.end_headers()
def log_message(self, *a):
pass
redir = http.server.HTTPServer(("127.0.0.1", 0), Redir)
for server in (internal, redir):
threading.Thread(target=server.serve_forever, daemon=True).start()
start = f"http://127.0.0.1:{redir.server_port}/"
real = w._is_safe_crawl_url
monkeypatch.setattr(w, "_is_safe_crawl_url", lambda u: u == start or real(u))
try:
result = w._crawl_with_httpx([start])
finally:
internal.shutdown()
redir.shutdown()
assert hits == []
assert "internal-metadata" not in result[0]["content"]
assert "SSRF" in result[0]["error"]
def test_searxng_may_use_loopback_but_the_crawler_may_not():
"""One predicate, two callers, opposite requirements. Pin both."""
from praisonaiagents.tools.url_safety import validate_searxng_url
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff. Show more